diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e0d9d14
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+.DS_Store
+local-data
+node_modules/
+coverage/
+*.log
+
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..34eb66f
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,63 @@
+# Code of Conduct
+
+## Purpose
+
+`mikuproject` aims to be a respectful and practical collaboration space for bug reports, feature requests, design discussion, documentation work, and code contributions.
+
+The goal is not to avoid disagreement. The goal is to keep discussion specific, constructive, and safe for participants.
+
+## Expected Behavior
+
+- Be respectful.
+- Be specific.
+- Prefer concrete repro steps, fixtures, examples, and tests over vague claims.
+- Critique ideas, code, assumptions, and designs without attacking people.
+- Keep technical disagreement focused on behavior, tradeoffs, and evidence.
+- Accept that maintainers may ask to narrow scope, add tests, or clarify intent before accepting a change.
+
+## Unacceptable Behavior
+
+- Harassment, intimidation, or personal attacks
+- Discriminatory, hateful, or abusive language
+- Repeated hostile or bad-faith argument
+- Dismissing or insulting contributors instead of addressing the technical point
+- Publishing private or sensitive information without permission
+- Spam, trolling, or deliberately disruptive behavior
+
+## Scope
+
+This code of conduct applies to project spaces such as:
+
+- Issues
+- Pull requests
+- Discussions and review comments
+- Documentation contributions
+- Other project-related public collaboration spaces managed for `mikuproject`
+
+## Maintainer Responsibility
+
+Project maintainers may moderate discussions and contributions to keep the project usable and collaborative.
+
+This may include:
+
+- Asking for clarification or tone adjustments
+- Hiding, editing, locking, or closing discussions when appropriate
+- Rejecting contributions that are technically unsuitable or behaviorally disruptive
+- Limiting further participation in project spaces if necessary
+
+## Reporting
+
+If you experience or observe behavior that should be addressed, contact the project maintainer through a project channel that is appropriate for the situation.
+
+If a public thread would make the issue worse, prefer a private contact path instead of escalating in public.
+
+## Project Style
+
+For `mikuproject`, the preferred collaboration style is:
+
+- specific
+- respectful
+- test-oriented
+- focused on reproducible behavior
+
+Technical rigor is welcome. Personal hostility is not.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..2e0130d
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,145 @@
+# Contributing to mikuproject
+
+Thank you for contributing to `mikuproject`.
+
+This project accepts bug reports, feature requests, documentation fixes, tests, and pull requests.
+
+## Before You Open an Issue or Pull Request
+
+- Check whether the topic is already covered by an existing issue or pull request.
+- For behavior changes, describe the expected behavior and the current behavior clearly.
+- For code changes, include or update tests when practical.
+- Keep changes focused. Small, reviewable pull requests are preferred.
+
+## Development Notes
+
+- `index.html` and `mikuproject.html` are generated files.
+- Edit `index-src.html`, `mikuproject-src.html`, and files under `src/` instead of editing generated output directly unless regeneration is intentionally part of the change.
+- Application logic should normally be edited in `src/ts/`.
+- `src/js/` is generated from `src/ts/`, but is currently committed to Git. If you change `src/ts/`, regenerate `src/js/` as part of the same change.
+- When a behavior change affects project structure, input/output rules, or AI integration, update the relevant documentation as well.
+
+Documentation roles:
+
+- `README.md`: repository entry point and quick start
+- `docs/architecture.md`: overall structure, build flow, generated files, and operational rules
+- `docs/spec.md`: format and behavior specifications
+- `docs/TODO.md`: incomplete work only
+
+## Recommended Checks
+
+Run relevant commands before submitting a pull request when possible.
+
+```bash
+npm run build:js
+npm run build:html
+npm test
+```
+
+If your change touches sample workbook generation, also run:
+
+```bash
+npm run build:xlsx-sample
+```
+
+## Pull Request Guidelines
+
+- Explain what changed and why.
+- Mention any user-visible behavior change.
+- Mention any specification or documentation updates if they are part of the change.
+- If a change is incomplete or intentionally deferred, say so explicitly.
+
+## Contribution License
+
+By submitting an issue, pull request, comment, documentation change, code change, or other material that is intentionally submitted for inclusion in this project, you agree that:
+
+- Your contribution is provided under the Apache License 2.0 used by this repository.
+- The project maintainer may use, modify, rewrite, adapt, edit, and redistribute your contribution as part of this project, as permitted by the project license structure.
+- You have the right to submit the contribution.
+- Unless you explicitly state otherwise, your contribution is treated as a "Contribution" under Section 5 of the Apache License 2.0.
+
+If you do not want a submission to be treated as a contribution for inclusion in the project, mark that clearly and do not open it as a pull request intended to be merged.
+
+## Attribution
+
+Contributors may be acknowledged in project history, release notes, or other project documents at the maintainer's discretion.
+
+## Code of Collaboration
+
+- Be specific.
+- Be respectful.
+- Prefer concrete repro steps, fixtures, and tests over vague reports.
+
+---
+
+# mikuproject へのコントリビュート
+
+`mikuproject` へのコントリビュートありがとうございます。
+
+このプロジェクトでは、バグ報告、機能提案、ドキュメント修正、テスト追加、Pull Request を受け付けます。
+
+## Issue / Pull Request の前に
+
+- 既存の issue / pull request と重複していないか確認してください。
+- 挙動変更を伴う場合は、期待する挙動と現在の挙動を明確に書いてください。
+- コード変更では、可能なら対応するテストも追加または更新してください。
+- 変更は小さく、レビューしやすい単位が望ましいです。
+
+## 開発メモ
+
+- `index.html` と `mikuproject.html` は生成物です。
+- 生成物を直接編集するのではなく、通常は `index-src.html`、`mikuproject-src.html`、`src/` 配下を編集してください。
+- アプリロジックの変更は、通常 `src/ts/` で行ってください。
+- `src/js/` は `src/ts/` から生成されますが、現状では Git 管理しています。`src/ts/` を変更した場合は、同じ変更で `src/js/` も更新してください。
+- project 構造、入出力ルール、生成AI 連携の挙動を変える場合は、関連ドキュメントも更新してください。
+
+ドキュメントの役割:
+
+- `README.md`: リポジトリの入口と quick start
+- `docs/architecture.md`: 全体構成、ビルド、生成物、運用ルール
+- `docs/spec.md`: 形式仕様と挙動仕様
+- `docs/TODO.md`: 未完了作業のみ
+
+## 推奨チェック
+
+可能であれば、Pull Request 前に関連コマンドを実行してください。
+
+```bash
+npm run build:js
+npm run build:html
+npm test
+```
+
+sample workbook 生成に関わる変更では、あわせて次も実行してください。
+
+```bash
+npm run build:xlsx-sample
+```
+
+## Pull Request のガイド
+
+- 何を変えたか、なぜ変えたかを書いてください。
+- ユーザーに見える挙動変更があれば明記してください。
+- 仕様書やドキュメント更新を含む場合は、その旨も書いてください。
+- 未完了部分や意図的に後回しにした点があれば、明示してください。
+
+## コントリビューションのライセンス
+
+このプロジェクトへ取り込みを意図して issue、pull request、コメント、ドキュメント変更、コード変更、その他の素材を提出した場合、次に同意したものとして扱います。
+
+- あなたのコントリビューションは、このリポジトリで採用している Apache License 2.0 の下で提供されます。
+- プロジェクト管理者は、そのコントリビューションを本プロジェクトの一部として、プロジェクトのライセンス構造で許容される範囲で、利用、修正、書き換え、調整、編集、再配布できます。
+- あなたは、そのコントリビューションを提出する権利を持っています。
+- あなたが明示的に別扱いを示さない限り、その提出物は Apache License 2.0 第5条の "Contribution" として扱われます。
+
+取り込みを意図しない連絡については、その旨を明確に示してください。マージを前提としない相談は、その前提が分かるように記述してください。
+
+## 謝辞
+
+コントリビューター名は、必要に応じて git 履歴、リリースノート、その他の文書で言及されることがあります。
+
+## コラボレーション方針
+
+- 具体的に書く
+- 相手を尊重する
+- 曖昧な説明より、再現手順、fixture、テストを優先する
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
new file mode 100644
index 0000000..115de9d
--- /dev/null
+++ b/CONTRIBUTORS.md
@@ -0,0 +1,6 @@
+# Contributors
+
+This project includes contributions, feedback, and improvement suggestions from the following people. Thank you for helping improve `mikuproject`.
+
+- Toshiki Iga
+ - Original author and initial maintainer.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..99d6868
--- /dev/null
+++ b/README.md
@@ -0,0 +1,99 @@
+# mikuproject
+
+`mikuproject` は、`MS Project XML` を基軸に、変換・可視化・限定編集を行うローカル HTML ツールです。
+
+`mikuproject` の強みは、同じプロジェクト情報を 1 つの意味体系のまま、用途に応じて複数の形式へ出し分けられることです。`MS Project XML` を基軸に、`XLSX`、`Markdown`、`JSON`、`Mermaid`、生成AI向け表現、そして必要に応じて `MS Project` へも橋渡しできるため、資料共有・レビュー・変換・再利用のそれぞれの場面に合わせて、無理なく形を変えて届けられます。
+
+特に、次の 3 つを重視して設計しています。
+
+- `MS Project XML` を基軸にした変換・可視化・限定編集
+- 生成AI 連携を意識した projection / 再取込
+- 人が読むための `WBS Excel ブック (.xlsx)` 帳票出力
+
+配布物は `mikuproject.html` ひとつの single-file web app で、Web ブラウザさえあればインストール不要・ネットワーク不要で利用できます。
+
+`MS Project XML` を意味の基軸として扱い、`.xlsx` と workbook JSON は確認・可視化・限定編集のための周辺表現として扱います。生成AI 連携の編集用 JSON は、workbook JSON と区別するため当面 `.editjson` 拡張子を推奨します。
+
+## 代表的なユースケース
+
+- その1: 管理用の Excel ブックに必要な情報を入力し、`mikuproject` を用いて `WBS Excel ブック (.xlsx)` 形式へ変換する
+- その2: 生成AI に専用プロンプトをセットして会話し、WBS 草案を作成する。生成された JSON を `mikuproject` へ入力し、`WBS Excel ブック (.xlsx)` 形式へ変換する
+- その3: `MS Project` のデータを `MS Project XML` 形式でエクスポートし、それを入力として `WBS Excel ブック (.xlsx)` 形式へ変換する
+
+## スクリーンショット
+
+### Input
+
+`Load from file`、`サンプル`、`生成AI連携` から入力を受け付ける。
+
+
+
+### Overview
+
+内部モデルの確認、validation、native SVG preview をここで行う。
+
+
+
+### Output
+
+`MS Project XML`、`XLSX`、workbook JSON、`WBS XLSX`、Mermaid、生成AI向け `.editjson` をここから保存する。
+
+
+
+### WBS Excel ブック (.xlsx)
+
+人が読むための帳票として出力される `WBS Excel ブック (.xlsx)` の例。
+
+
+
+## できること
+
+- `MS Project XML` の読込
+- `ProjectModel` への変換と内容確認
+- `MS Project XML` の再生成
+- Mermaid gantt テキスト生成
+- `CSV + ParentID` のファイル読込とダウンロード
+- 構造忠実な `Project / Tasks / Resources / Assignments / Calendars` workbook の `XLSX Export / Import`
+- 構造忠実な `Project / Tasks / Resources / Assignments / Calendars` workbook の `JSON Export / Import`
+- 表示専用の `WBS XLSX Export`
+- 生成AI向け `project_overview_view` / `phase_detail_view` / `full bundle` の出力
+- 生成AIが返した `project_draft_view` の取込
+
+## 使い始め方
+
+もっとも簡単なのは、生成済みの [mikuproject.html](mikuproject.html) をブラウザで開く方法です。
+
+画面上では主に次を行えます。
+
+- `Load from file` からの `MS Project XML / XLSX / workbook JSON (.json) / 生成AI向け編集用 JSON (.editjson) / CSV + ParentID` の読込
+- `project_draft_view` ベースで生成したサンプル XML の読込
+- 生成AIが返した `project_draft_view` の JSON 貼り付け取込
+- 内部モデル、validation、native SVG preview、各 preview の確認
+- `MS Project XML / XLSX / WBS XLSX / workbook JSON / CSV + ParentID / Mermaid / 生成AI向け .editjson` の保存
+
+## 開発
+
+```bash
+npm install
+npm run build:js
+npm run build:html
+npm run build:xlsx-sample
+npm test
+npm run build
+```
+
+`local-data/` は確認用の再生成可能な生成物置き場として扱う。ここに出す sample や検証用出力は、Git 管理下の永続成果物ではなく、必要時に再生成できればよい前提とする。
+
+## 関連ドキュメント
+
+- [docs/architecture.md](docs/architecture.md)
+- [docs/spec.md](docs/spec.md)
+- [docs/gap-notes.md](docs/gap-notes.md)
+- [docs/mikuproject-ai-json-spec.md](docs/mikuproject-ai-json-spec.md)
+- [docs/msprojectxml-ai-integration.md](docs/msprojectxml-ai-integration.md)
+- [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)
+- [docs/TODO.md](docs/TODO.md)
+- [CONTRIBUTING.md](CONTRIBUTING.md)
+- [CONTRIBUTORS.md](CONTRIBUTORS.md)
+- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
+- [LICENSE](LICENSE)
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
new file mode 100644
index 0000000..22bf3b5
--- /dev/null
+++ b/THIRD-PARTY-NOTICES.md
@@ -0,0 +1,41 @@
+# Third-Party Notices
+
+This document lists third-party software and reference materials used or referred to by `mikuproject`.
+
+## Third-party software
+
+## Reference materials
+
+### `open-msp-viewer`
+
+- Usage: Referred to as a temporary source of sample data for verification
+- License: Apache License 2.0
+- Source: https://github.com/rpbouman/open-msp-viewer/
+
+### MicrosoftDocs Project XML Data Interchange reference
+
+- Usage: Referred to when design decisions for `MS Project XML` handling are unclear. This reference is used together with the Microsoft-hosted Project 2007 schema endpoints such as `https://schemas.microsoft.com/project/2007/` and `mspdi_pj12.xsd`.
+- License: CC-BY-4.0 for documentation and MIT for code
+- Source: https://github.com/MicrosoftDocs/office-developer-msproject-xml-docs/tree/main/project-xml-data-interchange
+
+---
+
+# 第三者告知
+
+この文書は、`mikuproject` が利用または参照している第三者ソフトウェアおよび参考資料を記載したものです。
+
+## 第三者ソフトウェア
+
+## 参考資料
+
+### `open-msp-viewer`
+
+- 用途: 検証用サンプルデータの一時的な参照元
+- ライセンス: Apache License 2.0
+- Source: https://github.com/rpbouman/open-msp-viewer/
+
+### MicrosoftDocs Project XML Data Interchange reference
+
+- 用途: `MS Project XML` の扱いで設計判断に迷った場合の補助資料。`https://schemas.microsoft.com/project/2007/` や `mspdi_pj12.xsd` などの Microsoft 側 schema 実体とあわせて参照する。
+- ライセンス: 文書は CC-BY-4.0、コードは MIT
+- Source: https://github.com/MicrosoftDocs/office-developer-msproject-xml-docs/tree/main/project-xml-data-interchange
diff --git a/docs/TODO.md b/docs/TODO.md
new file mode 100644
index 0000000..6c68d01
--- /dev/null
+++ b/docs/TODO.md
@@ -0,0 +1,71 @@
+# TODO
+
+この文書には、未完了の作業だけを書く。概要説明や仕様判断は `README.md` と `docs/spec.md` に寄せる。
+
+## mikuproject
+
+- 最優先: サンプルデータを更新し、利用者の好みに合う題材・構造・見た目へ見直す
+- `excel-io` の workbook スタイルにフォントサイズ指定を追加し、XLSX 出力で大きい見出し文字を使えるようにする
+- WBS workbook と `mikuproject-sample.xlsx` のタイトル行で、フォントサイズ指定をどこまで使うか整理する
+- `Mermaid` 出力は Markdown / 設計資料向けに残しつつ、見た目を制御しやすい native SVG 描画を別系統で追加するか検討する
+- native `SVG` について、今の既定である `近接ラベル` 表示だけを残し、左側にテキストを描画する `一覧ラベル` モードは将来的に廃止したい
+- `mikuproject` の主要入出力を CLI からも扱えるようにするか検討する
+- 作成するテキストファイルについて、BOM 付き / なしを切り替えるスイッチを追加する
+- `local-data/` 配下のファイルを、参照用・検証用・生成物で整理する
+- `local-data/` に置くべきでない生成物や一時ファイルがないか見直す
+- `npm test` を `scripts/run-tests.mjs` ベースへ切り替えるか検討する
+- `main.test.js` に残っている `xlsx import` の file input wiring を、UI 配線確認と import 結果確認へさらに分離する
+- `XLSX Import` の実地回帰観点を明文化し、少なくとも次を継続確認する
+ - export した `.xlsx` をそのまま import できる
+ - Excel で 1 セル変更した `.xlsx` を import できる
+ - 同じファイル名で保存し直した `.xlsx` を連続 import できる
+ - 空 editable セルを埋めた変更を import できる
+ - `Name / Start / Finish / PercentComplete / PercentWorkComplete / Notes` など主要 editable 列が戻る
+ - `Milestone / Summary / Critical` など表示専用列は戻らない
+- `main.ts` の summary / validation / preview 描画を別モジュール化し、DOM テストをさらに軽くする
+- `phase_detail_view scoped` の `phase UID / root UID / max depth` 指定を、より選びやすい UI に改善する
+- `task_edit_view` の projection を実装する
+- `.editjson` の import で現状 `project_draft_view` だけを受けている制約を見直し、将来の `task_edit_view` / Patch JSON など他の `view_type` も扱えるようにする
+- 既存 project 向け Patch JSON の schema を具体化する
+- Patch JSON を内部モデルへ適用する処理を実装する
+- Patch JSON import 後の validation と差分要約 UI を実装する
+- import 前後で、どの `task / calendar / assignment` がどう変わったかを見やすく確認できる差分可視化を追加する
+- 差分適用を前提として、生成AI や外部編集結果を全件置換ではなく部分適用できる運用を強化する
+- `project_draft_request` を UI から生成しやすくするか整理する
+- UI の微調整として、`Input / Overview / Output` の各カードの余白・見出し・ボタン階層を見直し、`miku` 系テーマの統一感をさらに整える
+- `Overview` タブの summary / validation / preview の情報密度を見直し、どこを見る画面なのかをより直感的に伝わる構成へ調整する
+- `Output` タブの生成AI連携と各種 export ボタンの優先度表現を見直し、主操作と補助操作の区別をより明確にする
+- calendar 未設定時に自動補完する既定 calendar の祝日 `Exceptions` を、project 期間内だけに限定する
+- WBS 上で土日と祝日を別色表示する場合も、`MS Project XML` 正本へ独自の非稼働日種別を追加せず、`WeekDays / Exceptions` の由来で描き分ける
+- `build:xlsx-sample` の所要時間を個別計測し、sample workbook 生成処理の支配要因を確認する
+- `main.test.js` の初期化 DOM をケース別に最小化できるか見直す
+- CI 向けに `test:fast` と `test:full` のような実行導線を分けるか検討する
+- `docs/spec.md` に残っている実装済み前提との差分を定期的に解消する
+- 正本 / 表示用 / import 対象 / export 専用 の扱いを、UI または docs で分かりやすく可視化する
+- `.xlsx import` の次段として、どのシート・列を今後 import 対象に広げるか整理する
+- タスク実績について、`ActualStart / ActualFinish / ActualWork / RemainingWork / ActualCost / RemainingCost` などを今後どう扱うか整理し、将来的に対応する
+- 将来検討: Earned Value (`PV / EV / AC / SPI / CPI` など) をどこまで扱うか整理し、必要なら対応する
+- 実績・Earned Value 系は、いきなり広く対応せず、まず最小整理と小さな仕様を作って MVP から段階的に進める
+- WBS 用の `ステータス` は `Task.ExtendedAttribute` で扱う前提で、`FieldID / FieldName / 値候補` を設計する
+- `TaskStatus` 用 `ExtendedAttribute` を `mikuproject-sample.xlsx` と `WBS workbook` のどちらまで見せるか決める
+- `TaskStatus` 用 `ExtendedAttribute` の値候補と、`PercentComplete` / `Active` との関係を整理する
+- 画面検索ではなく、条件指定にもとづく task の部分 export / scoped export を強化できるか整理する
+- `phase_detail_view scoped` の延長として、phase 単位の入出力をうまく取り回す方法を整理し、使い勝手のよい導線を検討する
+- 画面では `Calendars / Exceptions` を read-only 確認に留める前提で、`XLSX Import` 側の `WeekDays / Exceptions / WorkWeeks` 編集導線をどこまで整えるか整理する
+- `Calendar / Baseline / TimephasedData / ExtendedAttributes` をどの順で扱うか優先順位を決める
+- validation について、warning の重要度分け、修正候補のヒント、入力由来別の注意をどこまで出すか整理する
+- `mikuproject-sample.xlsx` の `Project` シートで、構造忠実方針を崩さない範囲の見た目調整を続ける
+- `mikuproject-sample.xlsx` の `Resources / Assignments / NonWorkingDays` で、強調色が過剰にならない最終バランスを調整する
+- `WBS` の `プロジェクト情報` / `凡例` などと、`Project` シートの `Basic Info` に入っているドット編みかけ表現を除去する
+- WBS workbook の表示改善を継続する
+- WBS workbook の見た目改善と、構造忠実 workbook との責務分離を保つ
+- WBS について、完了タスクの表示 / 非表示を切り替えるオプションを追加する
+- 将来検討: WBS workbook について、表示専用列と Excel 再利用向けの機械利用列(hidden 列)の分離が必要か整理する
+- WBS Markdown の `プロジェクト情報` / `サマリ` / `WBS ツリー` / `WBS テーブル` をどう出すか sample ベースで固める
+- `project summary markdown` のような、WBS 以外の Markdown 出力拡張を検討する
+- `phase summary markdown` のような scoped Markdown 出力を追加するか検討する
+- 高優先: `WBS記述書 Markdown` の最小版に着手したいが、現時点では実施できないため保留にする
+- `WBS記述書 Markdown` 出力を追加し、task ごとの説明を別 Markdown として保存できるようにする
+- `WBS記述書` 用 `Task.ExtendedAttribute` の最小項目として `TaskPurpose / TaskDeliverable / TaskOutOfScope / TaskDoneDefinition / TaskOwner` を扱う
+- `WBS記述書 Markdown` では、長文補足を `Task.Notes` から出す
+- `WBS記述書 Markdown` の sample 出力を作成し、1 task 1 節構成で読みやすいか確認する
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..3738438
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,184 @@
+# Architecture
+
+## 概要
+
+`mikuproject` は、`MS Project XML` を意味の基軸として扱い、内部モデル `ProjectModel` を経由して複数の表現へ出し分ける構成を採る。
+
+重視しているのは次の 3 点である。
+
+- `MS Project XML` を基軸にした変換・可視化・限定編集
+- 生成AI 連携を意識した projection / 再取込
+- 人が読むための `WBS Excel ブック (.xlsx)` 帳票出力
+
+## 全体構成
+
+- 正本は `MS Project XML`
+- 内部の中立表現は `ProjectModel`
+- `.xlsx` と workbook JSON は補助的な入出力
+- まずは意味的ラウンドトリップを優先
+
+主な流れは次のとおり。
+
+- `MS Project XML -> ProjectModel -> MS Project XML`
+- `MS Project XML -> ProjectModel -> XLSX`
+- `MS Project XML -> ProjectModel -> mikuproject_workbook_json`
+- `MS Project XML -> ProjectModel -> Mermaid`
+- `MS Project XML -> ProjectModel -> 生成AI向け JSON projection`
+
+また、新規生成向けには次の流れを持つ。
+
+- `project_draft_view -> ProjectModel -> MS Project XML / WBS Excel ブック (.xlsx)`
+
+`XLSX Import` と workbook JSON import は、自由編集をそのまま受け入れるのではなく、限定列の部分更新として扱う。
+
+`mikuproject-sample.xlsx` は `MS Project XML` との対応関係を確認するための構造忠実 workbook として扱う。列やシートの対応関係は崩さず、見た目改善は可読性補助に留める。これに対して `mikuproject-wbs-sample.xlsx` は人が読むための表示重視 workbook として扱う。
+
+## Single-file Web App
+
+配布物は `mikuproject.html` ひとつの single-file web app である。Web ブラウザさえあればインストール不要・ネットワーク不要で利用できる。
+
+開発用 source は分割して管理するが、配布物としては single-file web app を維持する。
+
+- HTML source: `mikuproject-src.html`
+- TypeScript source: `src/ts/`
+- generated JavaScript: `src/js/`
+- CSS: `src/css/`
+
+`mikuproject.html` は `mikuproject-src.html` をもとに、ローカル CSS / JS を単一 HTML へインライン展開して生成する。
+
+## リポジトリ構成
+
+- `mikuproject.html`: 生成済みの単一 HTML アプリ
+- `mikuproject-src.html`: HTML ソース
+- `package.json`: Node.js ベースの開発設定
+- `src/ts/`: TypeScript ソース
+- `src/js/`: `src/ts/` から生成し、Git 管理も行うブラウザ実行用 JavaScript
+- `src/css/`: アプリ用 CSS
+- `tests/`: Vitest ベースのテスト
+- `testdata/`: XML テストデータ
+- `scripts/`: ビルド補助スクリプト
+- `docs/spec.md`: 現行仕様メモ
+- `docs/gap-notes.md`: 保持項目や互換性のギャップメモ
+
+## 画面構成
+
+### Input
+
+- `Load from file` からの `MS Project XML / XLSX / workbook JSON (.json) / 生成AI向け編集用 JSON (.editjson) / CSV + ParentID` の読込
+- `project_draft_view` ベースで生成したサンプル XML の読込
+- 生成AIが返した `project_draft_view` の JSON 貼り付け取込
+
+### Overview
+
+- 内部モデルの要約確認
+- validation メッセージの確認
+- native `SVG` プレビューの確認
+- `Project / Tasks / Resources / Assignments / Calendars` の preview 確認
+
+### Output
+
+- `MS Project XML` の保存
+- `XLSX` の保存
+- `WBS XLSX` の保存
+- `XLSX` 相当 workbook JSON の保存
+- `CSV + ParentID` の保存
+- Mermaid fenced code block を含む `.md` の保存
+- native `SVG` の保存
+- 生成AI向け `project_overview_view` / `phase_detail_view` / `full bundle` の `.editjson` 保存
+
+## 生成AI連携
+
+`mikuproject` は、生成AIとの直接連携に `MS Project XML` ではなく `JSON` を使う。
+
+- 既存 project 向けには `project_overview_view` と `phase_detail_view` を出力する
+- 既存 project 向けには `full bundle` も出力できる
+- 新規生成向けには、生成AIが返した `project_draft_view` を取り込める
+- `mikuproject_workbook_json` は `.json`、生成AI 向け編集用 JSON は `.editjson` を推奨拡張子とする
+- 現時点で UI から実装済みなのは `project_overview_view` / `phase_detail_view` / `full bundle` の出力と `project_draft_view` の取込である
+- `task_edit_view` と Patch JSON 適用は設計メモ段階で、まだ実装していない
+
+詳細な考え方は `docs/mikuproject-ai-json-spec.md` と `docs/msprojectxml-ai-integration.md` に置く。
+
+## 開発
+
+依存関係の導入:
+
+```bash
+npm install
+```
+
+TypeScript 由来のブラウザ実行 JavaScript を再生成:
+
+```bash
+npm run build:js
+```
+
+単一 HTML を再生成:
+
+```bash
+npm run build:html
+```
+
+サンプル XLSX を再生成:
+
+```bash
+npm run build:xlsx-sample
+```
+
+テスト実行:
+
+```bash
+npm test
+```
+
+ビルドとテストをまとめて実行:
+
+```bash
+npm run build
+```
+
+`npm run build` は `build:app` と `test` を順に実行する。
+
+スクリプトの役割は次のとおり。
+
+- `npm run build:js`: `src/ts/` から `src/js/` を生成する
+- `npm run build:html`: `index-src.html` と `mikuproject-src.html` から `index.html` と `mikuproject.html` を生成する
+- `npm run build:xlsx-sample`: `local-data/` 配下へサンプル XLSX / Markdown を生成する
+- `npm run build:app`: `build:js`、`build:html`、`build:xlsx-sample` を順に実行する
+
+`scripts/build-project.mjs` は `--js-only` と `--html-only` を受け取り、JavaScript 生成と HTML 生成を切り替える。
+
+## ソースと生成物の扱い
+
+`src/ts/` を正本として扱い、`src/js/` はそこから生成する中間生成物として扱う。ただし、現状では `src/js/` も Git 管理する。ブラウザ実行、テスト、`build:xlsx-sample` は `src/js/` を参照する。
+
+運用ルール:
+
+- アプリロジックの修正は原則 `src/ts/` で行う
+- `src/js/` は手編集の正本としては扱わない
+- `src/ts/` を更新した場合は `npm run build:js` を実行し、`src/js/` の差分もあわせて扱う
+
+## 現在の状態
+
+- `package.json` と `package-lock.json` を持つ単独の Node.js プロジェクトとして扱える
+- ソース配置は `src/ts/`, `src/js/`, `src/css/`
+- `npm run build:js`、`npm run build:html`、`npm test` は通る
+- `local-data/` と `node_modules/` は Git 管理対象外
+- `local-data/` は確認用の再生成可能な生成物置き場として扱う
+- `local-data/` 配下の sample や検証用出力は、消えてもよく、必要時に再生成できればよい前提とする
+
+## 制約
+
+- `MS Project` 実機は未保有
+- 目標は XML の完全一致ではなく、意味的に往復できること
+- `XLSX Import` の反映対象は限定列のみ
+- workbook JSON import の反映対象も同じ限定列のみ
+- `CSV + ParentID` は現在ファイルベースの補助入出力として扱う
+- `Calendars` の `WeekDays / Exceptions / WorkWeeks` などは現時点では反映対象外
+
+## ドキュメントの役割
+
+- `README.md`: このリポジトリの入口
+- `docs/architecture.md`: 全体構成、配布形態、ビルド、運用ルール
+- `docs/spec.md`: 仕様と設計判断の置き場
+- `docs/TODO.md`: 未完了作業の管理
diff --git a/docs/gap-notes.md b/docs/gap-notes.md
new file mode 100644
index 0000000..e567705
--- /dev/null
+++ b/docs/gap-notes.md
@@ -0,0 +1,298 @@
+# mikuproject gap notes
+
+`mikuproject` の次段を考えるための、実例 XML ベースの棚卸しメモ。
+
+前提:
+
+- 参照元は `local-data/` に一時配置した実例 XML
+- Git 管理対象の testdata ではない
+- ここでは `Project / Task / Resource / Assignment / Calendar` ごとに、実例で見えた主なタグを整理する
+- 目的は「現在の内部モデルで保持しているもの」と「今後候補になるもの」の差分を把握すること
+
+## 対象実例
+
+- `3PointPlan-example.xml`
+- `01145024.xml`
+- `Project_Grouping_and_Conditional_Formatting_Example.xml`
+- `link types.xml`
+
+## 現在の STEP 1 で保持済み
+
+### Project
+
+- `Name`
+- `Title`
+- `Author`
+- `Company`
+- `CreationDate`
+- `LastSaved`
+- `SaveVersion`
+- `CurrentDate`
+- `StartDate`
+- `FinishDate`
+- `ScheduleFromStart`
+- `DefaultStartTime`
+- `DefaultFinishTime`
+- `MinutesPerDay`
+- `MinutesPerWeek`
+- `DaysPerMonth`
+- `StatusDate`
+- `WeekStartDay`
+- `WorkFormat`
+- `DurationFormat`
+- `CalendarUID`
+
+### Task
+
+- `UID`
+- `ID`
+- `Name`
+- `OutlineLevel`
+- `OutlineNumber`
+- `WBS`
+- `Type`
+- `CalendarUID`
+- `Priority`
+- `Start`
+- `Finish`
+- `Duration`
+- `ActualStart`
+- `ActualFinish`
+- `Deadline`
+- `StartVariance`
+- `FinishVariance`
+- `Work`
+- `WorkVariance`
+- `TotalSlack`
+- `FreeSlack`
+- `Cost`
+- `ActualCost`
+- `RemainingCost`
+- `RemainingWork`
+- `ActualWork`
+- `Milestone`
+- `Summary`
+- `Critical`
+- `PercentComplete`
+- `PercentWorkComplete`
+- `Notes`
+- `ConstraintType`
+- `ConstraintDate`
+- `PredecessorLink`
+
+### Resource
+
+- `UID`
+- `ID`
+- `Name`
+- `Type`
+- `Initials`
+- `Group`
+- `WorkGroup`
+- `MaxUnits`
+- `CalendarUID`
+- `StandardRate`
+- `StandardRateFormat`
+- `OvertimeRate`
+- `OvertimeRateFormat`
+- `CostPerUse`
+- `Work`
+- `ActualWork`
+- `RemainingWork`
+- `Cost`
+- `ActualCost`
+- `RemainingCost`
+- `PercentWorkComplete`
+
+### Assignment
+
+- `UID`
+- `TaskUID`
+- `ResourceUID`
+- `Start`
+- `Finish`
+- `StartVariance`
+- `FinishVariance`
+- `Delay`
+- `Milestone`
+- `WorkContour`
+- `Units`
+- `Work`
+- `ActualWork`
+- `RemainingWork`
+- `Cost`
+- `ActualCost`
+- `RemainingCost`
+- `PercentWorkComplete`
+- `OvertimeWork`
+- `ActualOvertimeWork`
+
+### Calendar
+
+- `UID`
+- `Name`
+- `IsBaseCalendar`
+- `IsBaselineCalendar`
+- `BaseCalendarUID`
+- `WeekDays`
+- `Exceptions`
+- `WorkWeeks`
+
+## 実例で見えた主な未保持タグ
+
+### Project 候補
+
+優先度が高そう:
+
+- 直近の高優先候補は消化済み
+
+メモ:
+
+- `Project CalendarUID` と calendar 実体の整合 validation は追加済み
+- preview で `OutlineCodes / WBSMasks / ExtendedAttributes` の代表値を追えるようにした
+- `ProjectModel -> Mermaid gantt` の片方向補助出力に着手済み
+
+後回し候補:
+
+- `ExtendedAttributes` の完全対応
+- `ExtendedAttributes`
+
+### Task 候補
+
+優先度が高そう:
+
+- `Task CalendarUID` に紐づくカレンダー差分の可視化は前進済み
+
+メモ:
+
+- `Task CalendarUID` の存在 validation は追加済み
+- preview で task ごとの calendar 名を表示するようにした
+- predecessor の validation は task 名つきで追えるようにした
+
+実例で頻出だが重い:
+
+- `Baseline`
+- `TimephasedData`
+
+特記事項:
+
+- `UID=0`
+- 空 `Name`
+- `OutlineLevel=0`
+
+は実例で普通に出るため、validation では placeholder 扱いを考慮済み
+
+### Resource 候補
+
+優先度が高そう:
+
+- 直近の高優先候補は消化済み
+
+メモ:
+
+- preview で resource ごとの calendar 名を表示するようにした
+- validation 文言は resource 名つきで追いやすくした
+
+実例で頻出だが重い:
+
+- `Baseline`
+- `TimephasedData`
+
+### Assignment 候補
+
+優先度が高そう:
+
+- 直近の高優先候補は消化済み
+
+メモ:
+
+- preview で assignment から task/resource 名を追えるようにした
+- validation 文言は assignment UID と既知の task/resource 名を併記するようにした
+
+実例で頻出だが重い:
+
+- `Baseline`
+- `TimephasedData`
+- `ActualCost`
+- `RemainingCost`
+- `OvertimeWork`
+- `ActualOvertimeWork`
+
+特記事項:
+
+- `ResourceUID=-65535`
+
+は実例で未割当を示す特別値として扱う前提
+
+### Calendar 候補
+
+優先度が高そう:
+
+- 直近の高優先候補は消化済み
+
+メモ:
+
+- calendarPreview を追加済み
+- `Project / Task / Resource / BaseCalendar` からの参照数を preview で見えるようにした
+- `BaseCalendarUID` の自己参照 warning を追加済み
+
+後回し候補:
+
+- 直近の軽量候補は消化済み
+
+## 次に拾う候補
+
+現時点での優先順:
+
+1. 実例 XML ベースの `Calendar` 差分整理
+ - task/resource ごとの calendar 差分が実例でどう使われるかを棚卸しする
+2. `ExtendedAttributes` の次段整理
+ - project 以外の `ExtendedAttributes` をどこまで保持・表示するかを決める
+3. preview / validation の最終整形
+ - 現状でかなり揃ったので、残る文言や導線を必要最小限で整える
+4. 重い構造の着手判断
+ - `Baseline` / `TimephasedData` を STEP 2 に含めるか別段に切るかを決める
+5. 補助交換形式の次段整理
+ - `Mermaid gantt` の dependency 表現をどこまで育てるかを決める
+ - `CSV + ParentID` を別軸の交換形式として整理する
+
+補足メモ:
+
+- `CSV + ParentID` は、`ID / ParentID / Name` を最小列とする「まず押さえるべき、よくある交換形式」の第1候補として整理開始した
+- `Mermaid gantt` は可視化・共有向けの片方向補助出力、`CSV + ParentID` は編集・交換向けの候補として切り分けて考える
+- `CSV + ParentID` は最小出力と最小逆変換を実装済みで、現時点の出力列は `ID / ParentID / WBS / Name / Start / Finish / PredecessorID / Resource / PercentComplete / PercentWorkComplete / Milestone / Summary / Critical / Type / Priority / Work / CalendarUID / ConstraintType / ConstraintDate / Deadline / Notes`
+- UI には `CSV` のダウンロード導線と、CSV ファイル読込導線を追加済み
+- 逆変換では `ParentID` から task 階層を再構築し、`PredecessorID` と `Resource` から最小の dependency / resource / assignment を復元する
+- `PredecessorID / Resource` は `|` に加えて `,` `;` `、` の複数区切りを受け、trim と重複除去をかける
+- 構造エラーとして `ID` 重複、空 `Name`、自己参照 / 欠落 / 循環 `ParentID` を import 時点で弾くようにした
+- `single CSV` の次段比較候補として `tasks.csv / resources.csv / assignments.csv` をメモ化した
+- 判断軸は「人が 1 枚で編集しやすいか」と「resource / assignment を正規化して安全に往復できるか」のトレードオフになる
+- 複数 CSV に進む場合の最小草案として、`tasks.csv(ID / ParentID / Name)`, `resources.csv(ResourceID / Name)`, `assignments.csv(AssignmentID / TaskID / ResourceID)` を置く想定にした
+- 分割時の実装順は `tasks.csv -> resources.csv -> assignments.csv` が自然で、calendar はさらに次段と考える
+- `tasks.csv` については、`ParentID` 正本、`WBS` 補助、`ID / ParentID / Name` 必須、自己参照 / 欠落 / 循環 `ParentID` は import error、という最小仕様草案まで具体化した
+- `tasks.csv` の第1段では `Baseline / TimephasedData / ExtendedAttributes / cost 詳細` は含めず、task 単体属性に集中する
+- `resources.csv` については、`ResourceID` 正本、`Name` は必須だが重複非推奨、task との紐付けは持たず `assignments.csv` へ分離する、という最小仕様草案まで具体化した
+- `resources.csv` の第1段では `Baseline / TimephasedData / ExtendedAttributes / cost 実績詳細` は含めない
+- `assignments.csv` については、`AssignmentID / TaskID / ResourceID` 必須、`TaskID / ResourceID` は既存表を参照、assignment 固有の `Start / Finish / Units / Work / PercentWorkComplete` を持てる、という最小仕様草案まで具体化した
+- `assignments.csv` の第1段では `Baseline / TimephasedData / ExtendedAttributes / cost 詳細 / contour 系` は含めない
+
+## 後回しでよいもの
+
+- `Baseline` 系
+- `TimephasedData`
+- コスト系の完全保持
+- 表示設定
+- Project Server 連携系
+- `ExtendedAttributes` の完全対応
+
+## 判断メモ
+
+- STEP 2 は、まず「実例で頻出し、意味が分かりやすく、XML 往復しやすい項目」から拾うのがよい
+- `Baseline` や `TimephasedData` は重要だが、構造が重いため別段階が自然
+- 実例 XML を読む限り、parser 自体よりも「どこまでを内部モデルで保持するか」の整理が次の主題
+- preview / validation の detail 表示強化は一段進んだので、次は実例 XML を見ながら保持方針を詰める段階
+- 補助交換形式については、現時点では `single CSV` を主系統に維持し、複数 CSV は仕様草案止まりにするのが妥当
+- 複数 CSV へ切り替える条件は、同名 resource 衝突、多重 assignment の lossless 保持、`ResourceID` 正本連携の要求が具体化したとき
+- `Mermaid gantt` は、単一 predecessor かつ `FS` かつ lag なし かつ変換しやすい duration の task だけ `after ...` を使う部分ネイティブ化まで進めた
+- 複雑な predecessor は、`type` や `lag` を付けたコメントへ逃がす方針にした
+- `lag` は `PT...` をそのまま見せるのではなく、`2h` のような短い表現へ寄せるところまで進めた
diff --git a/docs/mikuproject-ai-json-spec.md b/docs/mikuproject-ai-json-spec.md
new file mode 100644
index 0000000..a0c946b
--- /dev/null
+++ b/docs/mikuproject-ai-json-spec.md
@@ -0,0 +1,397 @@
+# mikuproject AI JSON Prompt / Spec
+
+私たちは、これから取り組むプロジェクトの内容を理解し、WBS の観点から必要なマイルストーン / フェーズ / タスクを整理し、`mikuproject` に設定するための入力へ落とし込んでいきます。
+
+`mikuproject` は、`MS Project XML` を基軸に、変換・可視化・限定編集を行う single-file web app です。
+
+特に、次の 3 つを重視して設計しています。
+
+- `MS Project XML` を基軸にした変換・可視化・限定編集
+- 生成AI 連携を意識した projection / 再取込
+- 人が読むための `WBS Excel ブック (.xlsx)` 帳票出力
+
+`mikuproject` と私たち(生成AIを含む)の間のやり取りは、XML ではなく JSON ベースで行います。
+
+このプロンプトを読んだ直後は、内容を受け取ったことを示すために `OK` とだけ回答してください。
+
+## 現時点の実装状況
+
+- 実装済み: `project_overview_view` の export
+- 実装済み: `phase_detail_view` の export
+- 実装済み: `project_draft_view` の import
+- 未実装: `task_edit_view`
+- 未実装: Patch JSON の import / 適用
+- `project_draft_request` は補助的な構想・helper であり、現行 UI の主機能ではありません
+
+## 前提
+
+- AI へ渡される入力は用途別 projection JSON です
+- AI は説明文を返してよいです
+- 既存編集向けの Patch JSON は将来案として設計中です
+- `MS Project XML` は保存と互換のための外部形式ですが、AI は直接扱いません
+- workbook JSON と AI 向け編集用 JSON を混同しないため、当面 `project_draft_view` などの編集用 JSON には `.editjson` 拡張子を推奨します
+
+## 重要方針
+
+- 全体 JSON の再出力は禁止です
+- 不明な値を推測して補完してはいけません
+- 未指定項目は変更しない前提です
+- 与えられた projection と rules の範囲を超えて変更してはいけません
+- 業務意味が不明な場合、断定的な再設計は避けてください
+- 業務意味が不明な変更は候補案として扱ってください
+
+## Projection JSON の代表例
+
+- `project_overview_view`: プロジェクト全体の構造、粒度、主要節目を把握するための要約ビュー
+- `phase_detail_view`: 特定フェーズの task 群、主要 milestone、依存の要点を把握するための詳細ビュー
+- `task_edit_view`: 個別 task を安全に編集するための作業ビュー。現時点では未実装です
+- `project_draft_request`: 全く新規の project 草案を AI に生成させるための入力。現時点では設計メモ寄りです
+- `project_draft_view`: 新規 project 草案の全量出力。現時点では import 済みです
+
+## ファイル拡張子の運用
+
+- `mikuproject_workbook_json` は `.json` を推奨します
+- `project_draft_view` は `.editjson` を推奨します
+- `.editjson` は、将来 `task_edit_view` や Patch JSON などの AI 向け編集用 JSON 群にも拡張できる広めの拡張子として扱います
+- 拡張子は判別補助であり、必要に応じて中身の `view_type` / `format` でも判定します
+
+## 補足
+
+`phase_detail_view` は、安全な変更候補の抽出や、次に必要な `task_edit_view` の特定にも使えます。
+`phase_detail_view` には、phase 全体をそのまま渡す `full` モードと、対象を絞る `scoped` モードの両方がありえます。
+必要に応じて、`root_uid` と `max_depth` で対象範囲を絞って渡してよいです。
+
+新規生成モードでは、既存 project の編集は行わず、全く新しい project の草案だけを返します。
+
+### `project_overview_view` の例
+
+```json
+{
+ "project": {
+ "name": "新基幹システム導入",
+ "planned_start": "2026-04-01",
+ "planned_finish": "2026-12-31"
+ },
+ "summary": {
+ "task_count": 128,
+ "milestone_count": 12,
+ "max_outline_level": 4
+ },
+ "phases": [
+ {
+ "uid": "100",
+ "name": "要件定義",
+ "wbs": "1",
+ "task_count": 18,
+ "milestone_count": 2,
+ "planned_start": "2026-04-01",
+ "planned_finish": "2026-05-15"
+ }
+ ]
+}
+```
+
+### `phase_detail_view` の例
+
+```json
+{
+ "project": {
+ "name": "新基幹システム導入"
+ },
+ "phase": {
+ "uid": "100",
+ "name": "要件定義",
+ "wbs": "1",
+ "planned_start": "2026-04-01",
+ "planned_finish": "2026-05-15"
+ },
+ "scope": {
+ "mode": "full",
+ "root_uid": null,
+ "max_depth": null
+ },
+ "tasks": [
+ {
+ "uid": "110",
+ "name": "現状業務整理",
+ "parent_uid": "100",
+ "position": 0,
+ "planned_duration": "PT40H",
+ "planned_duration_hours": 40,
+ "planned_start": "2026-04-01",
+ "planned_finish": "2026-04-05"
+ }
+ ],
+ "milestones": [
+ {
+ "uid": "190",
+ "name": "要件定義完了",
+ "date": "2026-05-15"
+ }
+ ]
+}
+```
+
+### `phase_detail_view` の範囲指定
+
+- `mode = "full"` の場合は、phase 全体を対象にします
+- `mode = "scoped"` の場合は、`root_uid` と `max_depth` で対象範囲を絞れます
+- `root_uid` を指定すると、その task を起点にした subtree を対象にできます
+- `max_depth` を指定すると、`root_uid` から何階層下まで含めるかを制御できます
+- `mode = "full"` では `root_uid = null` かつ `max_depth = null` です
+
+### `task_edit_view` の例
+
+これは将来の安全編集用 projection 案であり、現時点では `mikuproject` 本体に未実装です。
+
+```json
+{
+ "project": {
+ "name": "新基幹システム導入"
+ },
+ "phase": {
+ "uid": "100",
+ "name": "要件定義"
+ },
+ "target_task": {
+ "uid": "120",
+ "name": "要件ヒアリング",
+ "parent_uid": "100",
+ "position": 1,
+ "planned_duration": "PT80H",
+ "planned_duration_hours": 80,
+ "planned_start": "2026-04-06",
+ "planned_finish": "2026-04-15"
+ },
+ "predecessors": [
+ {
+ "task_uid": "110",
+ "name": "現状業務整理",
+ "type": "FS",
+ "lag": "PT0H",
+ "lag_hours": 0
+ }
+ ],
+ "successors": [
+ {
+ "task_uid": "130",
+ "name": "要件確定",
+ "type": "FS",
+ "lag": "PT0H",
+ "lag_hours": 0
+ }
+ ],
+ "rules": {
+ "allow_patch_ops": ["update_task", "move_task", "link_tasks", "unlink_tasks"],
+ "allowed_edit_fields": ["name", "planned_start", "planned_finish", "planned_duration", "planned_duration_hours"],
+ "forbid_completed_task_changes": true
+ }
+}
+```
+
+### `project_draft_request` の例
+
+これは新規生成プロンプト組み立て用の入力案です。現時点では主に設計用で、UI の主導線にはまだ載せていません。
+
+```json
+{
+ "view_type": "project_draft_request",
+ "project": {
+ "name": "新規基幹刷新",
+ "planned_start": "2026-04-01"
+ },
+ "requirements": {
+ "goal": "社内基幹システム刷新",
+ "team_count": 2,
+ "must_have_phases": ["要件定義", "設計", "実装", "テスト", "移行"],
+ "must_have_milestones": ["要件確定", "本番移行"]
+ }
+}
+```
+
+### `project_draft_view` の例
+
+```json
+{
+ "view_type": "project_draft_view",
+ "project": {
+ "name": "新規基幹刷新",
+ "planned_start": "2026-04-01"
+ },
+ "tasks": [
+ {
+ "uid": "draft-1",
+ "name": "要件定義",
+ "parent_uid": null,
+ "position": 0,
+ "is_summary": true
+ }
+ ]
+}
+```
+
+### phase の定義
+
+- 当面、phase は top-level summary task を指します
+- ルート直下の summary task を phase とみなします
+- ここでいう summary task は `is_summary = true` 相当の task です
+- `UID=0` の project summary task は phase に含めません
+
+### UID
+
+- `uid` は常に string です
+- `parent_uid`, `from_uid`, `to_uid`, `task_uid` も常に string です
+
+### 日付・期間
+
+- 当面、WBS 理解用 projection は計画ベースです
+- 曖昧な `start` / `finish` は使わず、意味名を分けます
+- 例:
+ - `planned_start`
+ - `planned_finish`
+ - `planned_duration`
+ - `planned_duration_hours`
+ - `actual_start`
+ - `actual_finish`
+- duration は元表現と補助数値を併記することがあります
+- 例:
+ - `planned_duration: "PT40H"`
+ - `planned_duration_hours: 40`
+- 両方がある場合、理解や比較には `*_hours` を補助的に使ってよいです
+
+### 依存関係
+
+- dependency は単なる前後順ではなく意味的な関係です
+- 少なくとも次を見ます
+ - 相手 task の `uid`
+ - 相手 task の `name`
+ - `type`
+ - `lag`
+ - `lag_hours`
+- `type` は少なくとも次を扱います
+ - `FS`
+ - `SS`
+ - `FF`
+ - `SF`
+- `predecessors` だけでなく `successors` も見てください
+- `lag` は負値を取りうることがあります
+
+### rules
+
+- 各 projection には `rules` が含まれることがあります
+- `rules` は参考情報ではなく、AI が返してよい Patch の契約です
+- `allow_patch_ops` にない操作は返してはいけません
+- `allowed_edit_fields` にない field は更新してはいけません
+- `forbid_*` が true の条件は必ず守ってください
+
+### `rules` の例
+
+```json
+{
+ "allow_patch_ops": ["update_task", "move_task", "link_tasks", "unlink_tasks"],
+ "allowed_edit_fields": [
+ "name",
+ "planned_start",
+ "planned_finish",
+ "planned_duration",
+ "planned_duration_hours"
+ ],
+ "forbid_completed_task_changes": true,
+ "forbid_summary_task_direct_edit": true,
+ "forbid_delete_task": true
+}
+```
+
+### Patch JSON の原則
+
+- Patch JSON は `operations` 配列を持つオブジェクトです
+- task の field 更新は `update_task` を使います
+- 親子や順序の変更は `move_task` を使います
+- 依存関係の追加や解除は `link_tasks` / `unlink_tasks` を使います
+
+### 新規生成モードの原則
+
+- `project_draft_request` に対する返答は `project_draft_view` です
+- このとき `Patch JSON` は返しません
+- draft は正本ではなく草案です
+- draft 内の `uid` は `"draft-1"` のような仮 UID でよいです
+- `percent_complete` を含めてよいです
+- task が通常 task で、`planned_start` / `planned_finish` が日付だけの場合、`mikuproject` 側では勤務時間帯を補完して扱うことがあります
+- 同日 task の date-only 指定は、通常 task なら `09:00:00` 開始 / `18:00:00` 終了へ補完されることがあります
+- 複数日 task の date-only 指定でも、通常 task なら開始日は `09:00:00`、終了日は `18:00:00` を補完して扱うことがあります
+- `planned_finish` だけが与えられた通常 task は、まず同日の `planned_start` を補完したうえで、上記の勤務時間帯補完を適用することがあります
+- `is_milestone: true` の task には、この勤務時間帯補完を適用しません
+
+### Patch の例
+
+```json
+{
+ "operations": [
+ {
+ "op": "update_task",
+ "uid": "101",
+ "fields": {
+ "name": "修正タスク"
+ }
+ }
+ ]
+}
+```
+
+### 順序変更の例
+
+```json
+{
+ "operations": [
+ {
+ "op": "move_task",
+ "uid": "120",
+ "new_parent_uid": "100",
+ "new_index": 2
+ }
+ ]
+}
+```
+
+### 依存追加の例
+
+```json
+{
+ "operations": [
+ {
+ "op": "link_tasks",
+ "from_uid": "110",
+ "to_uid": "120",
+ "type": "FS",
+ "lag": "PT0H",
+ "lag_hours": 0
+ }
+ ]
+}
+```
+
+### 変更不要の例
+
+```json
+{
+ "operations": []
+}
+```
+
+## 出力ルール
+
+- 対話インタフェースでは、説明文を返してよいです
+- 変更理由や不確実性を簡潔に説明してよいです
+- ただし、最終的な機械処理対象 JSON は必ず最後に 1 個の `json` コードフェンスで囲って返してください
+- 既存編集モードでは、その最後の `json` コードフェンス内は `Patch JSON` です
+- 新規生成モードでは、その最後の `json` コードフェンス内は `project_draft_view` です
+- `mikuproject` が処理対象にするのは、その最後の `json` コードフェンス内の JSON のみです
+- 不明な場合は変更を最小にしてください
+- 変更不要なら最後の `json` コードフェンスで空の `operations` を返してください
+- 与えられていない task や field を勝手に推測しないでください
+
+## 改善候補
+
+- 将来的には `suggest_only` のような提案専用モードを追加する余地があります
+- 現時点の spec は task / phase / dependency を優先しており、resource や工数配分の扱いは今後の検討対象です
+- phase 定義は当面 `top-level summary task` 固定ですが、将来的にはより柔軟な定義へ拡張する余地があります
diff --git a/docs/msprojectxml-ai-integration.md b/docs/msprojectxml-ai-integration.md
new file mode 100644
index 0000000..f7eb0fa
--- /dev/null
+++ b/docs/msprojectxml-ai-integration.md
@@ -0,0 +1,1315 @@
+# MS Project XML x 生成AI 連携設計メモ
+
+この文書は、`mikuproject` における `MS Project XML` と生成AIの連携方針を整理するための設計メモである。
+
+現時点では実装仕様の確定版ではなく、アーキテクチャ判断と今後の仕様化ポイントを明確にすることを目的とする。
+
+現時点の実装範囲は次のとおり。
+
+- 実装済み: `project_overview_view` export
+- 実装済み: `phase_detail_view` export
+- 実装済み: `project_draft_view` import
+- 未実装: `task_edit_view`
+- 未実装: Patch JSON の受信・適用
+
+この設計では、生成AIとの会話は `JSON` ベースで行う方針とする。
+
+- AI へ渡す入力は用途別 projection JSON
+- AI から受け取る出力は、現状実装では `project_draft_view`、将来案としては Patch JSON を想定する
+- workbook JSON と AI 向け編集用 JSON を区別するため、当面 `project_draft_view` などの編集用 JSON には `.editjson` 拡張子を推奨する
+
+`MS Project XML` は保存と互換のための外部形式として保持し、AI との直接入出力には使わない。
+
+## 目的
+
+`mikuproject` は `MS Project XML` を忠実に扱う編集ソフトとして設計する。
+
+すでに `.xlsx` の import/export は実装済みであり、次の課題として生成AIとの安全な連携を検討する。
+
+- `WBS` やプロジェクト構造を、AIが扱いやすい形で渡す
+- AIの編集結果を、安全かつ検証可能な形で受け取る
+- `MS Project XML` との互換性を維持する
+
+なお、現時点の UI で扱える生成AI連携は、既存 project に対する `project_overview_view` / `phase_detail_view` の保存と、新規草案として返ってきた `project_draft_view` の取込までである。
+
+## 最重要方針
+
+結論として、生成AIとの会話は `JSON` ベースで行い、`MS Project XML` をそのまま AI に渡さない。
+
+代わりに次のレイヤで分離する。
+
+- 外部形式: `MS Project XML`
+- 内部形式: 正規化された canonical model
+- AI 入出力形式: 用途別 projection JSON と、将来案としての Patch JSON
+
+要点は次のとおり。
+
+- XML は保存と互換のための正本として扱う
+- 内部では意味ベースの正規化モデルを扱う
+- AI とは JSON ベースでやり取りする
+- AI からの返却は、現状では `project_draft_view` の取込、将来は差分 Patch の受取を想定する
+
+一言でまとめると、`XML は保存、JSON は思考、Patch は将来の操作` である。
+
+## レイヤ構造
+
+想定するデータ構造のレイヤは次のとおり。
+
+### 1. 外部形式
+
+- `MS Project XML`
+- 正本
+- 互換維持のための保存形式
+
+### 2. 内部モデル
+
+- 正規化された `Project / Task / Resource / Assignment / Calendar` モデル
+- `mikuproject` 内部で意味を保って扱う canonical representation
+
+### 3. AI 入出力形式
+
+- AI入力: 用途別に投影した JSON
+- AI出力: Patch JSON
+
+この分離により、AI側の都合と `MS Project XML` 側の都合を疎結合にできる。
+
+## なぜ XML をそのまま AI に渡さないか
+
+`MS Project XML` は互換形式としては有用だが、AIとの直接連携には不向きである。
+
+理由は次のとおり。
+
+- XML の階層と要素数が多くノイズが多い
+- AI にとって意味単位が読み取りづらい
+- XML 全量を書き換えさせると破損リスクが高い
+- 差分検証や部分適用が難しい
+
+したがって、AI 連携では XML の完全再構成を求めるのではなく、意味ベースの JSON と差分操作に変換して扱う。
+
+## AI 向け JSON 設計方針
+
+AI 向け JSON は、XML をそのまま JSON 化したものではなく、意味ベースで正規化した構造をもとに、目的別に投影した projection とする。
+
+### NG
+
+- XML を機械的に JSON 化する
+- 互換性都合の細かいフィールドをそのまま露出する
+- AI に全フィールドの再出力を求める
+- AI 用 JSON を 1 種類だけで済ませようとする
+
+### OK
+
+- タスクや依存関係など、意味のある単位に正規化する
+- 今回の判断に必要な情報だけを出す
+- 型と命名を一貫させる
+- AI に理解しやすい構造を優先する
+- 用途ごとに projection を切り替える
+
+例:
+
+```json
+{
+ "tasks": [
+ {
+ "uid": 100,
+ "name": "要件定義",
+ "parent_uid": null,
+ "duration": "PT40H",
+ "duration_hours": 40,
+ "predecessors": []
+ }
+ ]
+}
+```
+
+AI は `PT40H` のような `ISO 8601 duration` をある程度理解できるが、比較や推論をより安定させるため、AI projection では元の表現に加えて補助の数値表現も併記する方針とする。
+
+例:
+
+```json
+{
+ "duration": "PT40H",
+ "duration_hours": 40
+}
+```
+
+この場合、元の意味を保つ正規表現として `duration` を残しつつ、AI が扱いやすい補助値として `duration_hours` を与える。
+
+## JSON は独自仕様でよいか
+
+AI 向け JSON は、業界標準に完全準拠している必要はない。
+
+この用途では、次の条件を満たす独自 JSON で十分に成立する。
+
+- フィールド名が自然で意味を推測しやすい
+- 同じ意味に対して同じ名前を使う
+- 型が安定している
+- 暗黙ルールが少ない
+
+そのため、`OpenProject` など既存製品の JSON を参考にしつつ、`mikuproject` に必要な範囲で自作する方針が現実的である。
+
+## 標準との距離感
+
+完全にそのまま使える標準 JSON は現時点では見当たらない。
+
+参考になるものはあるが、いずれもそのまま採用するには不足がある。
+
+| 種類 | 位置づけ |
+| --- | --- |
+| `MS Project XML` | 標準だが XML であり、AI 入出力には不向き |
+| `OpenProject JSON` | 実務的だが製品固有 |
+| `schema.org Project` | 抽象的で WBS 編集には不足 |
+
+したがって方針としては、`OpenProject` 風のタスク中心 JSON を参考にしながら、`MS Project XML` へマッピングしやすい自前 schema を定義する。
+
+## AI 入力は「最小」ではなく「十分」
+
+AI 連携で重要なのは、単に情報量を減らすことではない。
+
+`全部の真実` を渡すのではなく、`今回の判断に必要な真実` を渡すことが重要である。
+
+AI に対して情報を過剰に渡すと、ノイズが増え、判断の焦点がぼやける。一方で、重要な制約や背景を削りすぎると、もっともらしいが不正確な提案を返しやすくなる。
+
+したがって、設計原則は `minimal` ではなく `sufficient` である。
+
+- 少なければよいわけではない
+- 多ければ正確になるわけでもない
+- 目的に対して十分な文脈を渡すことが重要
+
+## AI 入力 JSON の考え方
+
+AI に渡す JSON は、canonical model の単純縮小版ではなく、目的ごとに編集された `context-rich projection` として設計する。
+
+この projection には、少なくとも次の 3 層を含める。
+
+### 1. 編集対象
+
+AI が直接読み取り、提案や変更の対象にするデータ。
+
+例:
+
+- task の `uid`
+- `name`
+- `parent_uid`
+- 並び順
+- predecessor
+- duration
+- start / finish
+
+### 2. 制約コンテキスト
+
+AI が守るべきルールや変更可能範囲。
+
+例:
+
+- 変更禁止項目
+- 完了済み task は変更禁止
+- summary task の直接編集禁止
+- predecessor の循環禁止
+- 削除操作禁止
+
+### 3. 判断補助コンテキスト
+
+AI が正しい解釈に到達するための背景情報。
+
+例:
+
+- project 名
+- フェーズ概要
+- 親 task 名
+- 近傍 task
+- 主要 milestone
+- 対象プロジェクトの規模や種別
+
+## 全体像が必要なケース
+
+AI の判断は、局所編集だけで完結しない場合がある。
+
+たとえば次のような依頼では、全体像を渡す必要がある。
+
+- WBS の抜け漏れを見たい
+- 工程構成が妥当か見たい
+- フェーズ配分の偏りを見たい
+- 類似プロジェクトの WBS と比較したい
+- 同種案件のたたき台を作りたい
+
+このようなケースでは、個別 task の列挙だけでは不十分であり、フェーズ構成、主要 milestone、依存のまとまり、件数集計、代表 task 群など、プロジェクト全体を理解するための情報を渡す必要がある。
+
+## AI Projection は 1 種類ではない
+
+AI 入力は 1 種類の簡約 JSON で統一するより、用途別 projection を複数持つ方がよい。
+
+候補は次のとおり。
+
+- `project_overview_view`
+- `phase_detail_view`
+- `task_edit_view`
+- `dependency_edit_view`
+- `schedule_review_view`
+- `similar_project_view`
+- `comparison_view`
+- `project_draft_request`
+- `project_draft_view`
+
+必要に応じて、複数の projection を同時に AI へ渡してよい。
+
+たとえば `task_edit_view` を主データとしつつ、`project_overview_view` を補助コンテキストとして併用する、という使い方が考えられる。
+
+また、`project_overview_view` で全体像を把握した後、特定フェーズを深掘りするための中間ビューとして `phase_detail_view` を置く構成が考えられる。
+
+このとき、`phase_detail_view` は `full` と `scoped` の 2 モードを持てるようにしておく方が実務的である。`full` は phase 全量を渡し、`scoped` は必要に応じて `root_uid` と `max_depth` で subtree 単位に絞る。
+
+現行 UI では、既存 project 向けの生成AI連携は `Output` タブから扱う。
+
+- `full bundle`
+- `project_overview_view`
+- `phase_detail_view full`
+- `phase_detail_view scoped`
+
+を `.editjson` として保存できる構成とし、`scoped` の場合は `phase UID`、`root UID`、`max depth` 相当の入力を伴う。
+
+一方で、既存 project の安全編集とは別に、全く新規の project 草案を AI に生成させるための `project_draft_request` / `project_draft_view` 系を分離して持つことも有効である。
+
+## 新規生成モード
+
+既存計画の編集と、新規計画の生成は、同じ AI 連携でも別モードとして扱う方が安全である。
+
+- 既存編集モード: 既存 project を前提にし、出力は `Patch JSON`
+- 新規生成モード: 粗い要件から project 草案を生成し、出力は `project_draft_view`
+
+この新規生成モードでは、AI は既存 project を変更しない。許されるのは、新しい project の全量草案を返すことだけである。
+
+現行 UI では、この新規生成モードは `Input` タブ内の `生成AI連携` から扱う。生成AIとの会話自体は外部で行い、`mikuproject` 側では `project_draft_view` を
+
+- `Load from file` から `.editjson` として開く
+- JSON テキストを貼り付けて取り込む
+
+のいずれかで受け付ける。
+
+### 新規生成モードの原則
+
+- 既存 project の編集には使わない
+- 出力は `Patch` ではなく全量の draft JSON とする
+- draft は正本ではなく草案として扱う
+- `mikuproject` 側で validation を通してから内部モデルへ取り込む
+- 既存 UID と混同しないよう、仮 UID を使ってよい
+
+### `project_draft_request` の例
+
+```json
+{
+ "view_type": "project_draft_request",
+ "project": {
+ "name": "新規基幹刷新",
+ "planned_start": "2026-04-01"
+ },
+ "requirements": {
+ "goal": "社内基幹システム刷新",
+ "team_count": 2,
+ "must_have_phases": ["要件定義", "設計", "実装", "テスト", "移行"],
+ "must_have_milestones": ["要件確定", "本番移行"]
+ }
+}
+```
+
+### `project_draft_view` の例
+
+```json
+{
+ "view_type": "project_draft_view",
+ "project": {
+ "name": "新規基幹刷新",
+ "planned_start": "2026-04-01"
+ },
+ "tasks": [
+ {
+ "uid": "draft-1",
+ "name": "要件定義",
+ "parent_uid": null,
+ "position": 0,
+ "is_summary": true
+ }
+ ]
+}
+```
+
+## `project_overview_view` の役割
+
+`project_overview_view` は、個別 task を厳密に編集するためのビューではなく、プロジェクト全体の構造と粒度を AI に把握させるための要約 projection である。
+
+主な用途は次のとおり。
+
+- WBS 全体レビュー
+- 抜け漏れ検出
+- フェーズ構成の妥当性確認
+- フェーズ配分の偏り確認
+- 類似プロジェクト比較の入力
+- 個別編集前の全体コンテキスト付与
+
+逆に、次の用途には向かない。
+
+- 個別 task の厳密編集
+- 全依存関係の詳細編集
+- resource / assignment の詳細編集
+- XML 互換維持のための保持項目確認
+
+## `project_overview_view` に含めるべき情報
+
+`project_overview_view` では、全 task の全属性を渡すのではなく、全体理解に効く情報を圧縮して渡す。
+
+中核になるのは次の情報である。
+
+- project の基本情報
+- task 全体件数などの集計
+- フェーズ一覧
+- 主要 milestone
+- top level の依存関係
+- 判断ルールや制約
+
+特に重要なのは `phases` であり、AI がまず全体像を把握するための中心データとなる。
+
+## `project_overview_view` に含めすぎない方がよい情報
+
+次のような情報は、原則として `project_overview_view` には入れない方がよい。
+
+- 全 task の全属性
+- assignment の詳細
+- calendar の細粒度定義
+- XML 互換維持用の細かな保持項目
+- `TimephasedData` のような重い詳細
+
+これらは別 projection で扱うべきであり、overview に混ぜると AI の注意が散りやすい。
+
+## `project_overview_view` の最小構造案
+
+```json
+{
+ "project": {
+ "name": "新基幹システム導入",
+ "domain": "enterprise-it",
+ "planning_mode": "forward",
+ "planned_start": "2026-04-01",
+ "planned_finish": "2026-12-31",
+ "status_date": "2026-06-01"
+ },
+ "summary": {
+ "task_count": 128,
+ "summary_task_count": 18,
+ "milestone_count": 12,
+ "max_outline_level": 4
+ },
+ "phases": [
+ {
+ "uid": 100,
+ "name": "要件定義",
+ "wbs": "1",
+ "task_count": 18,
+ "milestone_count": 2,
+ "planned_start": "2026-04-01",
+ "planned_finish": "2026-05-15"
+ }
+ ],
+ "milestones": [
+ {
+ "uid": 190,
+ "name": "要件定義完了",
+ "parent_uid": 100,
+ "date": "2026-05-15"
+ }
+ ],
+ "top_level_dependencies": [
+ {
+ "from_uid": 100,
+ "to_uid": 200,
+ "type": "FS"
+ }
+ ],
+ "rules": {
+ "completed_tasks_exist": true,
+ "allow_patch_ops": ["add_task", "update_task", "move_task"]
+ }
+}
+```
+
+## `project_overview_view` の必須候補
+
+最低限、次の項目は候補になる。
+
+- `project.name`
+- `project.planned_start`
+- `project.planned_finish`
+- `summary.task_count`
+- `summary.max_outline_level`
+- `phases`
+- `milestones`
+- `top_level_dependencies`
+- `rules`
+
+## `phases` の重要性
+
+`project_overview_view` の中心は `phases` である。
+
+AI は全 task 一覧よりも、まずフェーズの塊を見た方が、全体構造、工程順序、粒度感を把握しやすい。
+
+`phases` に持たせる候補は次のとおり。
+
+- `uid`
+- `name`
+- `wbs`
+- `task_count`
+- `summary_task_count`
+- `milestone_count`
+- `planned_start`
+- `planned_finish`
+- `duration`
+- `duration_hours`
+- `percent_complete`
+
+## `phase` の定義
+
+`project_overview_view` や `phase_detail_view` で使う `phase` は、曖昧な概念のままにせず、抽出ルールを固定した方がよい。
+
+当面の基本方針としては、`phase` を `top-level summary task` ベースで定義するのがもっとも実務的である。
+
+つまり、原則として次を `phase` とみなす。
+
+- `Summary = true`
+- ルート直下にある task
+- `UID=0` の placeholder task は除外
+
+この定義にすると、MS Project XML の task 階層と自然に対応しやすく、`project_overview_view` と `phase_detail_view` を一貫した基準で構成しやすい。
+
+## `phase` 定義の利点
+
+この定義には次の利点がある。
+
+- XML 由来の task 階層と対応づけやすい
+- project 全体を自然な大区分に分けやすい
+- `phase_detail_view` の対象を `uid` で直接指せる
+- Patch 適用後の再集計も行いやすい
+
+要するに、独自のフェーズ抽出ロジックを複雑に持つより、まずは `top-level summary task` を `phase` とみなす方が壊れにくい。
+
+## `phase` とみなさないもの
+
+次のものは、原則として `phase` には含めない。
+
+- `UID=0` の project summary task
+- leaf task
+- ルート直下であっても summary ではない task
+
+この場合、ルート直下の leaf task は、どこかの phase に属する task ではなく、phase 外の top-level task として別扱いする。
+
+## 例外と fallback
+
+実際の WBS では、必ずしも top-level summary task がきれいにフェーズを表していない場合がある。
+
+そのため、将来的には次の fallback を持つ余地がある。
+
+1. `top-level summary task` を優先する
+2. それが不十分な場合は、明示設定された phase marker を使う
+3. さらに必要なら、特定 `OutlineLevel` を phase とみなす補助ルールを使う
+
+ただし、初期段階では fallback を増やしすぎると `phase` の意味がぶれるため、まずは `top-level summary task` に固定する方がよい。
+
+## `phase_detail_view` との関係
+
+`phase_detail_view` は、この定義で抽出された 1 つの `phase` を対象にする。
+
+つまり `phase_detail_view.phase.uid` は、`project_overview_view.phases[].uid` のいずれかと一致する前提で扱う。
+
+これにより、overview で見た phase を、そのまま detail へドリルダウンする導線が明確になる。
+
+## 代表 task を含めるか
+
+`project_overview_view` は単なる集計だけでなく、必要に応じて代表 task を少数含めた方がよい。
+
+要約だけでは、AI がフェーズの具体的な作業内容や粒度感を誤解することがあるためである。
+
+そのため、phase ごとに `sample_tasks` を数件だけ含める設計は有効である。
+
+例:
+
+```json
+{
+ "uid": 100,
+ "name": "要件定義",
+ "task_count": 18,
+ "sample_tasks": [
+ { "uid": 110, "name": "現状業務整理" },
+ { "uid": 120, "name": "要件ヒアリング" },
+ { "uid": 130, "name": "要件確定" }
+ ]
+}
+```
+
+## `project_overview_view` の位置づけ
+
+要するに、`project_overview_view` は、プロジェクト全体の構造、粒度、主要節目を AI に理解させるための要約 projection であり、個別 task 編集のための完全データではない。
+
+## `phase_detail_view` の位置づけ
+
+`phase_detail_view` は、`project_overview_view` で把握した全体像のうち、特定フェーズだけを一段詳しく見るための projection である。
+
+これは単なる task 一覧ではなく、フェーズの task 群、主要 milestone、依存の要点、必要に応じた要約情報をまとめて持てる詳細ビューとして位置づける。
+
+そのため、`phase_task_list_view` のような限定的な一覧ビューよりも、将来の拡張に耐えやすい。
+
+役割のイメージは次のとおり。
+
+- `project_overview_view`: プロジェクト全体の把握
+- `phase_detail_view`: 特定フェーズの深掘り
+- `task_edit_view`: 個別 task の厳密編集
+
+また、`phase_detail_view` は phase 全体を返すだけでなく、特定 summary task 配下へ対象範囲を絞る中間ビューとしても使えるようにしておくとよい。
+
+### 範囲指定の考え方
+
+`phase_detail_view` には、少なくとも次の 2 モードがあるとよい。
+
+- `full`: phase 全体をそのまま返す
+- `scoped`: 対象 subtree に絞って返す
+
+`scoped` の場合は、次のような scope 指定を持たせる。
+
+- `root_uid`: 対象 subtree の根 task UID
+- `max_depth`: `root_uid` から何階層下まで含めるか
+
+この形にしておくと、overview と task_edit の間にある「少し詳しいがまだ広い」ビューを、用途に応じて段階的に絞り込める。
+
+## `phase_detail_view` の具体例
+
+```json
+{
+ "project": {
+ "name": "新基幹システム導入",
+ "planned_start": "2026-04-01",
+ "planned_finish": "2026-12-31"
+ },
+ "phase": {
+ "uid": 100,
+ "name": "要件定義",
+ "wbs": "1",
+ "planned_start": "2026-04-01",
+ "planned_finish": "2026-05-15",
+ "task_count": 18,
+ "milestone_count": 2,
+ "percent_complete": 20
+ },
+ "tasks": [
+ {
+ "uid": 110,
+ "name": "現状業務整理",
+ "parent_uid": 100,
+ "position": 0,
+ "is_summary": false,
+ "is_milestone": false,
+ "duration": "PT40H",
+ "duration_hours": 40,
+ "planned_start": "2026-04-01",
+ "planned_finish": "2026-04-05",
+ "percent_complete": 100,
+ "predecessor_uids": []
+ },
+ {
+ "uid": 120,
+ "name": "要件ヒアリング",
+ "parent_uid": 100,
+ "position": 1,
+ "is_summary": false,
+ "is_milestone": false,
+ "duration": "PT80H",
+ "duration_hours": 80,
+ "planned_start": "2026-04-06",
+ "planned_finish": "2026-04-15",
+ "percent_complete": 50,
+ "predecessor_uids": [110]
+ }
+ ],
+ "milestones": [
+ {
+ "uid": 190,
+ "name": "要件定義完了",
+ "date": "2026-05-15"
+ }
+ ],
+ "dependency_summary": [
+ {
+ "from_uid": 110,
+ "to_uid": 120,
+ "type": "FS"
+ }
+ ],
+ "rules": {
+ "allow_patch_ops": ["add_task", "update_task", "move_task"],
+ "forbid_completed_task_changes": true
+ }
+}
+```
+
+この例では、`tasks` が中心データでありつつ、`phase` の要約、主要 `milestones`、依存の要点、`rules` を同時に持たせている。
+
+これにより、AI は特定フェーズの中身を task 単位で追いながら、そのフェーズ全体の意味も見失いにくくなる。
+
+## `task_edit_view` の位置づけ
+
+`task_edit_view` は、生成AIに個別 task の編集をさせるための projection である。
+
+`project_overview_view` や `phase_detail_view` が理解と検討のためのビューであるのに対し、`task_edit_view` は実際の変更提案を安全に返させるための作業ビューと位置づける。
+
+主な用途は次のとおり。
+
+- task 名の修正
+- task の親子変更
+- task の順序変更
+- task の期間変更
+- 依存関係の追加や解除
+- 個別 task 周辺の整合見直し
+
+## `task_edit_view` に必要な情報
+
+`task_edit_view` では、対象 task だけを孤立して渡すのではなく、編集判断に必要な局所文脈をあわせて渡す。
+
+少なくとも次の情報が候補になる。
+
+- 対象 task 本体
+- 親 task の情報
+- 近傍の sibling task
+- 同フェーズ内の関連 task
+- predecessor / successor
+- 編集ルール
+
+特に、task 単体の属性だけでは sibling order や依存文脈を誤解しやすいため、近傍文脈は重要である。
+
+## `task_edit_view` の具体例
+
+```json
+{
+ "project": {
+ "name": "新基幹システム導入"
+ },
+ "phase": {
+ "uid": 100,
+ "name": "要件定義"
+ },
+ "target_task": {
+ "uid": 120,
+ "name": "要件ヒアリング",
+ "parent_uid": 100,
+ "position": 1,
+ "is_summary": false,
+ "is_milestone": false,
+ "planned_duration": "PT80H",
+ "planned_duration_hours": 80,
+ "planned_start": "2026-04-06",
+ "planned_finish": "2026-04-15",
+ "percent_complete": 50
+ },
+ "parent_task": {
+ "uid": 100,
+ "name": "要件定義"
+ },
+ "sibling_tasks": [
+ {
+ "uid": 110,
+ "name": "現状業務整理",
+ "position": 0
+ },
+ {
+ "uid": 130,
+ "name": "要件確定",
+ "position": 2
+ }
+ ],
+ "predecessors": [
+ {
+ "task_uid": 110,
+ "name": "現状業務整理",
+ "type": "FS",
+ "lag": "PT0H",
+ "lag_hours": 0
+ }
+ ],
+ "successors": [
+ {
+ "task_uid": 130,
+ "name": "要件確定",
+ "type": "FS",
+ "lag": "PT0H",
+ "lag_hours": 0
+ }
+ ],
+ "rules": {
+ "allow_patch_ops": ["update_task", "move_task", "link_tasks", "unlink_tasks"],
+ "forbid_completed_task_changes": true,
+ "forbid_summary_task_direct_edit": true
+ }
+}
+```
+
+この例では、`target_task` が中心だが、親、近傍 task、依存関係、ルールも同時に持たせている。
+
+これにより、AI は対象 task を局所的に読みつつ、周辺文脈を踏まえた Patch を返しやすくなる。
+
+## `task_edit_view` と Patch の接続
+
+`task_edit_view` の目的は、AI に task を理解させることそのものではなく、妥当な Patch を返させることである。
+
+そのため、`task_edit_view` では次を意識する。
+
+- Patch で参照する識別子をそのまま見せる
+- 移動や依存変更に必要な相手 task を見せる
+- 編集禁止条件を `rules` として明示する
+- 未指定項目は変更しない前提で、対象外項目を無理に並べない
+
+つまり `task_edit_view` は、`Patch JSON` を安全に生成させるための最小作業面として設計する。
+
+## 日付・期間・進捗の意味論
+
+AI が WBS を安定して理解するためには、日付や期間の意味を曖昧にしない方がよい。
+
+特に次の点は、名前で区別して表現する。
+
+- それが計画値か実績値か
+- duration が計画上の期間か実績上の期間か
+- 進捗値が計画補助なのか実績反映なのか
+
+当面の基本方針は、WBS 理解用 projection を `計画ベース` とすることである。
+
+そのため、overview 系や phase 系の projection では、原則として計画値を主に渡す。
+
+推奨:
+
+- `planned_start`
+- `planned_finish`
+- `planned_duration`
+- `planned_duration_hours`
+- `actual_start`
+- `actual_finish`
+
+`start` や `finish` のような曖昧な名前は、計画と実績が混在し始めると意味衝突を起こしやすい。
+
+生成AIは文脈から推測できることも多いが、推測に依存した schema は弱い。
+
+そのため、値そのものを増やすことよりも、意味名を分けることを優先する。
+
+実績情報は、初期段階では overview に全面展開せず、必要な用途でのみ別 projection または別レイヤとして重ねる方が扱いやすい。
+
+例:
+
+```json
+{
+ "uid": 120,
+ "name": "要件ヒアリング",
+ "planned_start": "2026-04-06",
+ "planned_finish": "2026-04-15",
+ "planned_duration": "PT80H",
+ "planned_duration_hours": 80,
+ "actual_start": "2026-04-07",
+ "actual_finish": null,
+ "percent_complete": 50
+}
+```
+
+この方針により、AI にはまず計画構造を理解させ、その後に必要な場面だけ実績を重ねて見せられる。
+
+## 依存関係の意味論
+
+AI が WBS を理解するうえでは、依存関係を単なる前後順としてではなく、`type` と `lag` を持つ意味的な関係として見せた方がよい。
+
+少なくとも次の情報を持たせる。
+
+- 相手 task の `uid`
+- 相手 task の `name`
+- 依存種別 `type`
+- ラグ `lag`
+- ラグの補助値 `lag_hours`
+
+依存種別 `type` は、少なくとも次の 4 種を扱えるようにする。
+
+- `FS`: Finish-to-Start
+- `SS`: Start-to-Start
+- `FF`: Finish-to-Finish
+- `SF`: Start-to-Finish
+
+AI は `FS` を暗黙の既定として理解しがちだが、実際には `SS` や `FF` が工程設計上重要になる場合がある。
+
+そのため、依存関係を扱う projection では、`type` を省略せず明示する方がよい。
+
+## `lag` の扱い
+
+`lag` も duration と同様に、元表現と補助数値を併記する方が実務的である。
+
+推奨:
+
+```json
+{
+ "type": "FS",
+ "lag": "PT8H",
+ "lag_hours": 8
+}
+```
+
+これにより、MS Project に寄せた元表現を保ちながら、AI が比較や計算をしやすくなる。
+
+負のラグを許す場合も、同じ考え方で表せる。
+
+```json
+{
+ "type": "SS",
+ "lag": "-PT8H",
+ "lag_hours": -8
+}
+```
+
+## predecessor と successor
+
+AI に対象 task の編集をさせる場合は、`predecessors` だけでなく `successors` も見せた方が安全である。
+
+理由は次のとおり。
+
+- predecessor だけでは、変更が後続 task に与える影響を見落としやすい
+- successor だけでも、先行条件を見落としやすい
+- 双方向を見せることで、局所編集の破壊範囲を判断しやすくなる
+
+そのため、`task_edit_view` では `predecessors` と `successors` を併記する方針が妥当である。
+
+## 依存関係の Patch との接続
+
+依存関係を変更する場合は、汎用の field 更新よりも、専用の Patch 操作として表した方が安全である。
+
+候補:
+
+- `link_tasks`
+- `unlink_tasks`
+
+たとえば追加は次のように表せる。
+
+```json
+{
+ "operations": [
+ {
+ "op": "link_tasks",
+ "from_uid": 110,
+ "to_uid": 120,
+ "type": "FS",
+ "lag": "PT0H",
+ "lag_hours": 0
+ }
+ ]
+}
+```
+
+これにより、依存の追加・解除・変更を task 本体の field 更新と混同しにくくなる。
+
+## 依存関係で追加検討が必要な点
+
+今後さらに詰める必要がある点は次のとおり。
+
+- `lag` の単位を常に時間換算で持つか
+- `type` の既定値を許すか、常に明示するか
+- 同一 pair に複数 link を許すか
+- summary task への link を許すか
+- 循環依存をどう validation するか
+
+## `rules` の具体化
+
+AI に渡す `rules` は、単なる参考情報ではなく、AI が返してよい Patch の範囲を制御するための明示的な制約セットとして扱う。
+
+`rules` の目的は次のとおり。
+
+- AI に許可された操作範囲を明示する
+- 編集禁止条件を明示する
+- Patch 生成時の推測を減らす
+- validation 失敗を減らす
+
+つまり `rules` は、projection に付随する説明文ではなく、AI との編集契約として扱う。
+
+## `rules` に入れる情報の種類
+
+少なくとも次の 3 系統に分けて持たせるとよい。
+
+### 1. 許可操作
+
+AI が返してよい Patch 操作の一覧。
+
+例:
+
+- `allow_patch_ops`
+
+候補値:
+
+- `add_task`
+- `delete_task`
+- `move_task`
+- `update_task`
+- `link_tasks`
+- `unlink_tasks`
+
+### 2. 禁止条件
+
+特定条件下での編集禁止ルール。
+
+例:
+
+- `forbid_completed_task_changes`
+- `forbid_summary_task_direct_edit`
+- `forbid_delete_task`
+- `forbid_dependency_changes`
+
+### 3. 制約パラメータ
+
+単純な可否だけでなく、編集範囲や閾値を表すルール。
+
+例:
+
+- `max_new_tasks_per_request`
+- `allowed_edit_fields`
+- `allowed_target_scope`
+- `allowed_dependency_types`
+
+## `rules` の具体例
+
+```json
+{
+ "allow_patch_ops": ["update_task", "move_task", "link_tasks", "unlink_tasks"],
+ "allowed_edit_fields": [
+ "name",
+ "planned_start",
+ "planned_finish",
+ "planned_duration",
+ "planned_duration_hours"
+ ],
+ "allowed_target_scope": "current_phase",
+ "allowed_dependency_types": ["FS", "SS", "FF", "SF"],
+ "forbid_completed_task_changes": true,
+ "forbid_summary_task_direct_edit": true,
+ "forbid_delete_task": true,
+ "max_new_tasks_per_request": 0
+}
+```
+
+この例では、
+
+- 許可 op を限定し
+- 更新可能 field を限定し
+- 対象範囲を現在フェーズに限定し
+- 完了済み task や削除操作を禁止している
+
+## `rules` の解釈方針
+
+AI に対しては、`rules` に書かれていない操作を暗黙に許可しない方がよい。
+
+したがって、基本は次の解釈を採る。
+
+- `allow_patch_ops` にない op は返してはいけない
+- `allowed_edit_fields` にない field は更新してはいけない
+- `forbid_*` が `true` の条件は必ず守る
+- 迷った場合は変更せず、Patch を小さく保つ
+
+この方針により、AI が「たぶん許されるだろう」と推測して広く変更するのを防ぎやすくなる。
+
+## `rules` をどのビューに持たせるか
+
+`rules` は用途に応じて、各 projection に持たせてよい。
+
+たとえば:
+
+- `project_overview_view` では大域的な編集方針
+- `phase_detail_view` では当該フェーズの編集範囲
+- `task_edit_view` では対象 task 周辺の具体的な編集制約
+
+同じ key 名でも、ビューごとにスコープが違ってよい。ただし意味自体は変えない方がよい。
+
+## `rules` と validation の関係
+
+`rules` は AI への指示であると同時に、Patch 検証側でも使える方がよい。
+
+つまり、返却された Patch は次の順で確認できる。
+
+1. schema に合っているか
+2. `rules` に違反していないか
+3. 参照整合性に問題がないか
+4. 業務ルールに違反していないか
+
+この順に見ることで、単なる型不正と、許可範囲逸脱と、業務不整合を分けて扱いやすくなる。
+
+## `rules` で今後詰める点
+
+今後さらに明確化したい点は次のとおり。
+
+- `allow_patch_ops` を必須にするか
+- `allowed_edit_fields` を常に持たせるか
+- `forbid_*` を列挙型に寄せるか、boolean key で持つか
+- スコープを `project / phase / task` のどこまで細かく切るか
+- AI が `rules` 違反を検出したときに空 Patch を返すか、理由つき拒否を返すか
+
+## AI とのやり取り
+
+### AI へ渡すもの
+
+- WBS またはプロジェクトの用途別 projection JSON
+- その projection JSON の schema
+- 正しい例
+- 制約ルール
+
+### AI から受け取るもの
+
+- Patch JSON のみ
+
+AI に対しては、全体 JSON の再出力ではなく、差分だけを返すよう明示する。
+
+## Projection 設計の実務方針
+
+projection 設計では、次の考え方を採る。
+
+- canonical model 全量をそのまま AI に見せない
+- AI の用途ごとに projection を作る
+- projection には編集対象だけでなく制約と背景も入れる
+- XML 互換維持用の細かい保持項目は原則として見せない
+- 今回の判断に不要な `Resource / Assignment / Calendar` 全量は渡さない
+- ただし、判断に必要なら全体像や類似案件情報も渡す
+
+要するに、AI に必要なのは `縮小データ` ではなく、`目的に応じて編集された文脈付きデータ` である。
+
+## Patch 形式
+
+AI の返却は差分ベースの Patch 形式にする。
+
+例:
+
+```json
+{
+ "operations": [
+ {
+ "op": "update_task",
+ "uid": 101,
+ "fields": {
+ "name": "修正タスク"
+ }
+ }
+ ]
+}
+```
+
+この方式の利点は次のとおり。
+
+- 変更範囲が明確
+- 検証しやすい
+- 適用前レビューがしやすい
+- 不要な書き換えを抑制できる
+- XML 全体破壊のリスクを減らせる
+
+## 識別子設計
+
+識別子は次の考え方で整理する。
+
+| 項目 | 扱い |
+| --- | --- |
+| `UID` | 正本。内部・Patch・参照で使う不変キー |
+| `ID` | 表示用 |
+| `WBS` | 派生値 |
+
+AI が参照・更新に使う主キーは `UID` に寄せる。
+
+## JSON Schema の位置づけ
+
+`JSON Schema` は正式な標準仕様として採用できる。
+
+主に `draft-2020-12` を前提候補とする。
+
+### Schema でできること
+
+- 型制約
+- 必須項目の定義
+- `enum` 制約
+- 配列やオブジェクトの基本構造制約
+
+### Schema だけではできないこと
+
+- 参照整合性の保証
+- 循環依存の検出
+- 業務ルール検証
+- 意味的妥当性の保証
+
+そのため、`JSON Schema` は入口の構文検証として使い、その後に独自の validation を重ねる必要がある。
+
+## Schema と例と制約
+
+AI 制御では schema だけでは足りない。
+
+精度はおおむね次の順で高くなる。
+
+| 条件 | 期待精度 |
+| --- | --- |
+| Schema なし | 低い |
+| Schema のみ | 中 |
+| Schema + 例 | 高い |
+| Schema + 例 + 制約 | 非常に高い |
+
+そのため、AI 入出力仕様として最低限必要なのは次の 3 点である。
+
+1. `JSON Schema`
+2. 正しい例
+3. 制約ルール
+
+## AI への指示方針
+
+AI に対しては次を明示する。
+
+- JSON のみ返す
+- 出力形式を固定する
+- 不明値は推測しない
+- 全体返却は禁止し、Patch のみ返す
+
+抽象的な説明よりも、具体例を添えた方が精度は高い。
+
+## 想定アーキテクチャ
+
+```text
+MS Project XML
+ ↓
+正規化モデル(内部)
+ ↓
+AI 用 JSON
+ ↓
+AI(編集)
+ ↓
+Patch JSON
+ ↓
+検証
+ ↓
+内部モデル更新
+ ↓
+XML 再生成
+```
+
+## 今後詰めるべき仕様論点
+
+現時点の方向性は妥当だが、実装仕様としては次を詰める必要がある。
+
+### 1. Patch 操作の粒度
+
+`update_task` だけでは不足する。最低でも次の操作は候補になる。
+
+- `add_task`
+- `delete_task`
+- `move_task`
+- `reorder_task`
+- `update_task`
+- `link_tasks`
+- `unlink_tasks`
+
+### 2. 階層と順序の扱い
+
+`parent_uid` だけでは sibling order を表現できない。
+
+人間向けには次の表現が候補になる。
+
+- `parent_uid + position`
+- `before_uid`
+- `after_uid`
+
+ただし、生成AIとの Patch では `before_uid / after_uid` は整合性を崩しやすく、扱いがやや不安定である。
+
+そのため、AI とのやり取りでは、順序変更を汎用 field 更新として表すよりも、専用操作として表した方がよい。
+
+推奨:
+
+- `move_task`
+- `new_parent_uid`
+- `new_index`
+
+例:
+
+```json
+{
+ "operations": [
+ {
+ "op": "move_task",
+ "uid": 120,
+ "new_parent_uid": 100,
+ "new_index": 2
+ }
+ ]
+}
+```
+
+`new_index` は `0-based` と明記し、適用時に sibling 範囲チェックを行う。
+
+さらに AI に許す操作を絞る場合は、`move_up / move_down / indent_task / outdent_task` のような意味的な操作へ寄せる余地もある。
+
+### 3. 部分更新の意味論
+
+Patch の `fields` に含まれない項目をどう扱うかを固定する必要がある。
+
+- 未指定は変更なし
+- `null` は原則として通常の更新値としては使わない
+- 値の削除や解除が必要な場合は、専用操作または専用フィールド意味論で表す
+
+生成AIとのやり取りでは、`未指定は変更なし` を基本ルールにした方が安全である。
+
+AI は全項目を安定して再出力するとは限らないため、未指定を削除扱いにすると意図しない消失が起きやすい。
+
+この意味論が曖昧だと安全な適用ができない。
+
+### 4. 業務ルール検証
+
+Schema の後段で、少なくとも次を検証する必要がある。
+
+- 参照先 `UID` が存在するか
+- predecessor に循環がないか
+- 日付や duration の整合性が取れているか
+- summary task に対する制約を満たすか
+- 削除や移動で不整合が起きないか
+
+### 5. 用途別 JSON 投影
+
+AI に見せる JSON は単一万能形式ではなく、用途別に投影を分ける余地がある。
+
+例:
+
+- 編集用ビュー
+- 要約用ビュー
+- 提案生成用ビュー
+
+### 6. 競合検出
+
+古いスナップショットを前提に AI が Patch を返す可能性がある。
+
+そのため Patch には次のような競合検出情報を持たせる余地がある。
+
+- `base_revision`
+- `snapshot_hash`
+
+### 7. 権限制御
+
+AI に許す操作範囲をセッション単位で制限できるようにする。
+
+例:
+
+- タスク名変更のみ許可
+- 依存関係変更は禁止
+- 削除操作は禁止
+
+### 8. エラー分類
+
+Patch 適用失敗時は、単なる失敗ではなく理由を分類して返せるようにする。
+
+例:
+
+- schema error
+- reference error
+- business rule violation
+- conflict
+
+## 現時点の暫定結論
+
+`mikuproject` における生成AI連携の基本方針は次のとおりとする。
+
+- 外部互換と保存は `MS Project XML`
+- 内部処理と AI 連携は正規化 JSON
+- AI の返却は Patch JSON
+- 妥当性確認は schema と独自 validation を組み合わせる
+
+この方針により、XML 互換性を保ったまま、AI 編集に必要な安全性と実装容易性を両立しやすくなる。
diff --git a/docs/native-svg-spec.md b/docs/native-svg-spec.md
new file mode 100644
index 0000000..904695d
--- /dev/null
+++ b/docs/native-svg-spec.md
@@ -0,0 +1,93 @@
+# native SVG spec
+
+この文書は、`mikuproject` における自前 `SVG` 描画の最小仕様メモである。
+
+## 位置づけ
+
+- 自前 `SVG` 描画は、`Mermaid` の置き換えを目的としない
+- `Mermaid` 出力は、`Markdown` / 設計資料向けの軽量テキスト表現として残す
+- 自前 `SVG` 描画は、見た目を制御しやすい preview / download 向けの別系統出力として扱う
+
+## 目的
+
+- `WBS` を、`Mermaid` よりも見た目を制御しやすい `SVG` として描画する
+- 同じ描画結果を、画面内 preview と `SVG` download の両方で使う
+- `mikuproject` 内の他出力と表示解釈がぶれないようにする
+
+## 入力
+
+- 入力は `ProjectModel` とする
+- 表示期間、`holiday`、`business day`、進捗帯計算の基準は、`WBS XLSX` と同じロジックを使う
+- `WBS XLSX` 側の `holidayDates`、表示期間、営業日判定と意味が一致することを優先する
+
+## MVP の描画要素
+
+MVP では、少なくとも次を描画対象とする。
+
+- phase 行
+- task bar
+- milestone
+- today line
+- task label
+- 日付軸
+
+補足:
+
+- `WBS XLSX` で進捗を `■` や `□` で表している表現は、native `SVG` では持ち込まない
+
+## MVP のレイアウト方針
+
+- 横軸は日単位とする
+- 縦軸は task 1 行固定とする
+- 折り返しは行わない
+- ラベル衝突は初期段階では扱わない
+- まずは素直に描けることを優先し、凝った自動レイアウトは後回しにする
+
+## ラベル配置モード
+
+- native `SVG` のラベル配置には 2 つのモードを持てるようにする
+- 新デフォルトは `近接ラベル` とする
+- 旧来方式は `一覧ラベル` とする
+
+### 近接ラベル
+
+- phase / task / milestone の名称を、対応する図形の右または左に近接して配置する
+- 目の移動を減らし、ガント図としての読みやすさを優先する
+- native `SVG` の既定表示は、この `近接ラベル` を前提とする
+
+### 一覧ラベル
+
+- 名称を左側の一覧領域にまとめて表示する
+- 長い名称に強く、旧来の一覧 + 図形の見え方を維持したい場合に使う
+- 旧来方式として残すが、既定にはしない
+
+## 出力方針
+
+- 自前 `SVG` は preview 用と download 用を兼ねる単一の描画結果として生成する
+- 生成した `SVG` 文字列を、そのまま preview に埋め込み、そのまま保存できる形を基本とする
+- 線幅や寸法は `px` 前提ではなく、`viewBox` に基づく `SVG` の user unit で扱う
+
+## 役割分担
+
+- `Mermaid`
+ - `Markdown` / 設計資料向け
+ - 軽量テキスト表現
+- native `SVG`
+ - 見た目重視の preview / download 向け
+ - 形状、色、配置を `mikuproject` 側で制御するための描画
+
+## 非目標
+
+MVP では、次は優先しない。
+
+- `Mermaid` の完全互換
+- 折り返しやラベル衝突回避
+- 高度な自動レイアウト
+- 過剰な装飾やアニメーション
+- `MS Project` 代替のような重い描画編集機能
+
+## 実装上の含意
+
+- できるだけ `WBS XLSX` の表示ロジックを再利用し、判断に迷ったときは `WBS XLSX` に寄せる
+- `Mermaid` と native `SVG` は別出力として併存させる
+- 表示品質を上げたい要求は、まず native `SVG` 側で受ける前提とする
diff --git a/docs/screenshots/excel01.png b/docs/screenshots/excel01.png
new file mode 100644
index 0000000..bbb9b20
Binary files /dev/null and b/docs/screenshots/excel01.png differ
diff --git a/docs/screenshots/screen01.png b/docs/screenshots/screen01.png
new file mode 100644
index 0000000..1fa5af5
Binary files /dev/null and b/docs/screenshots/screen01.png differ
diff --git a/docs/screenshots/screen02.png b/docs/screenshots/screen02.png
new file mode 100644
index 0000000..51c5770
Binary files /dev/null and b/docs/screenshots/screen02.png differ
diff --git a/docs/screenshots/screen03.png b/docs/screenshots/screen03.png
new file mode 100644
index 0000000..c658956
Binary files /dev/null and b/docs/screenshots/screen03.png differ
diff --git a/docs/spec.md b/docs/spec.md
new file mode 100644
index 0000000..de6dd4f
--- /dev/null
+++ b/docs/spec.md
@@ -0,0 +1,1093 @@
+# mikuproject
+
+`mikuproject` は、MS Project XML 形式の入出力を扱うプロジェクト管理アプリとして設計する。
+
+この文書は `mikuproject` の仕様メモであり、README の代わりではない。利用方法やビルド手順は `README.md` に置き、未完了タスクは `docs/TODO.md` に置く。
+
+配置先:
+
+- `docs/spec.md`
+
+アプリ名:
+
+- `mikuproject`
+
+前提:
+
+- このリポジトリ流儀の single-file web app とする
+- ローカルで動作する HTML ツールとして構築する
+- まずは UI よりも、MS Project XML の入出力と内部モデル化を優先する
+- `MS Project XML` を意味の基軸として扱う
+- 内部では `ProjectModel` を中立表現として扱う
+- `.xlsx` は確認・可視化・限定編集のための周辺表現として扱う
+- 仕様判断に迷った場合は、独自都合よりも `MS Project 仕様` に立ち返って判断する
+- `MS Project` 実機は未保有である
+
+立ち位置:
+
+- `mikuproject` は `MS Project` 代替製品を目指すものではない
+- 中核は、`MS Project XML` をハブにして `XLSX / Markdown / JSON / Mermaid / 生成AI / MS Project` をつなぐ変換・可視化・橋渡しツールである
+- したがって、重要機能の優先順位も「橋渡しを強くするか」で判断する
+- 具体的には、差分可視化、Patch 適用、phase 単位の scoped export/import、import/export の扱い可視化は優先しやすい
+- 一方で、重い管理機能や UI 内完結の大規模操作は優先度を上げすぎない
+
+## STEP 1 の目的
+
+STEP 1 の目的は、`MS Project XML` を意味的に往復できる状態を作ること。
+
+ここでいう「往復できる」とは、次を意味する。
+
+- `MS Project XML` を読める
+- 必要な情報を内部モデルへ落とせる
+- 内部モデルから `MS Project XML` を再生成できる
+- 再生成した XML を、少なくとも `mikuproject` 自身で再読込できる
+- 主要フィールドが壊れず往復できる
+
+注意:
+
+- 目標は「元の XML と完全一致」ではない
+- 目標は「意味的に往復できる」ことである
+
+## `.xlsx` の位置づけ
+
+`mikuproject` における `.xlsx` は、`MS Project XML` の代替正本ではない。
+
+- `MS Project XML` は意味の基軸
+- `ProjectModel` は内部の中立表現
+- `.xlsx` は確認・可視化・限定編集のための周辺表現
+
+したがって、`.xlsx` 対応は `MS Project XML` の仕様を置き換えるためではなく、`ProjectModel` を介した補助入出力として追加する。
+
+同様に、構造忠実 workbook の JSON 版も、`MS Project XML` の代替正本ではなく、`.xlsx` の写し身として扱う。
+
+一方で、生成AI 連携の編集用 JSON はこれと別系統のものとし、当面は workbook JSON と混同しないよう `.editjson` 拡張子を推奨する。
+
+現時点で想定する経路は次のとおり。
+
+- `MS Project XML -> ProjectModel -> .xlsx`
+- `.xlsx -> ProjectModel -> MS Project XML`
+- `MS Project XML -> ProjectModel -> workbook JSON`
+- `workbook JSON -> ProjectModel -> MS Project XML`
+
+ただし、`.xlsx -> ProjectModel` は自由編集をそのまま受け入れるのではなく、編集可能な列を限定した部分更新として扱う。
+
+`workbook JSON -> ProjectModel` も同様に、自由編集をそのまま受け入れるのではなく、`.xlsx import` と同じ編集可能列の部分更新として扱う。
+
+現時点の `.xlsx` / workbook JSON 周りは、実装済みの限定 import/export として次のように整理できる。
+
+### 現状実装
+
+- 構造忠実な汎用 workbook export/import
+ - `Project / Tasks / Resources / Assignments / Calendars` を `ProjectModel` 構造に沿って扱う
+- 構造忠実 workbook の JSON export/import
+ - `mikuproject_workbook_json` として、`XLSX` workbook の論理構造を JSON へ写して扱う
+ - `format = "mikuproject_workbook_json"`、`version = 1` を持つ
+ - `Project / Tasks / Resources / Assignments / Calendars / NonWorkingDays` を `sheets` 配下に持つ
+ - import 時の反映対象列・キー・部分更新ルールは `XLSX Import` と同じにする
+- 表示専用の `WBS` workbook export
+ - `Tasks` 中心の別 workbook として `.xlsx` 出力できる
+ - 現時点では export 専用であり、import は扱わない
+ - `WBS XLSX Export` では、`ProjectModel` から補完した既定祝日と、UI で指定した追加祝日を合成して扱う
+ - 指定した祝日は WBS 日付帯で祝日色として表示する
+ - sample 生成では、`Calendar.Exceptions` のうち非稼働日例外を祝日候補として WBS workbook へ反映する
+ - 現行レイアウトでは、先頭に `プロジェクト情報`、続いて `凡例` と `サマリ` を置き、その下に日付帯と task 一覧を並べる
+ - `プロジェクト情報` ブロックは先頭に置き、`プロジェクト名 / カレンダ / 開始日 / 終了日 / 現在日 / 祝日` を持つ
+ - `凡例` ブロックでは、進捗済み / 予定帯 / 当日 / 週頭 / 週末 / 祝日 / フェーズ / 進捗済みタスク / 予定タスク / マイルストーン / サマリ / クリティカル / 未設定 を見分けられるようにする
+ - `サマリ` ブロックには `表示日 / 表示週 / 営業日 / 前日数 / 後日数 / 表示 / 進捗 / 基準日 / タスク / リソース / 割当 / カレンダ` を持つ
+ - `サマリ` の値側は、長い日付がはみ出しにくいように結合セルで表示する
+ - 日付帯では、日付行と曜日行を分けて表示し、週ラベル専用行は持たない
+ - 曜日帯では `Sat` と `Sun` を強めの週末色で表示し、祝日は別色で表示する
+ - `タスク詳細` は `Task.Notes` を表示し、空の場合は `-` とする
+ - `J2` には `出力日時 YYYY-MM-DD HH:mm` を出す
+- 表示専用の `WBS Markdown` export
+ - `WBS XLSX` と同じく、人が読むための帳票として扱う
+ - 当面は export 専用であり、import は扱わない
+ - `WBS XLSX` と同じ `ProjectModel` を入力にし、表示内容もできるだけ揃える
+ - 少なくとも `プロジェクト情報`、`WBS 本体`、`サマリ` を持つ
+ - `プロジェクト情報` では、見出し上の主名として `Project.Name` を使う
+ - `Project.Title` は、Markdown 出力の主表示には使わない
+ - 1 つの Markdown に、`WBS ツリー` と `WBS テーブル` の両方を含める
+ - 出力順は、`プロジェクト情報`、`WBS ツリー`、区切り線、`WBS テーブル`、区切り線、`サマリ` を基本とする
+ - 前半の主表示は `WBS ツリー` とし、`WBS テーブル` は確認用の詳細表、その後ろに `サマリ` を置く
+ - table 形式では、`プロジェクト情報` と `サマリ` を小さな Markdown table に分ける
+ - `WBS テーブル` は、可読性を優先して 1 個の大 table とする
+ - 結合セル、塗り色、罫線、列幅は Markdown へは持ち込まない
+ - `WBS ツリー` では、task 階層をインデントと記号で表す
+ - `WBS ツリー` では、子 task を `┗ ` で表し、階層が深い場合は `全角空白 + ┗ ` を段数ぶん積む形とする
+ - `WBS ツリー` は Markdown の list 記法に無理に寄せず、等幅で読める `WBS の文字` として出す
+ - `WBS ツリー` の task 行には、少なくとも `WBS / 名称 / 開始 / 終了 / 進捗` を含められるようにする
+ - `タスク詳細` は、`WBS テーブル` では列として保持し、`WBS ツリー` では task 本文の次行または後置表現で保持する
+ - 日付帯や進捗帯のような色依存の表現は、そのまま再現しない
+ - 色依存の表現は、日付文字列・数値へ落として表す
+ - `WBS Markdown` は、`WBS XLSX` と競合するものではなく、軽量なテキスト共有用の派生表現として扱う
+ - `WBS ツリー` と `WBS テーブル` の task 集合は一致させ、片方だけに存在する task を作らない
+ - 当面の仕様検討では、まずこの両方入り構成で sample 出力を作り、前半と後半の情報差を確認する
+- 表示専用の `WBS記述書 Markdown` export
+ - `WBS` の階層表現そのものとは別物として扱う
+ - 目的は、各 task の意味・成果物・完了条件などを文章で確認しやすくすること
+ - 当面は export 専用であり、import は扱わない
+ - 最小設計では、保持元を `Task.ExtendedAttribute` 主体とし、長文補足のみ `Task.Notes` を使う
+ - 最小設計で扱う項目は、少なくとも次の 5 つとする
+ - `TaskPurpose`
+ - `TaskDeliverable`
+ - `TaskOutOfScope`
+ - `TaskDoneDefinition`
+ - `TaskOwner`
+ - 上記 5 項目は `Task.ExtendedAttribute` の `FieldName` で識別する前提とする
+ - 補足本文や自由記述は `Task.Notes` を使う
+ - Markdown 出力では、task ごとに 1 節を持つ構成を基本とする
+ - 各節では、少なくとも `WBS / 名称 / 目的 / 成果物 / スコープ外 / 完了条件 / 担当 / 補足` を必要に応じて出す
+ - 未設定の項目は空欄のまま出さず、省略する
+ - `WBS記述書 Markdown` は、`WBS Markdown` の代替ではなく、task 説明を補う別出力として扱う
+ - 当面は新規入力 UI を先に増やさず、既存の `ExtendedAttribute` / `Notes` からの export を優先する
+
+### workbook JSON の位置づけ
+
+`mikuproject` は、構造忠実 workbook のテキスト版として `mikuproject_workbook_json` を持てるようにする。
+
+これは生成AI向け projection JSON とは別物であり、`XLSX` の写し身として扱う。
+
+- 目的は、構造忠実 workbook をテキストで扱いやすくすること
+- `XLSX` と意味を揃えた補助入出力として扱う
+- 新しい編集モデルや新しい意味体系は持ち込まない
+- styling、列幅、merge、塗り色などの表示情報は持たない
+
+`mikuproject_workbook_json` の最小外形は次のとおり。
+
+```json
+{
+ "format": "mikuproject_workbook_json",
+ "version": 1,
+ "sheets": {
+ "Project": [],
+ "Tasks": [],
+ "Resources": [],
+ "Assignments": [],
+ "Calendars": [],
+ "NonWorkingDays": []
+ }
+}
+```
+
+sheet 名は、構造忠実 workbook の `XLSX` と同じく次で固定する。
+
+- `Project`
+- `Tasks`
+- `Resources`
+- `Assignments`
+- `Calendars`
+- `NonWorkingDays`
+
+各 sheet の列名は、対応する `XLSX` workbook の header と完全一致で固定する。
+
+つまり、`mikuproject_workbook_json` は `XLSX` workbook の論理構造を JSON へ写したものであり、列追加や別名導入は行わない。
+
+import 時の扱いも `XLSX Import` と完全に揃える。
+
+- 反映対象列は `XLSX Import` と完全一致とする
+- 反映単位は部分更新とする
+- `Tasks / Resources / Assignments / Calendars` は `UID` をキーに扱う
+- `NonWorkingDays` は `CalendarUID + Index` をキーに扱う
+- 未対応列や未知の列は反映対象にしない
+- `workbook JSON` だからといって自由編集を全量反映しない
+
+拡張子運用:
+
+- `mikuproject_workbook_json` は、構造忠実 workbook の JSON として `.json` を推奨する
+- 生成AI 連携の編集用 JSON は、workbook JSON と区別するため `.editjson` を推奨する
+- `project_draft_view` は当面この編集用 JSON 群に含め、`.editjson` で受け渡す前提とする
+
+### 新規作成時の既定非稼働日
+
+`mikuproject` は `MS Project XML` を意味の基軸として扱うため、新規 project 作成時の既定非稼働日も、できる限り `MS Project XML` の calendar 表現にそのまま載る形で扱う。
+
+新規 project を作成する場合、明示的な calendar 指定がなければ、既定 calendar を 1 つ作り、その中に次を合成して持たせる前提とする。
+
+- 土日を非稼働日とする週次ルールを `WeekDays` へ設定する
+- 日本の祝日を非稼働日例外として `Exceptions` へ設定する
+
+これらは独自の「非稼働日種別」や別 calendar 概念を正本側へ追加するのではなく、最初から `MS Project XML` として自然な 1 つの calendar にまとめて扱う。
+
+この既定 calendar の表示名は、当面 `Standard` を既定値として扱う。新規 project に calendar が存在せず、project 側にも明示的な `CalendarUID` 指定がない場合に限り、この `Standard` calendar を自動補完する。
+
+補完時は少なくとも次を行う。
+
+- `Calendars` に既定 calendar を 1 件追加する
+- `Project.CalendarUID` をその calendar の `UID` に設定する
+- task / resource に個別 calendar 指定がない場合は、project 既定 calendar を継承する前提で扱う
+
+この既定 calendar に含める祝日例外は、無制限に生成するのではなく、原則として project の `StartDate` から `FinishDate` までの期間内に入るものへ限定する。
+
+この制限は、calendar が存在しない project に対して `mikuproject` が既定 calendar を自動補完する場合にだけ適用する。
+
+すでに calendar が存在する場合や、ユーザーが明示的に指定した calendar / `Exceptions` については、`mikuproject` 側で自動的に再構成・再トリミングしない前提とする。
+
+意図:
+
+- 暗黙の「土日休み」を仕様化して、生成AI による新規計画作成でも前提を揃えやすくする
+- 日本の業務予定として自然な初期状態を作る
+- project 期間外の祝日を `MS Project XML` へ過剰に書き出さず、正本の見通しと差分の素直さを保つ
+- 既存 calendar やユーザー指定内容を、`mikuproject` の都合で自動変更しない
+- `MS Project XML` の `WeekDays / Exceptions` 表現をそのまま使い、独自概念への依存を増やさない
+- 将来の実装では、明示的な calendar がある場合にこの既定値を上書きまたは置換できる余地を残す
+
+### WBS workbook の非稼働日反映方針
+
+`WBS XLSX Export` では、単に祝日色を塗るだけでなく、表示上の期間計算にも非稼働日を反映する方向で扱う。
+
+- 期間帯の表示では、非稼働日を作業期間から除外する
+- 進捗帯の表示でも、同じ非稼働日基準を使う
+- 祝日色の表示と、営業日ベースの期間/進捗表示とで基準が食い違わないようにする
+
+ここでいう非稼働日には、少なくとも次を含める。
+
+- `Calendar.WeekDays` による週次の非稼働日
+- `Calendar.Exceptions` による祝日その他の非稼働日例外
+
+土日と祝日は、WBS 上では表示都合で別色にしてよいが、そのために `MS Project XML` 正本へ別概念を追加しない。色分けは `mikuproject` 側の表示ルールで扱う。
+
+現時点で `XLSX Import` の反映対象としている列は次のとおり。
+
+- `Project`: `Name / Title / Author / Company / StartDate / FinishDate / CurrentDate / StatusDate / CalendarUID / MinutesPerDay / MinutesPerWeek / DaysPerMonth / ScheduleFromStart`
+- `Tasks`: `Name / Start / Finish / PercentComplete / PercentWorkComplete / Notes`
+- `Resources`: `Name / Group / MaxUnits`
+- `Assignments`: `Units / Work / PercentWorkComplete`
+- `Calendars`: `Name / IsBaseCalendar / BaseCalendarUID`
+- `NonWorkingDays`: `Name / Date / FromDate / ToDate / DayWorking`
+
+一覧で見ると次のとおり。
+
+| Sheet | Editable Columns | Notes |
+| --- | --- | --- |
+| `Project` | `Name / Title / Author / Company / StartDate / FinishDate / CurrentDate / StatusDate / CalendarUID / MinutesPerDay / MinutesPerWeek / DaysPerMonth / ScheduleFromStart` | project 単位の部分更新として扱う |
+| `Tasks` | `Name / Start / Finish / PercentComplete / PercentWorkComplete / Notes` | `UID` をキーに部分更新する |
+| `Resources` | `Name / Group / MaxUnits` | `UID` をキーに部分更新する |
+| `Assignments` | `Units / Work / PercentWorkComplete` | `UID` をキーに部分更新する |
+| `Calendars` | `Name / IsBaseCalendar / BaseCalendarUID` | `UID` をキーに部分更新する |
+| `NonWorkingDays` | `Name / Date / FromDate / ToDate / DayWorking` | `CalendarUID + Index` をキーに部分更新する |
+
+`mikuproject_workbook_json` の import も、この表と同じ反映対象列・キー・部分更新ルールを使う。
+
+`Tasks` シートの header ごとの扱いは次のとおり。
+
+| Tasks Header | 扱い |
+| --- | --- |
+| `UID` | 表示のみ |
+| `ID` | 表示のみ |
+| `Name` | import 可 |
+| `OutlineLevel` | 表示のみ |
+| `OutlineNumber` | 表示のみ |
+| `WBS` | 表示のみ |
+| `Start` | import 可 |
+| `Finish` | import 可 |
+| `Duration` | 表示のみ |
+| `PercentComplete` | import 可 |
+| `PercentWorkComplete` | import 可 |
+| `Milestone` | 表示のみ |
+| `Summary` | 表示のみ |
+| `Critical` | 表示のみ |
+| `CalendarUID` | 表示のみ |
+| `Predecessors` | 表示のみ |
+| `Notes` | import 可 |
+
+`Resources` と `Assignments` が 0 件の workbook では、どの列が import 対象か分かるように、editable 列だけ着色されたダミー行を 1 行出してよい。これは表示補助であり、`UID` 等のキーが空なので import 時には無視される。
+
+### Calendar 編集方針
+
+`Calendars / Exceptions` は、業務上は重要だが壊しやすい領域でもあるため、当面は `mikuproject` の画面上で直接編集しない方針とする。
+
+- 画面上では、calendar の存在、件数、参照状況、既定祝日の補完結果などの read-only 確認を主とする
+- `Calendars / Exceptions / WeekDays / WorkWeeks` の実編集は、`MS Project XML` または `XLSX Import` 経由で行う
+- 画面側に独自の calendar editor を持ち込まず、`MS Project XML` を意味の基軸とする設計を優先する
+
+### 現時点で反映対象外のもの
+
+これ以外の列や、未対応シートの編集は、現在の `XLSX Import` では反映対象としない。特に `Calendars` では、`WeekDays / WorkWeeks` はまだ反映対象外とする。`Exceptions` は `NonWorkingDays` シートとして限定的に扱う。
+
+## WBS ステータスの扱い方針
+
+`WBS` 用の業務ステータスは、`PercentComplete` の派生値としてではなく、`Task.ExtendedAttribute` に保持する前提で扱う。
+
+- `Complete` と `Cancelled` を区別できるようにする
+- `PercentComplete=100` とは別軸の状態として保持する
+- `MS Project XML` の round-trip で保持しやすい形を優先する
+
+`MS Project` 互換の観点では、`Active=false` は「スケジュール対象外」として使いうるが、`WBS` 上での業務ステータス表示とは役割が異なる。そのため、`mikuproject` では `Cancelled` などの業務値を `ExtendedAttribute` に置く方針を採る。
+
+具体的な `FieldID / FieldName / 値候補` は今後の設計項目とする。
+
+### UI 上の確認手段
+
+`XLSX Import` 後の validation では、`Calendars.BaseCalendarUID` が既存 Calendar を指していない場合や、自身を指している場合の warning も、差分要約と並べて確認できるようにする。
+
+## STEP 1 の完了条件
+
+STEP 1 の完了条件は次のとおり。
+
+- `MS Project XML` を入力として読み込める
+- XML から必要な情報を抽出し、内部モデルを生成できる
+- 内部モデルから `MS Project XML` を出力できる
+- 出力した XML を再読込しても例外にならない
+- `xml -> model -> xml -> model` の往復後に、主要フィールドが保持されている
+
+## 現時点の実装メモ
+
+現時点では、STEP 1 の確認をしやすくするために、次の補助表示を持つ。
+
+- `Project / Tasks / Resources / Assignments / Calendars` の件数サマリ
+- 内部モデルの JSON 表示
+- `Project / Tasks / Resources / Assignments / Calendars` の preview 表示
+- validation メッセージ表示
+
+preview / validation の現状メモ:
+
+- project は `OutlineCodes / WBSMasks / ExtendedAttributes` の代表値を preview で追えるようにする
+- task / resource / assignment は参照先の名前つきで追えるようにする
+- calendar は `Project / Task / Resource / BaseCalendar` からの参照関係を追えるようにする
+- validation は `UID` だけでなく、可能な範囲で名前つきで追えるようにする
+
+注意:
+
+- これらは STEP 1 の主目的そのものではなく、意味的ラウンドトリップを確認しやすくするための補助機能である
+- `.xlsx` 表示や `.xlsx import/export` も、同様に確認と限定編集のための補助機能として扱う
+- `XLSX Import` の反映結果は、`Tasks / Resources / Assignments` ごとの件数と `UID` 単位の差分要約で確認できるようにする
+- `XLSX Import` 後も validation を走らせ、反映結果と検証メッセージを同時に確認できるようにする
+- validation では、`PercentComplete` の範囲外や `Start > Finish` のような編集結果も UI 上で追えるようにする
+- validation が残っていても、`XML Export` はその時点の XML をそのまま保存できるようにする
+
+### 現在の UI 上の整理
+
+現行 UI は、概ね次の 3 画面構成で整理している。
+
+- `Input`
+ - `Load from file` から `MS Project XML`、`XLSX`、workbook JSON (`.json`)、生成AI向け編集用 JSON (`.editjson`)、`CSV + ParentID` を読込
+ - サンプル XML の読込
+ - 生成AIが返した `project_draft_view` の貼り付け取込
+- `Overview`
+ - 内部モデルの要約確認
+ - validation の確認
+ - native `SVG` preview の確認
+ - preview 表示
+- `Output`
+ - `MS Project XML`、`XLSX`、`WBS XLSX`、workbook JSON、`CSV + ParentID` の保存
+ - Mermaid fenced code block を含む `.md` と native `SVG` の保存
+ - 生成AI向け `project_overview_view` / `phase_detail_view` / `full bundle` の `.editjson` 出力
+
+生成AI連携の現状実装範囲:
+
+- 既存 project 向けには `project_overview_view` / `phase_detail_view` / `full bundle` の export を持つ
+- 新規生成向けには `project_draft_view` の import を持つ
+- `task_edit_view` の export と Patch JSON の import / 適用は、現時点では未実装とする
+
+ここでいう `Overview` は、内部実装上の `transform` 相当タブを、ユーザー向けに読み替えた呼称である。
+
+## STEP 1 の入力データ前提
+
+`MS Project` 実機を保有していないため、STEP 1 の入力データ前提は次のとおりとする。
+
+- Microsoft 公開の `MS Project XML schema` を基準にする
+- 当面の基準スキーマは `Microsoft Office Project 2007 XML Data Interchange Schema` とする
+- 具体的には `https://schemas.microsoft.com/project/2007/` および `mspdi_pj12.xsd` を基準とする
+- STEP 1 で扱うファイル形式は、`.mpp` ではなく `.xml` の `MS Project XML 形式` とする
+- `.mpp` は MS Project のネイティブ本体形式、`.xml` は外部連携や交換のための XML 表現と捉える
+- STEP 1 の検証用 XML は、自作の最小サンプル XML を用いる
+- まずは `mikuproject` 自身で意味的に往復できることを優先する
+- 実際の `MS Project` 本体が出力した XML との互換確認は、将来課題として扱う
+
+検証用データの参照元メモ:
+
+- 一時的な検証用データの参照元として `https://github.com/rpbouman/open-msp-viewer/` を利用する
+- ただし、Git 管理下へそのまま格納するかどうかは別途判断する
+- `open-msp-viewer` プロジェクトのサンプルには大いに助けられた。感謝する
+- 実例 XML から見えた保持項目ギャップは `docs/gap-notes.md` に整理する
+- 仕様判断で迷った場合は、MicrosoftDocs の Project XML Data Interchange リファレンスも補助資料として参照する
+ - `https://github.com/MicrosoftDocs/office-developer-msproject-xml-docs/tree/main/project-xml-data-interchange`
+
+## STEP 1 で扱う対象
+
+STEP 1 では、MS Project XML のうち、次の情報を優先して扱う。
+
+- `Project` 基本情報
+- `Tasks`
+- `Resources`
+- `Assignments`
+- 必要最小限の `Calendars`
+- `PredecessorLink` などの依存関係
+
+## STEP 1 で優先する主要フィールド
+
+### Project
+
+- `Name`
+- `Title`
+- `Author`
+- `Company`
+- `CreationDate`
+- `LastSaved`
+- `SaveVersion`
+- `CurrentDate`
+- `StartDate`
+- `FinishDate`
+- `ScheduleFromStart`
+- `DefaultStartTime`
+- `DefaultFinishTime`
+- `MinutesPerDay`
+- `MinutesPerWeek`
+- `DaysPerMonth`
+- `StatusDate`
+- `WeekStartDay`
+- `WorkFormat`
+- `DurationFormat`
+- `CurrencyCode`
+- `CurrencyDigits`
+- `CurrencySymbol`
+- `CurrencySymbolPosition`
+- `FYStartDate`
+- `FiscalYearStart`
+- `CriticalSlackLimit`
+- `DefaultTaskType`
+- `DefaultFixedCostAccrual`
+- `DefaultStandardRate`
+- `DefaultOvertimeRate`
+- `DefaultTaskEVMethod`
+- `NewTaskStartDate`
+- `NewTasksAreManual`
+- `NewTasksEffortDriven`
+- `NewTasksEstimated`
+- `ActualsInSync`
+- `EditableActualCosts`
+- `HonorConstraints`
+- `InsertedProjectsLikeSummary`
+- `MultipleCriticalPaths`
+- `TaskUpdatesResource`
+- `UpdateManuallyScheduledTasksWhenEditingLinks`
+- `CalendarUID`
+- `OutlineCodes`
+- `WBSMasks`
+- `ExtendedAttributes`
+
+### Tasks
+
+- `UID`
+- `ID`
+- `Name`
+- `OutlineLevel`
+- `OutlineNumber`
+- `WBS`
+- `Type`
+- `CalendarUID`
+- `Priority`
+- `Start`
+- `Finish`
+- `Duration`
+- `ActualStart`
+- `ActualFinish`
+- `Deadline`
+- `StartVariance`
+- `FinishVariance`
+- `Work`
+- `WorkVariance`
+- `TotalSlack`
+- `FreeSlack`
+- `Cost`
+- `ActualCost`
+- `RemainingCost`
+- `RemainingWork`
+- `ActualWork`
+- `Milestone`
+- `Summary`
+- `Critical`
+- `PercentComplete`
+- `PercentWorkComplete`
+- `Notes`
+- `ConstraintType`
+- `ConstraintDate`
+- `ExtendedAttribute`
+- `Baseline`
+- `TimephasedData`
+- `TimephasedData`
+- `PredecessorLink`
+
+### Resources
+
+- `UID`
+- `ID`
+- `Name`
+- `Type`
+- `Initials`
+- `Group`
+- `WorkGroup`
+- `MaxUnits`
+- `CalendarUID`
+- `StandardRate`
+- `StandardRateFormat`
+- `OvertimeRate`
+- `OvertimeRateFormat`
+- `CostPerUse`
+- `Work`
+- `ActualWork`
+- `RemainingWork`
+- `Cost`
+- `ActualCost`
+- `RemainingCost`
+- `PercentWorkComplete`
+- `ExtendedAttribute`
+- `Baseline`
+- `TimephasedData`
+
+### Assignments
+
+- `UID`
+- `TaskUID`
+- `ResourceUID`
+- `Start`
+- `Finish`
+- `StartVariance`
+- `FinishVariance`
+- `Delay`
+- `Milestone`
+- `WorkContour`
+- `Units`
+- `Work`
+- `Cost`
+- `ActualCost`
+- `RemainingCost`
+- `PercentWorkComplete`
+- `OvertimeWork`
+- `ActualOvertimeWork`
+- `ActualWork`
+- `RemainingWork`
+- `ExtendedAttribute`
+- `Baseline`
+
+### Calendars
+
+- `UID`
+- `Name`
+- `IsBaseCalendar`
+- `BaseCalendarUID`
+- `WeekDays`
+- `Exceptions`
+- `WorkWeeks`
+
+## STEP 1 で後回しにするもの
+
+STEP 1 では、次のようなものは後回し候補とする。
+
+- `.xlsx import` における自由編集の全面対応
+- `Calendars / Baseline / TimephasedData / ExtendedAttributes` の `.xlsx` 編集反映
+
+- 表示設定
+- UI レイアウト情報
+- 独自拡張要素
+- 完全互換のために必要だが、主要データの意味保持に直結しない補助ノード群
+
+## 内部モデル方針
+
+内部モデルは、MS Project XML をそのまま保持するのではなく、意味的に扱いやすい正規化済みのモデルとする。
+
+最小モデル案:
+
+```ts
+type ProjectModel = {
+ project: {
+ name: string;
+ currentDate?: string;
+ startDate: string;
+ finishDate: string;
+ scheduleFromStart: boolean;
+ defaultStartTime?: string;
+ defaultFinishTime?: string;
+ minutesPerDay?: number;
+ minutesPerWeek?: number;
+ daysPerMonth?: number;
+ statusDate?: string;
+ weekStartDay?: number;
+ workFormat?: number;
+ durationFormat?: number;
+ currencyCode?: string;
+ currencyDigits?: number;
+ currencySymbol?: string;
+ currencySymbolPosition?: number;
+ fyStartDate?: string;
+ fiscalYearStart?: boolean;
+ criticalSlackLimit?: number;
+ defaultTaskType?: number;
+ defaultFixedCostAccrual?: number;
+ defaultStandardRate?: string;
+ defaultOvertimeRate?: string;
+ defaultTaskEVMethod?: number;
+ newTaskStartDate?: number;
+ newTasksAreManual?: boolean;
+ newTasksEffortDriven?: boolean;
+ newTasksEstimated?: boolean;
+ actualsInSync?: boolean;
+ editableActualCosts?: boolean;
+ honorConstraints?: boolean;
+ insertedProjectsLikeSummary?: boolean;
+ multipleCriticalPaths?: boolean;
+ taskUpdatesResource?: boolean;
+ updateManuallyScheduledTasksWhenEditingLinks?: boolean;
+ calendarUID?: string;
+ outlineCodes: OutlineCodeModel[];
+ wbsMasks: WBSMaskModel[];
+ extendedAttributes: ProjectExtendedAttributeModel[];
+ };
+ calendars: CalendarModel[];
+ tasks: TaskModel[];
+ resources: ResourceModel[];
+ assignments: AssignmentModel[];
+};
+
+type TaskModel = {
+ uid: string;
+ id: string;
+ name: string;
+ outlineLevel: number;
+ outlineNumber: string;
+ wbs?: string;
+ type?: number;
+ calendarUID?: string;
+ priority?: number;
+ start: string;
+ finish: string;
+ duration: string;
+ actualStart?: string;
+ actualFinish?: string;
+ deadline?: string;
+ startVariance?: string;
+ finishVariance?: string;
+ work?: string;
+ workVariance?: string;
+ totalSlack?: string;
+ freeSlack?: string;
+ cost?: number;
+ actualCost?: number;
+ remainingCost?: number;
+ remainingWork?: string;
+ actualWork?: string;
+ milestone: boolean;
+ summary: boolean;
+ critical?: boolean;
+ percentComplete: number;
+ percentWorkComplete?: number;
+ notes?: string;
+ constraintType?: number;
+ constraintDate?: string;
+ predecessors: PredecessorModel[];
+};
+
+type PredecessorModel = {
+ predecessorUid: string;
+ type?: number;
+ linkLag?: string;
+};
+
+type ResourceModel = {
+ uid: string;
+ id: string;
+ name: string;
+ type?: number;
+ initials?: string;
+ group?: string;
+ workGroup?: number;
+ maxUnits?: number;
+ calendarUID?: string;
+ standardRate?: string;
+ standardRateFormat?: number;
+ overtimeRate?: string;
+ overtimeRateFormat?: number;
+ costPerUse?: number;
+ work?: string;
+ actualWork?: string;
+ remainingWork?: string;
+ cost?: number;
+ actualCost?: number;
+ remainingCost?: number;
+ percentWorkComplete?: number;
+};
+
+type AssignmentModel = {
+ uid: string;
+ taskUid: string;
+ resourceUid: string;
+ start?: string;
+ finish?: string;
+ startVariance?: string;
+ finishVariance?: string;
+ delay?: string;
+ milestone?: boolean;
+ workContour?: number;
+ units?: number;
+ work?: string;
+ cost?: number;
+ actualCost?: number;
+ remainingCost?: number;
+ percentWorkComplete?: number;
+ overtimeWork?: string;
+ actualOvertimeWork?: string;
+ actualWork?: string;
+ remainingWork?: string;
+};
+
+type CalendarModel = {
+ uid: string;
+ name: string;
+ isBaseCalendar: boolean;
+ isBaselineCalendar?: boolean;
+ baseCalendarUID?: string;
+ weekDays: Array<{
+ dayType: number;
+ dayWorking: boolean;
+ workingTimes: Array<{
+ fromTime: string;
+ toTime: string;
+ }>;
+ }>;
+ exceptions: Array<{
+ name?: string;
+ fromDate?: string;
+ toDate?: string;
+ dayWorking?: boolean;
+ workingTimes: Array<{
+ fromTime: string;
+ toTime: string;
+ }>;
+ }>;
+ workWeeks: Array<{
+ name?: string;
+ fromDate?: string;
+ toDate?: string;
+ weekDays: Array<{
+ dayType: number;
+ dayWorking: boolean;
+ workingTimes: Array<{
+ fromTime: string;
+ toTime: string;
+ }>;
+ }>;
+ }>;
+};
+```
+
+注意:
+
+- これは STEP 1 の最小モデル案であり、今後拡張の余地がある
+- 日付・期間表現は、まず XML と往復しやすい文字列保持を優先する
+
+## 実装方針
+
+STEP 1 の中核処理は、次のような責務に分ける。
+
+- `parseXmlDocument(xmlText): XMLDocument`
+- `importMsProjectXml(xmlText): ProjectModel`
+- `validateProjectModel(model): ValidationIssue[]`
+- `exportMsProjectXml(model): string`
+- `normalizeProjectModel(model): ProjectModel`
+
+テストの基本方針:
+
+- `xml -> model -> xml -> model` のラウンドトリップを確認する
+- 比較対象は文字列一致ではなく、正規化後の内部モデル一致とする
+
+実装判断の原則:
+
+- 仕様や表現方法に迷った場合は、`MS Project XML` の持ち方を優先する
+- 独自に扱いやすいモデル化は許容するが、`MS Project XML` との意味対応を壊さないことを優先する
+- 特にタスク階層や依存関係は、独自表現へ寄せすぎず、まず `MS Project` 側の表現を基準に考える
+
+## テスト方針
+
+STEP 1 では、少なくとも次を確認する。
+
+- サンプル XML を読み込める
+- 内部モデルへ変換できる
+- 最小妥当性チェック結果を確認できる
+- 再生成 XML を出力できる
+- 再生成 XML を再読込できる
+- 主要フィールドが保持される
+
+比較観点:
+
+- `Project` 基本情報
+- `Tasks` の主要フィールド
+- `Resources` の主要フィールド
+- `Assignments` の主要フィールド
+- 依存関係
+
+## 非目標
+
+STEP 1 では、次は非目標とする。
+
+- MS Project XML の完全再現
+- 元 XML のノード順や空白や書式の完全保持
+- フル機能の編集 UI
+- すべての MS Project XML 要素の対応
+
+## STEP 1 実装済みメモ
+
+現時点の STEP 1 実装では、次が入っている。
+
+- `types.ts`, `msproject-xml.ts`, `main.ts` への責務分離
+- サンプル XML の読込
+- XML 文字列の import
+- 内部モデルから整形済み XML を再生成
+- XML ファイルの export
+- `Project / Tasks / Resources / Assignments / Calendars` の簡易プレビュー表示
+- `project / tasks / resources / assignments / calendars` 単位の検証メッセージ表示
+- `mikuproject` 独自の最小妥当性チェック
+- `Calendar` の `BaseCalendarUID / WeekDays / WorkingTimes` の round-trip
+- `Calendar` の `IsBaselineCalendar / Exceptions / WorkWeeks / Exception WorkingTimes` の round-trip
+- `Resource` の `CalendarUID / StandardRate / CostPerUse` の round-trip
+- `Resource` の `Work / ActualWork / RemainingWork / Cost / ActualCost / RemainingCost / PercentWorkComplete` の round-trip
+- `Assignment` の `StartVariance / FinishVariance` の round-trip
+- `Resource` の `WorkGroup` の round-trip
+- `Assignment` の `Delay / Milestone / WorkContour` の round-trip
+- `Assignment` の `OvertimeWork / ActualOvertimeWork` の round-trip
+- `Task` の `Deadline / StartVariance / FinishVariance` の round-trip
+- `Task` の `WorkVariance / TotalSlack / FreeSlack / Critical` の round-trip
+- `Resource` の `StandardRateFormat / OvertimeRate / OvertimeRateFormat` の round-trip
+- `Assignment` の `PercentWorkComplete / ActualWork / RemainingWork` の round-trip
+- `Project` の `StatusDate / WeekStartDay / WorkFormat / DurationFormat` の round-trip
+- `Project` の `CurrencyCode / CurrencyDigits / CurrencySymbol / CurrencySymbolPosition` の round-trip
+- `Project` の `FYStartDate / FiscalYearStart` の round-trip
+- `Project` の `CriticalSlackLimit / DefaultTaskType` の round-trip
+- `Project` の `DefaultFixedCostAccrual / DefaultStandardRate / DefaultOvertimeRate` の round-trip
+- `Project` の `DefaultTaskEVMethod / NewTaskStartDate` の round-trip
+- `Project` の `NewTasksAreManual / NewTasksEffortDriven` の round-trip
+- `Project` の `NewTasksEstimated / ActualsInSync` の round-trip
+- `Project` の `EditableActualCosts / HonorConstraints` の round-trip
+- `Project` の `InsertedProjectsLikeSummary / MultipleCriticalPaths` の round-trip
+- `Project` の `TaskUpdatesResource / UpdateManuallyScheduledTasksWhenEditingLinks` の round-trip
+- `Project` の `OutlineCodes / WBSMasks` の最小 round-trip
+- `Project` の `ExtendedAttributes` の最小 round-trip
+- `Task` の `ExtendedAttribute` の最小 round-trip
+- `Resource` の `ExtendedAttribute` の最小 round-trip
+- `Assignment` の `ExtendedAttribute` の最小 round-trip
+- `Task` の `Baseline` の最小 round-trip
+- `Assignment` の `Baseline` の最小 round-trip
+- `Resource` の `Baseline` の最小 round-trip
+- `Task` の `TimephasedData` の最小 round-trip
+- `Resource` の `TimephasedData` の最小 round-trip
+- `Assignment` の `TimephasedData` の最小 round-trip
+- `Task / Assignment` の `Cost / ActualCost / RemainingCost` の round-trip
+- round-trip テスト
+
+## Mermaid gantt 出力メモ
+
+現時点では、確認・共有向けの補助出力として `ProjectModel -> Mermaid gantt` の片方向出力を持つ。
+
+目的:
+
+- `MS Project XML` の全情報保持ではなく、task の時系列と大まかな依存関係を軽量に共有する
+- `mikuproject` 内部モデルの内容を、Mermaid 対応環境へ持ち出しやすくする
+
+現時点の出力方針:
+
+- summary task は `section` として扱う
+- summary ではない task のうち、`Start` と `Finish` を持つものを gantt のタスク行として出力する
+- `critical=true` は `crit` として出力する
+- `milestone=true` は `milestone` として出力する
+- `0 < percentComplete < 100` は `active` として出力する
+- `percentComplete >= 100` は `done` として出力する
+- task 名や title は Mermaid で壊れやすい一部記号を簡易正規化して出力する
+- predecessor は、`単一 predecessor` かつ `FS` かつ `lag なし` かつ `duration` を Mermaid 向けへ素直に変換できる task のみ `after ...` でネイティブ出力する
+- 上記に当てはまらない predecessor は、task 名を含むコメント行で補助出力する
+- comment 側の `lag` は、可能な範囲で `2h` のような短い人間向け表現に整形して出力する
+- `lag` がある場合は、`after Prep + 2h` のような擬似読解用 comment も追加する
+- preview と SVG export は native `SVG` 描画を使う
+- phase 背景は交互の淡色で視認補助する
+
+`project_draft_view` からの補完メモ:
+
+- 通常 task で `planned_start` / `planned_finish` が date-only の場合、内部化時に勤務時間帯として `09:00:00` / `18:00:00` を補完して扱うことがある
+- `planned_finish` だけが与えられた通常 task は、まず同日の `planned_start` を補完し、その後に上記の勤務時間帯補完を適用する
+- `is_milestone=true` の task には、この勤務時間帯補完を適用しない
+
+現時点で意図的に落とすもの:
+
+- `Resources`
+- `Assignments`
+- `Calendars`
+- `Baseline`
+- `TimephasedData`
+- コスト系の詳細
+- `PredecessorLink` の完全表現
+
+注意:
+
+- これはあくまで片方向の補助出力であり、`Mermaid gantt -> ProjectModel` の往復は対象外とする
+- 現時点の dependency 表現は部分的にネイティブ化しているが、複数 predecessor、`FS` 以外の link type、lag あり、複雑な duration はコメント保持のままとする
+- どの情報を落としているかは、将来の `CSV + ParentID` 等の交換形式検討と切り分けて扱う
+
+## CSV + ParentID 交換形式メモ
+
+`mikuproject` の次段候補として、`CSV + ParentID` を「まず押さえるべき、よくある交換形式」の第1候補とする。
+
+目的:
+
+- 人が表計算ソフトやスプレッドシートで編集しやすい形を持つ
+- 独自記法を先に増やしすぎず、一般的な交換形式を先に押さえる
+- task 階層を `ParentID` で素直に表現する
+
+最小列候補:
+
+- `ID`
+- `ParentID`
+- `Name`
+
+実用列候補:
+
+- `WBS`
+- `Start`
+- `Finish`
+- `PredecessorID`
+- `Resource`
+- `PercentComplete`
+
+現時点の整理方針:
+
+- まずは単一 CSV を前提に考える
+- task 階層の正本は `ParentID` とし、`WBS` は補助列として扱う候補とする
+- `PredecessorID` は単一値か複数値区切りかを今後決める
+- `Resource` は名前で持つか `ResourceID` で持つかを今後決める
+
+単一 CSV で落ちやすいもの:
+
+- `Assignments` の完全表現
+- `Calendars`
+- `Baseline`
+- `TimephasedData`
+- コスト系の詳細
+
+注意:
+
+- 現時点では仕様草案段階であり、`CSV + ParentID <-> ProjectModel` の完全往復仕様は未確定
+- 将来必要であれば、`tasks.csv / resources.csv / assignments.csv` の複数表構成も比較対象にする
+- 現在の UI では、`CSV + ParentID` は textarea ではなくファイルベースの補助入出力として扱う
+ - `Input` 側は CSV ファイル読込
+ - `Output` 側は CSV ダウンロード
+
+複数 CSV 構成の比較メモ:
+
+- `single CSV` の利点は、人が 1 枚の表で task 階層を編集しやすいこと
+- `single CSV` の弱点は、`Resource` や `Assignment` を task 行へ押し込むため、正規化されず表現が崩れやすいこと
+- `tasks.csv / resources.csv / assignments.csv` の利点は、resource と assignment を独立表現でき、`ResourceID` ベースの安全な往復へ寄せやすいこと
+- `tasks.csv / resources.csv / assignments.csv` の弱点は、人が直接編集するには 1 ファイル増えて分かりにくくなること
+- 現時点では、まず `single CSV` で task 中心の軽量交換を育て、resource / assignment の保持要求が増えた時点で複数 CSV を比較する方針とする
+- その場合の最初の分割候補は `tasks.csv` と `resources.csv` と `assignments.csv` であり、calendar はさらに次段とする
+
+複数 CSV の最小草案:
+
+- `tasks.csv`
+ - 最小列候補: `ID / ParentID / Name`
+ - 実用列候補: `WBS / Start / Finish / PredecessorID / PercentComplete / PercentWorkComplete / Milestone / Summary / Critical / Type / Priority / Work / CalendarUID / ConstraintType / ConstraintDate / Deadline / Notes`
+- `resources.csv`
+ - 最小列候補: `ResourceID / Name`
+ - 実用列候補: `Initials / Group / CalendarUID / MaxUnits / StandardRate / OvertimeRate / CostPerUse`
+- `assignments.csv`
+ - 最小列候補: `AssignmentID / TaskID / ResourceID`
+ - 実用列候補: `Start / Finish / Units / Work / PercentWorkComplete`
+
+草案メモ:
+
+- `tasks.csv` は現在の `single CSV` の task 列をほぼそのまま引き継げる
+- `resources.csv` は name だけでなく `ResourceID` を正本にすることで、同名 resource の衝突を避けやすい
+- `assignments.csv` を分けることで、1 task に複数 resource が割り当たるケースを自然に表現できる
+- 第1段では `calendar` と `baseline/timephased` は複数 CSV にも入れず、別段とする
+- もし複数 CSV に進む場合、最初の実装順は `tasks.csv -> resources.csv -> assignments.csv` が妥当と考える
+
+`tasks.csv` の最小仕様草案:
+
+- 目的は task 階層と task 単体属性を、resource / assignment から切り離して安全に往復すること
+- 正本の階層表現は `ParentID` とし、`WBS` は補助列扱いとする
+- `ID / ParentID / Name` を必須列とする
+- `ID` は CSV 内で一意でなければならない
+- `ParentID` は空文字を root task とみなし、値がある場合は既存 `ID` を指さなければならない
+- `ParentID` の自己参照と循環参照は import error とする
+- `Name` は空不可とする
+- `PredecessorID` は任意列とし、複数値は `|` を正規表現としつつ、import では `,` `;` `、` も受ける
+- `Milestone / Summary / Critical` は `0/1` を正とし、import では `true/false/yes/no` も受ける
+- `PercentComplete / PercentWorkComplete` は `0..100` を想定し、範囲外は validation 対象とする
+- `Start / Finish / ConstraintDate / Deadline` は `MS Project XML` と同じ日時文字列を前提にする
+- `Type / Priority / ConstraintType` は整数列とする
+- `Work` は `PT...` 形式の duration 文字列を前提にする
+
+`tasks.csv` の第1段 scope:
+
+- 含める: 階層、日付、依存、進捗、milestone/summary/critical、主要 task 属性
+- 含めない: `Baseline`, `TimephasedData`, `ExtendedAttributes`, task ごとの cost 詳細
+- `CalendarUID` は保持対象に含めるが、calendar 実体は別表へ分けず参照値扱いに留める
+
+`resources.csv` の最小仕様草案:
+
+- 目的は resource 単体属性を task 行から切り離し、同名 resource を安全に区別できるようにすること
+- 正本の識別子は `ResourceID` とし、`Name` は表示用の主要属性として扱う
+- `ResourceID / Name` を必須列とする
+- `ResourceID` は CSV 内で一意でなければならない
+- `Name` は空不可とする
+- `Name` の重複は直ちに import error とはしないが、運用上は非推奨とする
+- `CalendarUID` は任意列とし、calendar 実体は別表へ分けず参照値扱いに留める
+- `MaxUnits / CostPerUse` は数値列とする
+- `StandardRate / OvertimeRate` は `MS Project XML` と同じ文字列表現を前提にする
+- `Initials / Group` は任意の表示属性とする
+
+`resources.csv` の第1段 scope:
+
+- 含める: 識別子、表示名、group/initials、calendar 参照、基本 rate/cost 属性
+- 含めない: `Baseline`, `TimephasedData`, `ExtendedAttributes`, resource ごとの cost 実績詳細
+- `assignments.csv` が別にある前提で、task との紐付けは `resources.csv` に持たせない
+
+`assignments.csv` の最小仕様草案:
+
+- 目的は task と resource の関係を独立表現し、1 task に複数 resource が付くケースを正規化して扱うこと
+- 正本の識別子は `AssignmentID` とし、参照の正本は `TaskID / ResourceID` とする
+- `AssignmentID / TaskID / ResourceID` を必須列とする
+- `AssignmentID` は CSV 内で一意でなければならない
+- `TaskID` は `tasks.csv` の既存 `ID` を指さなければならない
+- `ResourceID` は `resources.csv` の既存 `ResourceID` を指さなければならない
+- `TaskID / ResourceID` の組が重複する assignment を許すかは未確定だが、第1段では重複非推奨とする
+- `Start / Finish` は任意列とし、assignment 固有の期間がある場合のみ保持する
+- `Units / PercentWorkComplete` は数値列とする
+- `Work` は `PT...` 形式の duration 文字列を前提にする
+
+`assignments.csv` の第1段 scope:
+
+- 含める: task-resource 参照、多重割当、assignment 単体の期間と work/units/進捗
+- 含めない: `Baseline`, `TimephasedData`, `ExtendedAttributes`, assignment ごとの cost 詳細
+- 第1段では `Milestone / Delay / WorkContour / OvertimeWork` などは未保持でもよい
+
+現時点の判断メモ:
+
+- 当面は `single CSV` を主系統として維持する
+- 理由は、いまの利用目的が「軽量な交換・編集」であり、1 枚の表で task 階層を扱える利点がまだ大きいからである
+- `tasks.csv / resources.csv / assignments.csv` は有力な次段候補だが、現時点では仕様草案までに留める
+- `single CSV` から複数 CSV へ切り替える判断条件は、少なくとも次のいずれかを満たしたときとする
+ - 同名 resource を安全に往復したい要求が具体化した
+ - 1 task に複数 resource を持つ assignment を lossless に扱いたい要求が増えた
+ - assignment 単体属性を `single CSV` の task 行へ押し込むのが不自然になった
+ - `ResourceID` 正本での連携が必要になった
+- 逆に、task 中心の軽量編集が主目的である間は `single CSV` の方が実用的とみなす
+
+現時点の実装メモ:
+
+- `ProjectModel -> CSV + ParentID` の出力を持つ
+- 現在の出力列は `ID / ParentID / WBS / Name / Start / Finish / PredecessorID / Resource / PercentComplete / PercentWorkComplete / Milestone / Summary / Critical / Type / Priority / Work / CalendarUID / ConstraintType / ConstraintDate / Deadline / Notes`
+- `PredecessorID` は複数値を `|` 区切りで補助出力する
+- `Resource` は assignment から task 単位で集約した resource 名を補助出力する
+- `CSV + ParentID -> ProjectModel` の最小逆変換を持つ
+- 最小逆変換では `ID / ParentID / Name` を必須とし、`WBS / Start / Finish / PredecessorID / Resource / PercentComplete / PercentWorkComplete / Milestone / Summary / Critical / Type / Priority / Work / CalendarUID / ConstraintType / ConstraintDate / Deadline / Notes` を可能な範囲で復元する
+- 最小逆変換では `PredecessorID / Resource` の複数値区切りとして `|` に加えて `,` `;` `、` を受け付け、trim と重複除去を行う
+- 最小逆変換では `ID` 重複、空 `Name`、自己参照 `ParentID`、欠落 `ParentID`、循環 `ParentID` を import error として扱う
+- UI には `CSV` のダウンロード導線と、CSV ファイル読込導線を追加済み
+- 現時点では `Project` 詳細、`Calendars`、`Baseline`、`TimephasedData`、assignment 詳細は CSV から完全復元しない
+
+## 次に決めること
+
+STEP 1 の次の検討項目:
+
+1. サンプル XML の置き場所
+2. 内部モデル型の確定
+3. XML パーサ / シリアライザの実装方針
+4. STEP 1 で実際に保持する必須フィールドの最終確定
+5. ラウンドトリップ比較用の正規化ルール
diff --git a/index-src.html b/index-src.html
new file mode 100644
index 0000000..3159da6
--- /dev/null
+++ b/index-src.html
@@ -0,0 +1,358 @@
+
+
+
+
+
+
+
+
+ mikuproject
+
+
+
+
+ Local Browser Tool
+ mikuproject
+ Updated: {{BUILD_DATE}}
+
+
+ What is this?
+
+ mikuproject は、MS Project XML を基軸に、変換・可視化・限定編集を行う single-file web app です。
+
+
+ MS Project XML を基軸にした変換・可視化・限定編集を重視しています。
+ 生成AI 連携を意識した projection の出力と草案の再取込を持ちます。
+ 人が読むための WBS Excel ブック (.xlsx) 帳票出力を持ちます。
+ Web ブラウザさえあればインストール不要・ネットワーク不要で利用できます。
+
+
+
+
+
+
+ Features
+
+
+
+ MS Project XML を読み込み、内部モデルへ変換します。
+ 内部モデルを JSON とサマリとして確認できます。
+ MS Project XML を再生成できます。
+ Mermaid gantt テキストと、native SVG の preview / SVG を出力できます。
+
+
+
+
+ Project / Tasks / Resources / Assignments / Calendars workbook を XLSX Export / Import できます。
+ 人が読むための WBS Excel ブック (.xlsx) を出力できます。
+ CSV + ParentID の生成と解析を行えます。
+ 生成AI向け JSON projection の出力と草案 JSON の取込ができます。
+
+
+
+
+
+
+
+
+ Use Cases
+
+ 管理用の Excel ブックに必要な情報を入力し、WBS Excel ブック (.xlsx) 形式へ変換したい。
+ 生成AI に専用プロンプトを与えて WBS 草案を作成し、その結果を取り込んで WBS Excel ブック (.xlsx) にしたい。
+ MS Project のデータを MS Project XML 形式でエクスポートし、それを WBS Excel ブック (.xlsx) 形式へ変換したい。
+
+
+
+
+
+
+ How to use
+
+ Web ブラウザで mikuproject.html を開きます。
+ Load from file から project ファイルを読み込むか、サンプルを使います。
+ 内部モデル、validation、native SVG preview を確認します。
+ 必要に応じて MS Project XML、XLSX、WBS Excel ブック (.xlsx)、Mermaid、CSV を保存します。
+
+
+
+
+
+
+
+ Screenshots
+
+
+
+
+
+
+ Input 画面。Load from file、サンプル、生成AI連携 から入力を受け付けます。
+
+
+
+
+
+
+
+ Overview 画面。内部モデル、validation、native SVG preview を確認します。
+
+
+
+
+
+
+
+ Output 画面。MS Project XML、XLSX、JSON、CSV、WBS XLSX、Mermaid、SVG を保存できます。
+
+
+
+
+
+
+
+ 人が読むための WBS Excel ブック (.xlsx) 帳票出力の例です。
+
+
+
+
+
+
+
+
+
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..905df39
--- /dev/null
+++ b/index.html
@@ -0,0 +1,358 @@
+
+
+
+
+
+
+
+
+ mikuproject
+
+
+
+
+ Local Browser Tool
+ mikuproject
+ Updated: 2026-03-30
+
+
+ What is this?
+
+ mikuproject は、MS Project XML を基軸に、変換・可視化・限定編集を行う single-file web app です。
+
+
+ MS Project XML を基軸にした変換・可視化・限定編集を重視しています。
+ 生成AI 連携を意識した projection の出力と草案の再取込を持ちます。
+ 人が読むための WBS Excel ブック (.xlsx) 帳票出力を持ちます。
+ Web ブラウザさえあればインストール不要・ネットワーク不要で利用できます。
+
+
+
+
+
+
+ Features
+
+
+
+ MS Project XML を読み込み、内部モデルへ変換します。
+ 内部モデルを JSON とサマリとして確認できます。
+ MS Project XML を再生成できます。
+ Mermaid gantt テキストと、native SVG の preview / SVG を出力できます。
+
+
+
+
+ Project / Tasks / Resources / Assignments / Calendars workbook を XLSX Export / Import できます。
+ 人が読むための WBS Excel ブック (.xlsx) を出力できます。
+ CSV + ParentID の生成と解析を行えます。
+ 生成AI向け JSON projection の出力と草案 JSON の取込ができます。
+
+
+
+
+
+
+
+
+ Use Cases
+
+ 管理用の Excel ブックに必要な情報を入力し、WBS Excel ブック (.xlsx) 形式へ変換したい。
+ 生成AI に専用プロンプトを与えて WBS 草案を作成し、その結果を取り込んで WBS Excel ブック (.xlsx) にしたい。
+ MS Project のデータを MS Project XML 形式でエクスポートし、それを WBS Excel ブック (.xlsx) 形式へ変換したい。
+
+
+
+
+
+
+ How to use
+
+ Web ブラウザで mikuproject.html を開きます。
+ Load from file から project ファイルを読み込むか、サンプルを使います。
+ 内部モデル、validation、native SVG preview を確認します。
+ 必要に応じて MS Project XML、XLSX、WBS Excel ブック (.xlsx)、Mermaid、CSV を保存します。
+
+
+
+
+
+
+
+ Screenshots
+
+
+
+
+
+
+ Input 画面。Load from file、サンプル、生成AI連携 から入力を受け付けます。
+
+
+
+
+
+
+
+ Overview 画面。内部モデル、validation、native SVG preview を確認します。
+
+
+
+
+
+
+
+ Output 画面。MS Project XML、XLSX、JSON、CSV、WBS XLSX、Mermaid、SVG を保存できます。
+
+
+
+
+
+
+
+ 人が読むための WBS Excel ブック (.xlsx) 帳票出力の例です。
+
+
+
+
+
+
+
+
+
diff --git a/lht-cmn/FEEDBACK.md b/lht-cmn/FEEDBACK.md
new file mode 100644
index 0000000..deb3c6c
--- /dev/null
+++ b/lht-cmn/FEEDBACK.md
@@ -0,0 +1,54 @@
+# lht-cmn Feedback
+
+## 2026-03-08 `lht-command-block` style contract gap
+
+- 症状:
+ - `lht-command-block` をページ側で利用しても、`lht-cmn/css/components.css` だけでは「角丸四角の結果表示ブロック」として視覚的に完成しない
+ - 実際には `md-code-block` / `md-code` / `md-copy-button` / `md-icon-button.md-copy-button` 相当の見た目をページ側 CSS が別途持っている前提になっている
+- 問題:
+ - `lht-command-block` を共通部品として使っても、利用ページごとに結果表示の見た目が欠けうる
+ - `lht-cmn` の self-contained 方針とずれている
+- 期待:
+ - `lht-command-block` は `lht-cmn/css/components.css` だけで最低限の完成した見た目になるべき
+ - 少なくとも以下の visual contract は `lht-cmn` 側に同梱する
+ - `.md-code-block`
+ - `.md-code`
+ - `.md-copy-button`
+ - `md-icon-button.md-copy-button`
+ - `md-icon-button.md-copy-button--surface`
+- 補足:
+ - 今回 `docs/prompt/prompt-gen-src.html` では、既存画面に合わせるためページ側へ上記スタイルを追加して回避した
+ - 根本対応は `lht-cmn` 側で行うべき
+
+## 2026-03-08 `lht-switch-help` material bundle gap
+
+- 症状:
+ - `lht-switch-help` を利用しても、ページ側で `md-switch` が未登録のため fallback 実装に落ちる
+ - `prompt-gen` では text field は Material 表示なのに switch だけ fallback 表示になる
+- 問題:
+ - `lht-*` を使っても、入力部品ごとに Material / fallback が混在しやすい
+ - ページ側から見ると `lht-switch-help` の見た目が他の Material 部品と揃わず、利用側で原因が見えにくい
+- 期待:
+ - `lht-switch-help` も `lht-cmn` 側で Material 実装を self-contained に利用できる形に寄せたい
+ - 少なくとも `md-switch` 用 bundle の vendor / 読み込み導線を `lht-cmn` 側で用意したい
+ - それが難しい場合でも、README に「switch は fallback 前提になりうる」ことを明記したい
+- 補足:
+ - 現状の `lht-switch-help` は `window.customElements.get("md-switch")` が false のとき fallback DOM を生成する実装になっている
+ - `lht-cmn/vendor` には `material-web-outlined-text-field.bundle.js` はあるが、`md-switch` 相当の bundle は見当たらない
+
+## 2026-03-09 `lht-text-field-help` trailing action gap
+
+- 症状:
+ - `prompt-gen` で「やりたいこと」入力欄に `×` クリアボタンを付けたかったが、`lht-text-field-help` 自体には trailing action / trailing icon を安全に差し込む契約がない
+ - 外付け absolute 配置では、Material 側の見た目中心と合いにくく、位置合わせが不安定だった
+- 問題:
+ - 画面ごとに似た「入力クリア」「末尾アイコン」「補助アクション」実装が再発しやすい
+ - `lht-text-field-help` を使っていても、入力欄内アクションはページ側の局所 CSS に依存しやすい
+ - fallback 実装と Material 実装で、末尾アクションの見た目や余白が揃いにくい
+- 期待:
+ - `lht-text-field-help` に trailing action slot、または `clearable` のような共通機能を検討したい
+ - もし汎用化しない場合でも、「入力欄右端に後付けアクションを重ねる時の推奨パターン」を README に明記したい
+- 補足:
+ - 今回は暫定対応として `lht-text-field-help` に `clearable` 属性をローカル追加し、`prompt-gen` ではそれを利用する形へ寄せた
+ - ただしこれは prompt-gen 都合で先に入れた provisional API なので、`lht-cmn` チーム側では trailing action 全般を扱える正式な契約として見直したい
+ - 正式方針が固まったら、今回の `clearable` 実装や CSS 調整はレビューのうえ置き換えたい
diff --git a/lht-cmn/LICENSE b/lht-cmn/LICENSE
new file mode 100644
index 0000000..4bb44d2
--- /dev/null
+++ b/lht-cmn/LICENSE
@@ -0,0 +1,197 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with the Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!)
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/lht-cmn/NOTICE b/lht-cmn/NOTICE
new file mode 100644
index 0000000..d1b7fb9
--- /dev/null
+++ b/lht-cmn/NOTICE
@@ -0,0 +1,11 @@
+Copyright 2026 Toshiki Iga
+
+This product includes software developed by Toshiki Iga.
+
+Design direction:
+- 本プロジェクトは Material Design 3 の設計原則を参照しています。
+
+Third-party component implementation:
+- `lht-cmn` の内部実装では、必要に応じて Material Web(`@material/web`)を優先利用します。
+- Material Web のライセンスは Apache License, Version 2.0 です。
+- Source: https://github.com/material-components/material-web
diff --git a/lht-cmn/README.md b/lht-cmn/README.md
new file mode 100644
index 0000000..e7dad3c
--- /dev/null
+++ b/lht-cmn/README.md
@@ -0,0 +1,408 @@
+# lht-cmn
+
+`lht-cmn` は `local-html-tools` 全体で共有する UI コンポーネント基盤です。
+
+- Version: `v20260308`
+- License: Apache License 2.0 (`lht-cmn/LICENSE`)
+- Copyright: Toshiki Iga
+
+## ライセンスと帰属
+
+- `lht-cmn` 自体は Apache License 2.0 で配布します。
+- デザイン方針は Material Design 3 の設計原則を参照します。
+- 実装技術として、`lht-cmn` は必要に応じて Material Web(`@material/web`)を優先利用します。
+- Material Web のライセンスは Apache License 2.0 です。
+- 帰属情報の詳細は `lht-cmn/NOTICE` に記載します。
+
+## 目的
+
+`local-html-tools` では、入力・選択・ヘルプ・コピー・メニューなどの UI を複数ページで繰り返し実装してきました。
+`lht-cmn` はこの重複を減らし、UI を `lht-*` Web Components として共通化するためのレイヤーです。
+
+## 基本方針
+
+- デザイン基準は Material Design 3
+- 画面側の公開 UI レイヤーは常に `lht-*` とし、`md-*` を直接使わせない
+- `lht-*` は self-contained を原則とし、アプリ側へ `md-*` の登録責務を漏らさない
+- 実現手段として Material Web を優先利用してよい
+- ただし Material 利用の有無に関わらず、`lht-*` は最低保証で壊れないことを優先する
+- 内部実装として許可する型は次の 2 つに限定する:
+ - `md-*` 優先 + fallback
+ - 完全自前実装
+- 「Material 依存で fallback なし」は原則避け、採る場合は README に明示する
+- fallback は Material の完全再現ではなく、公開 API の最低保証に留める
+
+## メリット
+
+- 画面ごとの重複実装を削減できる
+- 見た目と挙動(必須表示、ヘルプ表示、フォーカス時の挙動)を統一できる
+- 変更点を `lht-cmn` に集約でき、保守・レビューがしやすくなる
+- 単一HTML生成前提でも、開発時の部品再利用性を維持できる
+- 変更点が局所化され、生成AIが誤って別画面を壊す確率が下がる
+- UI規約が `lht-*` に集約され、提案が毎回同じ型で出せる
+- レビュー時に「画面の見た目差分」より「共通部品の差分」を見ればよくなり、判断が速くなる
+
+## 運用方針(重要)
+
+- 画面側(`docs/*-src.html`)は `lht-*` を利用し、`md-*` 直接実装の追加は原則避ける
+- `lht-cmn/js/components.js` を共通コンポーネントの正本とする
+- `lht-cmn/css/components.css` を実運用スタイルの正本とする
+- `lht-cmn/` 配下(特に `js/components.js` / `css/components.css`)の変更は、必ずユーザーの明示許可を得てから実施する
+- `md3/` は段階的にリファレンス用途へ縮退し、実運用スタイルは `lht-cmn` に集約する
+
+## 構成
+
+- `lht-cmn/js/components.js`
+ - 共通 Web Components 定義
+- `lht-cmn/css/components.css`
+ - 上記コンポーネントの共通スタイル
+- `lht-cmn/catalog/index.html`
+ - 実表示と HTML 利用例を並べて確認するコンポーネントカタログ
+
+### コンポーネント一覧
+
+| コンポーネント | できること | 内部構造(概要) |
+|---|---|---|
+| `lht-help-tooltip` | `(i)` ヘルプアイコンとツールチップを1タグで配置できる | `md-icon-button` が使える環境ではそれを利用し、未定義時はネイティブ `button` fallback を生成。タグ内HTMLをツールチップ本文へ差し込む |
+| `lht-text-field-help` | ラベル付き入力(単一行/複数行)とフォーカス時ヘルプ表示を共通化できる | `md-outlined-text-field` が使える環境ではそれを利用し、未定義時はネイティブ `input` / `textarea` fallback を生成。属性(`field-id`/`label`/`type`/`rows` など)を透過する |
+| `lht-select-help` | ラベル付きドロップダウンとヘルプ表示を共通化できる | 内部で `md-outlined-select` を生成し、`` を使用する
+3. `lht-select-help` で `` 子要素は使用しない(後方互換運用は終了)
+4. 既存JS互換のため、DOM参照ID(`document.getElementById(...)`)は変更しない
+
+## カード共通化(`lht-index-card-link`)
+
+トップ `index` のリンクカードは、基本的に `lht-index-card-link` で共通化します。
+
+- 目的:
+ - カードDOM(`a + title + desc + arrow`)の型を固定する
+ - 見た目と挙動をコンポーネント側へ集約する
+
+### 主な属性
+
+- `href`(必須): 遷移先
+- `title`(必須): タイトル
+- `desc`(必須): 説明文
+- `icon`(任意): タイトル先頭に出すアイコン文字(例: `🧰`)
+- `variant`: `default | simple | external`
+- `arrow`: `auto | none`
+- `target` / `rel`: 必要時に指定(`external` / 外部URL / `_blank` は自動補完あり)
+- `badge`: バッジ文字列
+- `desc-lines`: 説明文の行数クランプ(数値)
+
+### 使用例
+
+```html
+
+
+```
+
+## LHT リファレンス
+
+### `lht-help-tooltip`
+
+- 用途: `(i)` ヘルプ表示
+- 主な属性: `label`, `wide`, `placement`
+- fallback:
+ - `md-icon-button` 未読込時はネイティブ `button.md-help-icon-button--fallback` を内部生成する
+ - hover / focus-within による tooltip 表示契約は Material / fallback の両方で共通
+ - anchor 用の最小 CSS (`position`, `overflow`, tooltip visibility) は `lht-help-tooltip` 側に同梱し、アプリ側の追加 tooltip CSS を前提にしない
+- placement:
+ - `placement="auto|left|right|top|bottom"` を指定できる
+ - 既定値は `auto`
+ - `auto` は active 時に viewport overflow が最小になる向きを選び、必要に応じて位置を clamp する
+
+### `lht-text-field-help`
+
+- 用途: テキスト/数値/複数行入力 + フォーカス時ヘルプ
+- 主な属性: `field-id`, `label`, `help-text`, `hide-delay-ms`, `type`, `placeholder`, `value`, `rows`, `min`, `max`, `step`, `required`, `disabled`, `field-class`
+- fallback:
+ - `md-outlined-text-field` 未読込時はネイティブ `input` / `textarea` を内部生成する
+ - `rows` 指定時は `textarea` fallback を優先する
+ - fallback 時の `help-text` は field 下部の supporting text として表示し、`focus` で表示・`blur` 後 `hide-delay-ms` で非表示にする
+ - fallback 時も `title` 属性は補助的に維持する
+
+### `lht-select-help`
+
+- 用途: セレクト入力 + フォーカス時ヘルプ
+- 主な属性: `field-id`, `label`, `help-text`, `hide-delay-ms`, `value`, `required`, `disabled`, `field-class`
+- fallback:
+ - `md-outlined-select` 未読込時はネイティブ `select` を内部生成する
+ - fallback 時の `help-text` は field 下部の supporting text として表示し、`focus` で表示・`blur` 後 `hide-delay-ms` で非表示にする
+ - fallback 時も `title` 属性は補助的に維持する
+- 選択肢定義: ``
+- 補助メソッド:
+ - `setOptions([{ value, label, selected?, disabled? }], { preserveValue? })`
+ - `getValue()`
+ - `setValue(value)`
+- lifecycle メモ:
+ - declarative options の有無は初期化開始時点で判定する
+ - `script[slot="options"]` を利用した場合、初期化時に JSON を読んで内部 option へ反映したあと script ノードは消費される
+ - declarative options 利用時は child `option` 監視による再 hydration を行わない
+- dynamic options:
+ - 動的更新は `setOptions([...])` を正規ルートとする
+ - 既定では `preserveValue: true` と同等に動作し、同じ `value` が次の options に残っていれば選択を維持する
+ - 既存選択値が次の options に存在しない場合は空に戻す
+ - 動的更新と declarative JSON / 手動 child mutation の混在は避ける
+
+### `lht-switch-help`
+
+- 用途: スイッチ + ラベル + ヘルプ
+- 主な属性: `switch-id`, `label`, `help-label`, `help-wide`, `checked`, `on-change`
+- fallback:
+ - `md-switch` 未読込時は `input.md-switch-input + span.md-switch` を内部生成する
+ - `switch-id` は fallback 時も checkbox input に付与される
+ - `checked` 状態と `change` イベントは Material / fallback の両方で利用できる
+
+### `lht-command-block`
+
+- 用途: コマンド表示 + コピーUI
+- 主な属性: `command-id`, `copy-buttons`(`single` / `dual`)
+- fallback:
+ - `md-icon-button` 未読込時はネイティブ `button.md-copy-button--fallback` を内部生成する
+ - コピー動作は Material / fallback の両方で共通
+
+### `lht-page-menu`
+
+- 用途: 右上メニュー(戻るリンク等)
+- 主な属性: `home-href`, `home-label`
+
+### `lht-page-hero`
+
+- 用途: ページ先頭の見出しブロック(タイトル + 補助説明 + ヘルプ + メニュー)
+- 主な属性: `title`, `subtitle`, `icon`, `help-label`, `help-wide`, `menu-home-href`, `menu-home-label`, `no-menu`
+- 本文スロット: ヘルプポップアップに表示する説明HTML
+
+### `lht-index-card-link`
+
+- 用途: `docs/index` 用カードリンク
+- 主な属性: `href`, `title`, `desc`, `icon`, `variant`, `arrow`, `target`, `rel`, `badge`, `desc-lines`
+
+### `lht-file-select`
+
+- 用途: ファイル選択UI(ボタン + hidden file input + ファイル名表示)
+- 主な属性: `input-id`, `button-id`, `file-name-id`, `accept`, `button-label`, `placeholder`, `file-label`, `multiple`, `disabled`, `show-file-name`, `auto-open`
+- 公開イベント:
+ - `lht-file-select:before-open`
+ - button click 時に発火する cancellable event
+ - `auto-open="false"` のときは発火のみ行い、内部 `input.click()` は実行しない
+ - `auto-open` が既定 `true` の場合でも `preventDefault()` で内部 open を抑止できる
+ - `lht-file-select:change`
+ - hidden file input の `change` 後に発火する
+ - `detail.names` と `detail.files` で選択結果を参照できる
+
+### `lht-loading-overlay`
+
+- 用途: ファイル読み込みなどの非同期処理中オーバーレイ(indeterminate loading)
+- 主な属性: `active`, `text`, `busy-target-id`, `disable-target-ids`
+- 補助メソッド: `setActive(boolean)`, `isActive()`, `waitForNextPaint()`
+- ARIAルール:
+ - 常時 `role="status"` と `aria-live="polite"` を持つ
+ - `active` に応じて `aria-hidden` を `false/true` へ同期する
+ - `busy-target-id` 指定時は対象へ `aria-busy` を `true/false` で同期する
+- 推奨フロー:
+ 1. `overlay.setActive(true)` で開始
+ 2. `await overlay.waitForNextPaint()` で先に描画を確定
+ 3. 重い処理を実行
+ 4. `finally` で `overlay.setActive(false)` を必ず実行
+
+### `lht-toast`
+
+- 用途: コピー完了などの短時間通知(toast/snackbar)
+- 主な属性: `active`, `text`, `duration-ms`
+- 補助メソッド: `show(message?, durationMs?)`, `hide()`
+- ARIAルール:
+ - 常時 `role="status"` と `aria-live="polite"` を持つ
+ - 常時 `aria-atomic="true"` を持つ
+- 運用メモ:
+ - ページ側に ` ` を1つ配置して使う
+ - 既存コードが `window.showToast(...)` を呼ぶ場合、未定義時は `lht-toast` 側が自動補完する
+
+### `lht-error-alert`
+
+- 用途: 画面内エラー/警告/情報表示の共通化(`errorText` パターンの置換)
+- 主な属性: `text`, `active`, `variant`
+- 補助メソッド: `show(message?)`, `hide()`, `clear()`, `isVisible()`
+- ARIAルール:
+ - `variant="error"` は `role="alert"` と `aria-live="assertive"`
+ - `variant="warning|info"` は `role="status"` と `aria-live="polite"`
+ - 常時 `aria-atomic="true"` を持つ
+ - 表示状態に応じて `aria-hidden` を同期する
+
+### `lht-input-mode-toggle`
+
+- 用途: `file/source` 入力切替ラジオUIの共通化(music系の重複置換)
+- 主な属性: `name`, `group-label`, `file-id`, `source-id`, `file-label`, `source-label`, `default-mode`, `source-target-id`, `file-target-id`, `on-change`, `disabled`
+- 補助メソッド: `getMode()`, `setMode(mode)`, `applyModeUi()`
+- 互換メモ:
+ - 既定の `file-id` / `source-id` は `inputModeFile` / `inputModeSource`
+ - 既存JSが `document.getElementById("inputModeFile")` 等を参照していても置換しやすい
+
+### `lht-preview-output`
+
+- 用途: プレビュー表示とコピー導線の共通化(`preview + copyBtn` パターンの置換)
+- 主な属性: `preview-id`, `copy-button-id`, `copy-target-id`, `placeholder`, `copy-label`, `copy-aria-label`, `preview-tag`, `no-copy`
+- 補助メソッド: `getText()`, `setText(text)`, `copy(targetId?)`, `clear()`
+- 運用メモ:
+ - 既定の `preview-id` / `copy-button-id` は `previewText` / `copyBtn`
+ - `copy-target-id` を指定すると、プレビュー枠とは別要素のテキストをコピーできる
+
+## Appendix
+
+### Appendix A: Material Web 置換の実施手順(実装メモ)
+
+`*-src.html` を `lht-*` 前提へ寄せるときの、実務上の手順メモです。
+
+1. 置換対象を `*-src.html` 上で特定する
+2. 既存の生HTML部品を `lht-*`(内部的には Material Web または自前実装)へ置換する
+3. 状態取得/保存ロジックを `selected` / `value` ベースへ揃える
+4. 見た目差分(角丸、高さ、フォーカスリング、余白)を CSS トークンで吸収する
+5. 単一HTMLビルドを実行して動作確認する
+
+### Appendix B: 置換対応表(内部実装の目安)
+
+- テキスト入力: `md-outlined-text-field`
+- テキストエリア: `md-outlined-text-field type="textarea"`
+- セレクト: `md-outlined-select` + `md-select-option`
+- トグル: `md-switch`
+- アイコンボタン: `md-icon-button`
+- ヘルプ `(i)`: `lht-help-tooltip`
+- フィールド活性時ヘルプ表示: `lht-text-field-help`
+- スイッチ + ヘルプ: `lht-switch-help`
+- コマンド表示 + コピー: `lht-command-block`
+- 右上メニュー: `lht-page-menu`
+
+### Appendix C: テーマ色運用メモ
+
+- フォーカス、選択、強調は `primary` 系(`--md-sys-color-primary`)を基準にする
+- `secondary` は `primary` と競合しない範囲で使う。迷ったら `primary` に寄せる
+- フォーカスリング色はコンポーネント間で統一する
+- Material Web の色変更は、まず `:root` の `--md-sys-*` を調整し、個別上書きは最小限にする
+
+### Appendix D: tooltip 実装制約メモ
+
+- `@material/web@2.4.1` では `md-tooltip` が同梱されないため、`lht-help-tooltip` は `md-tooltip-group` + `md-tooltip-content` ベースで運用する
+
+### Appendix E: ドロップダウンでよくあるミスと回避方法
+
+`lht-select-help` は `md-outlined-select` を内部利用するため、単一HTML化や依存読込順の影響を受けやすいです。
+以下のミスが、ドロップダウン崩れ(選択肢がただのテキストになる等)を起こしやすいです。
+
+1. `md-outlined-select` が未定義のまま初期化される
+- 症状:
+ - 選択UIが表示されず、選択肢テキストだけが並ぶ
+- 回避:
+ - 標準配置の Material Web バンドル(`lht-cmn/vendor/material-web-outlined-text-field.bundle.js`)を `lht-cmn/js/components.js` より前に配置する
+ - `lht-cmn` 側のフォールバック(ネイティブ `select`)が効く実装を維持する
+
+2. `lht-select-help` の選択肢定義が不正
+- 症状:
+ - 選択肢が空になる / 既定値が反映されない
+- 回避:
+ - `` の JSON を必ず配列で定義する
+ - `value` と `label` を明示する
+ - 既定値は `selected: true` と `value` の整合を取る
+
+3. `field-id` を変えて既存JS参照が壊れる
+- 症状:
+ - `document.getElementById(...)` が `null` になり、初期化やイベント登録で失敗する
+- 回避:
+ - 置換時も DOM 参照ID(`field-id`)は既存IDを維持する
+
+4. 単一HTML化でインラインスクリプトが壊れる
+- 症状:
+ - `Unexpected end of input`
+ - バンドル内文字列が壊れ、`popover` などの警告が連鎖する
+- 回避:
+ - ビルド時に `` を `<\\/script>` へエスケープする
+ - 文字列置換でJSを差し込む場合は `replace` の関数置換を使い、`$` 展開事故を避ける
+
+5. CSSの責務が混在して見た目が崩れる
+- 症状:
+ - ドロップダウンの幅・余白・フォーカス装飾がページごとに不揃い
+- 回避:
+ - 基本スタイルは `lht-cmn/css/components.css` に集約する
+ - 画面側CSSはレイアウト差分(余白・配置)に限定する
diff --git a/lht-cmn/catalog/README.md b/lht-cmn/catalog/README.md
new file mode 100644
index 0000000..9c1076e
--- /dev/null
+++ b/lht-cmn/catalog/README.md
@@ -0,0 +1,16 @@
+# lht-cmn catalog
+
+`index.html` is a catalog for checking `lht-cmn` Web Components with live previews and HTML code examples side by side.
+
+- `index.html`
+ - Main catalog page
+- `catalog.css`
+ - Catalog-specific layout and presentation
+- `catalog.js`
+ - Demo initialization and code-display helpers
+
+Assets loaded by the catalog:
+
+- `../css/components.css`
+- `../js/components.js`
+- `../vendor/material-web-outlined-text-field.bundle.js`
diff --git a/lht-cmn/catalog/catalog.css b/lht-cmn/catalog/catalog.css
new file mode 100644
index 0000000..a969f8e
--- /dev/null
+++ b/lht-cmn/catalog/catalog.css
@@ -0,0 +1,346 @@
+:root {
+ color-scheme: light;
+ --catalog-bg: #f5f1e8;
+ --catalog-surface: rgba(255, 255, 255, 0.84);
+ --catalog-surface-strong: rgba(255, 255, 255, 0.94);
+ --catalog-line: rgba(73, 58, 42, 0.14);
+ --catalog-ink: #2a231d;
+ --catalog-muted: #66584b;
+ --catalog-accent: #0f766e;
+ --catalog-accent-soft: rgba(15, 118, 110, 0.12);
+ --catalog-shadow: 0 24px 70px rgba(65, 49, 35, 0.12);
+ --md-sys-color-primary: #0f766e;
+ --md-sys-color-secondary: #7c5c2f;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ color: var(--catalog-ink);
+ background:
+ radial-gradient(circle at top left, rgba(234, 179, 8, 0.18), transparent 34%),
+ radial-gradient(circle at top right, rgba(15, 118, 110, 0.16), transparent 30%),
+ linear-gradient(180deg, #efe8da 0%, var(--catalog-bg) 55%, #ece5d6 100%);
+ font-family: "Avenir Next", "Hiragino Sans", "Yu Gothic", sans-serif;
+}
+
+code,
+pre {
+ font-family: "SFMono-Regular", "Cascadia Code", "Source Code Pro", monospace;
+}
+
+.catalog-icons {
+ position: absolute;
+}
+
+.catalog-shell {
+ display: grid;
+ grid-template-columns: 288px minmax(0, 1fr);
+ gap: 32px;
+ width: min(1480px, calc(100% - 40px));
+ margin: 0 auto;
+ padding: 24px 0 64px;
+}
+
+.catalog-sidebar {
+ position: relative;
+}
+
+.catalog-sidebar__inner {
+ position: sticky;
+ top: 24px;
+ padding: 26px 22px;
+ border: 1px solid var(--catalog-line);
+ border-radius: 28px;
+ background: var(--catalog-surface);
+ backdrop-filter: blur(14px);
+ box-shadow: var(--catalog-shadow);
+}
+
+.catalog-sidebar__eyebrow,
+.catalog-kicker,
+.catalog-section__tag {
+ margin: 0 0 8px;
+ color: var(--catalog-accent);
+ font-size: 0.76rem;
+ font-weight: 800;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+.catalog-sidebar__title {
+ margin: 0;
+ font-size: 2rem;
+ line-height: 1.05;
+}
+
+.catalog-sidebar__lead {
+ margin: 16px 0 22px;
+ color: var(--catalog-muted);
+ line-height: 1.7;
+}
+
+.catalog-nav {
+ display: grid;
+ gap: 8px;
+}
+
+.catalog-nav a {
+ display: block;
+ padding: 10px 12px;
+ border-radius: 14px;
+ color: inherit;
+ text-decoration: none;
+ transition: background-color 160ms ease, transform 160ms ease;
+}
+
+.catalog-nav a:hover,
+.catalog-nav a:focus-visible {
+ background: var(--catalog-accent-soft);
+ outline: none;
+ transform: translateX(4px);
+}
+
+.catalog-main {
+ display: grid;
+ gap: 28px;
+}
+
+.catalog-intro,
+.catalog-section {
+ padding: 28px;
+ border: 1px solid var(--catalog-line);
+ border-radius: 30px;
+ background: var(--catalog-surface);
+ backdrop-filter: blur(12px);
+ box-shadow: var(--catalog-shadow);
+}
+
+.catalog-intro h2,
+.catalog-section h2 {
+ margin: 0;
+ font-size: clamp(1.5rem, 2vw, 2.2rem);
+}
+
+.catalog-intro p,
+.catalog-section__header p {
+ color: var(--catalog-muted);
+ line-height: 1.75;
+}
+
+.catalog-section__header {
+ display: flex;
+ justify-content: space-between;
+ gap: 24px;
+ align-items: end;
+ margin-bottom: 20px;
+}
+
+.catalog-section__header > * {
+ margin: 0;
+ max-width: 760px;
+}
+
+.catalog-meta {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 14px;
+ margin-top: 22px;
+}
+
+.catalog-meta > div {
+ padding: 16px 18px;
+ border: 1px solid var(--catalog-line);
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.65);
+}
+
+.catalog-meta strong,
+.catalog-meta span {
+ display: block;
+}
+
+.catalog-meta strong {
+ margin-bottom: 6px;
+}
+
+.catalog-card {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 20px;
+ align-items: start;
+}
+
+.catalog-preview,
+.catalog-code,
+.catalog-eventlog {
+ min-width: 0;
+ margin: 0;
+ border: 1px solid var(--catalog-line);
+ border-radius: 22px;
+ background: var(--catalog-surface-strong);
+}
+
+.catalog-preview {
+ position: relative;
+ padding: 22px;
+ overflow: visible;
+}
+
+.catalog-code,
+.catalog-eventlog {
+ padding: 18px 20px;
+ overflow: auto;
+ line-height: 1.6;
+ white-space: pre-wrap;
+}
+
+.catalog-code code {
+ display: block;
+ font-size: 0.9rem;
+}
+
+.catalog-eventlog {
+ margin-top: 16px;
+ color: var(--catalog-muted);
+}
+
+.catalog-note {
+ margin: 14px 0 0;
+ color: var(--catalog-muted);
+ font-size: 0.95rem;
+}
+
+.catalog-stack {
+ display: grid;
+ gap: 18px;
+}
+
+.catalog-inline-row,
+.catalog-action-row {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.catalog-inline-label {
+ font-weight: 700;
+}
+
+.catalog-button {
+ appearance: none;
+ border: 0;
+ border-radius: 999px;
+ background: var(--catalog-accent);
+ color: #fff;
+ padding: 11px 16px;
+ font: inherit;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.catalog-button--ghost {
+ background: rgba(42, 35, 29, 0.08);
+ color: var(--catalog-ink);
+}
+
+.catalog-button:hover,
+.catalog-button:focus-visible {
+ filter: brightness(1.04);
+ outline: none;
+}
+
+.catalog-hero-surface,
+.catalog-menu-surface,
+.catalog-busy-surface,
+.catalog-panel {
+ position: relative;
+ border: 1px solid var(--catalog-line);
+ border-radius: 22px;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(255, 255, 255, 0.7));
+}
+
+.catalog-hero-surface,
+.catalog-busy-surface,
+.catalog-panel {
+ padding: 18px;
+}
+
+.catalog-menu-surface {
+ min-height: 120px;
+ padding: 18px;
+}
+
+.catalog-card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 18px;
+}
+
+.catalog-busy-surface > p {
+ margin: 12px 0 0;
+ color: var(--catalog-muted);
+}
+
+#catalogLoadingOverlay {
+ border-radius: 22px;
+}
+
+#catalogToast {
+ justify-self: start;
+}
+
+#catalogPreviewText {
+ min-height: 6.4em;
+}
+
+#catalogCommand {
+ display: block;
+ padding-right: 46px;
+ white-space: pre-wrap;
+}
+
+.catalog-section :where(lht-page-menu) {
+ margin-left: auto;
+}
+
+@media (max-width: 1100px) {
+ .catalog-shell {
+ grid-template-columns: 1fr;
+ width: min(1120px, calc(100% - 28px));
+ }
+
+ .catalog-sidebar__inner {
+ position: static;
+ }
+
+ .catalog-nav {
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ }
+}
+
+@media (max-width: 860px) {
+ .catalog-section__header {
+ display: grid;
+ gap: 10px;
+ }
+
+ .catalog-meta {
+ grid-template-columns: 1fr;
+ }
+
+ .catalog-intro,
+ .catalog-section {
+ padding: 22px;
+ border-radius: 24px;
+ }
+}
diff --git a/lht-cmn/catalog/catalog.js b/lht-cmn/catalog/catalog.js
new file mode 100644
index 0000000..e529beb
--- /dev/null
+++ b/lht-cmn/catalog/catalog.js
@@ -0,0 +1,150 @@
+const demoTemplates = {
+ "hero-basic": {
+ afterRender(preview) {
+ const menu = preview.querySelector("lht-page-menu");
+ if (menu) {
+ const menuPanel = menu.querySelector(".md-menu-panel");
+ if (menuPanel) menuPanel.classList.add("md-hidden");
+ }
+ }
+ },
+ "switch-basic": {
+ afterRender() {
+ window.catalogHandleSwitchChange = () => {
+ const input = document.getElementById("catalogDemoSwitch");
+ const status = document.getElementById("switch-status");
+ if (!input || !status) return;
+ status.textContent = `Switch status: ${input.checked ? "on" : "off"}`;
+ };
+ window.catalogHandleSwitchChange();
+ }
+ },
+ "command-basic": {
+ afterRender(preview) {
+ const command = preview.querySelector("#catalogCommand");
+ if (command) {
+ command.textContent = "git diff --stat origin/main...feature/catalog-page";
+ }
+ }
+ },
+ "file-select-basic": {
+ afterRender(preview) {
+ const element = preview.querySelector("lht-file-select");
+ const log = document.getElementById("file-select-log");
+ if (!element || !log) return;
+
+ element.addEventListener("lht-file-select:before-open", (event) => {
+ log.textContent = `before-open: autoOpen=${String(event.detail.autoOpen)}`;
+ });
+ element.addEventListener("lht-file-select:change", (event) => {
+ const names = Array.isArray(event.detail.names) ? event.detail.names.join(", ") : "";
+ log.textContent = names ? `change: ${names}` : "change: no file names";
+ });
+ }
+ },
+ "preview-basic": {
+ afterRender(preview) {
+ const output = preview.querySelector("#catalogPreviewOutput");
+ if (!output) return;
+
+ preview.querySelector('[data-action="preview-fill"]')?.addEventListener("click", () => {
+ output.setText(" can update text via setText().");
+ });
+ preview.querySelector('[data-action="preview-clear"]')?.addEventListener("click", () => {
+ output.clear();
+ });
+ }
+ },
+ "input-mode-basic": {
+ afterRender() {
+ window.catalogHandleModeChange = (mode) => {
+ const status = document.getElementById("input-mode-status");
+ if (!status) return;
+ status.textContent = `Current mode: ${mode}`;
+ };
+ window.catalogHandleModeChange("file");
+ }
+ },
+ "loading-basic": {
+ afterRender(preview) {
+ const overlay = preview.querySelector("#catalogLoadingOverlay");
+ const trigger = preview.querySelector('[data-action="loading-run"]');
+ if (!overlay || !trigger) return;
+
+ trigger.addEventListener("click", async () => {
+ overlay.setActive(true);
+ await overlay.waitForNextPaint();
+ await new Promise((resolve) => window.setTimeout(resolve, 900));
+ overlay.setActive(false);
+ });
+ }
+ },
+ "toast-basic": {
+ afterRender(preview) {
+ const toast = preview.querySelector("#catalogToast");
+ const trigger = preview.querySelector('[data-action="toast-show"]');
+ if (!toast || !trigger) return;
+ trigger.addEventListener("click", () => {
+ toast.show("Catalog toast fired.", 1600);
+ });
+ }
+ },
+ "alert-basic": {
+ afterRender(preview) {
+ const alert = preview.querySelector("#catalogAlert");
+ if (!alert) return;
+
+ preview.querySelector('[data-action="alert-error"]')?.addEventListener("click", () => {
+ alert.setAttribute("variant", "error");
+ alert.show("Error: invalid input.");
+ });
+ preview.querySelector('[data-action="alert-warning"]')?.addEventListener("click", () => {
+ alert.setAttribute("variant", "warning");
+ alert.show("Warning: review your options.");
+ });
+ preview.querySelector('[data-action="alert-info"]')?.addEventListener("click", () => {
+ alert.setAttribute("variant", "info");
+ alert.show("Info: generation completed.");
+ });
+ preview.querySelector('[data-action="alert-clear"]')?.addEventListener("click", () => {
+ alert.clear();
+ });
+ }
+ }
+};
+
+function normalizeIndent(text) {
+ const lines = text.replace(/^\n+|\n+$/g, "").split("\n");
+ const indents = lines
+ .filter((line) => line.trim().length > 0)
+ .map((line) => line.match(/^\s*/)?.[0].length ?? 0);
+ const minIndent = indents.length ? Math.min(...indents) : 0;
+ return lines.map((line) => line.slice(minIndent)).join("\n");
+}
+
+function renderCatalogCard(card) {
+ const key = card.dataset.demo;
+ const template = document.querySelector(`[data-demo-template="${key}"]`);
+ const preview = card.querySelector(".catalog-preview");
+ const code = card.querySelector(".catalog-code code");
+ if (!template || !preview || !code) return;
+
+ const fragment = template.content.cloneNode(true);
+ const source = normalizeIndent(template.innerHTML);
+
+ preview.appendChild(fragment);
+ code.textContent = source;
+
+ const controller = demoTemplates[key];
+ if (controller?.afterRender) {
+ controller.afterRender(preview);
+ }
+}
+
+function bootstrapCatalog() {
+ document.querySelectorAll(".catalog-card").forEach((card) => {
+ renderCatalogCard(card);
+ });
+}
+
+window.addEventListener("DOMContentLoaded", bootstrapCatalog);
diff --git a/lht-cmn/catalog/index.html b/lht-cmn/catalog/index.html
new file mode 100644
index 0000000..4c20fab
--- /dev/null
+++ b/lht-cmn/catalog/index.html
@@ -0,0 +1,462 @@
+
+
+
+
+
+ lht-cmn Component Catalog
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Reference Page
+ Live previews and copy-ready examples in one place
+
+ This page is not a build artifact. It loads `lht-cmn` as regular multi-file assets so you can inspect each component directly.
+ Every section pairs a live preview with the corresponding code.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Switch status: on
+
+
+
+
+
+
+
+ No file selected.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This hero bundles a title, supporting text, a help tooltip, and the top-right menu.
+
+
+
+
+
+
+
Label with help
+
+ This tooltip uses placement="right" together with wide.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Use this toggle when you want to reveal additional inputs or advanced options.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Set text
+ Clear
+
+
+
+
+
+
+
+
+
+
+
+
File panel
+
Place your file selection UI here.
+
+
+
Source panel
+
Place your source input area here.
+
+
Current mode: file
+
+
+
+
+
+
+ Run async demo
+ Secondary action
+
+
Show the overlay before starting heavy work, then close it after the operation completes.
+
+
+
+
+
+
+
+
+
+
+
+
+ Error
+ Warning
+ Info
+ Clear
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lht-cmn/components.test.js b/lht-cmn/components.test.js
new file mode 100644
index 0000000..dddf9e9
--- /dev/null
+++ b/lht-cmn/components.test.js
@@ -0,0 +1,630 @@
+// @vitest-environment jsdom
+
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+import { describe, expect, it, vi } from "vitest";
+
+import "./js/components.js";
+
+const componentsCss = readFileSync(resolve(process.cwd(), "lht-cmn/css/components.css"), "utf8");
+
+function waitForMicrotask() {
+ return new Promise((resolve) => queueMicrotask(resolve));
+}
+
+function defineMaterialTestDoubles() {
+ const definitions = {
+ "md-icon-button": class extends HTMLElement {},
+ "md-filled-button": class extends HTMLElement {},
+ "md-switch": class extends HTMLElement {},
+ "md-outlined-text-field": class extends HTMLElement {},
+ "md-outlined-select": class extends HTMLElement {},
+ "md-select-option": class extends HTMLElement {}
+ };
+
+ for (const [tagName, ctor] of Object.entries(definitions)) {
+ if (!customElements.get(tagName)) {
+ customElements.define(tagName, ctor);
+ }
+ }
+}
+
+function renderCriticalComponentsMarkup() {
+ document.body.innerHTML = `
+ body
+
+
+
+ help
+
+ `;
+}
+
+function mountTooltipStyles() {
+ document.head.innerHTML = ``;
+}
+
+describe("lht-select-help declarative options", () => {
+ it("does not enable child observer after consuming declarative JSON script", async () => {
+ document.body.innerHTML = `
+
+
+
+ `;
+
+ const element = document.querySelector("lht-select-help");
+ const field = element.querySelector("select");
+
+ expect(field).not.toBeNull();
+ expect(field.options).toHaveLength(1);
+ expect(field.options[0].value).toBe("a");
+ expect(field.options[0].textContent).toBe("Alpha");
+ expect(element.querySelector("script[slot='options']")).toBeNull();
+ expect(element._optionsObserver ?? null).toBeNull();
+
+ const lateOption = document.createElement("option");
+ lateOption.value = "b";
+ lateOption.textContent = "Beta";
+ element.appendChild(lateOption);
+ await waitForMicrotask();
+
+ expect(field.options).toHaveLength(1);
+ expect(field.options[0].value).toBe("a");
+ });
+
+ it("supports setOptions and preserves selected value by default", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const element = document.querySelector("lht-select-help");
+ const field = element.querySelector("select");
+
+ element.setOptions([
+ { value: "a", label: "Alpha" },
+ { value: "b", label: "Beta", selected: true }
+ ]);
+ expect(field.value).toBe("b");
+
+ element.setOptions([
+ { value: "b", label: "Beta 2" },
+ { value: "c", label: "Gamma" }
+ ]);
+
+ expect(field.value).toBe("b");
+ expect(field.options).toHaveLength(2);
+ expect(field.options[0].textContent).toBe("Beta 2");
+ });
+
+ it("clears selected value when preserveValue is disabled or missing from next options", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const element = document.querySelector("lht-select-help");
+ const field = element.querySelector("select");
+
+ element.setOptions([
+ { value: "a", label: "Alpha", selected: true },
+ { value: "b", label: "Beta" }
+ ]);
+ expect(field.value).toBe("a");
+
+ element.setOptions([
+ { value: "c", label: "Gamma" }
+ ], { preserveValue: false });
+ expect(field.value).toBe("c");
+
+ element.setValue("c");
+ expect(element.getValue()).toBe("c");
+
+ element.setOptions([
+ { value: "x", label: "Ex" }
+ ]);
+ expect(field.value).toBe("");
+ });
+
+ it("renders fallback supporting text and keeps title for native select", () => {
+ document.body.innerHTML = `
+
+
+
+ `;
+
+ const field = document.querySelector("lht-select-help select");
+ const supportingText = document.querySelector("lht-select-help .lht-select-help__supporting-text");
+
+ expect(field).not.toBeNull();
+ expect(field.title).toBe("Select one option");
+ expect(field.getAttribute("aria-label")).toBe("Choice");
+ expect(supportingText).not.toBeNull();
+ expect(supportingText.textContent).toBe("Select one option");
+ expect(supportingText.hidden).toBe(true);
+ });
+
+ it("shows fallback select help text on focus and hides it after blur delay", () => {
+ vi.useFakeTimers();
+ document.body.innerHTML = `
+
+
+
+ `;
+
+ const field = document.querySelector("lht-select-help select");
+ const supportingText = document.querySelector("lht-select-help .lht-select-help__supporting-text");
+
+ field.dispatchEvent(new Event("focus"));
+ expect(supportingText.hidden).toBe(false);
+ expect(supportingText.getAttribute("aria-hidden")).toBe("false");
+
+ field.dispatchEvent(new Event("blur"));
+ vi.advanceTimersByTime(219);
+ expect(supportingText.hidden).toBe(false);
+
+ vi.advanceTimersByTime(1);
+ expect(supportingText.hidden).toBe(true);
+ expect(supportingText.getAttribute("aria-hidden")).toBe("true");
+ vi.useRealTimers();
+ });
+
+ it("uses the default 160ms hide delay for fallback select when hide-delay-ms is omitted", () => {
+ vi.useFakeTimers();
+ document.body.innerHTML = `
+
+
+
+ `;
+
+ const field = document.querySelector("lht-select-help select");
+ const supportingText = document.querySelector("lht-select-help .lht-select-help__supporting-text");
+
+ field.dispatchEvent(new Event("focus"));
+ field.dispatchEvent(new Event("blur"));
+
+ vi.advanceTimersByTime(159);
+ expect(supportingText.hidden).toBe(false);
+
+ vi.advanceTimersByTime(1);
+ expect(supportingText.hidden).toBe(true);
+ vi.useRealTimers();
+ });
+});
+
+describe("lht-help-tooltip fallback", () => {
+ it("renders a native button when md-icon-button is unavailable", () => {
+ document.body.innerHTML = `
+
+ help
+
+ `;
+
+ const button = document.querySelector("lht-help-tooltip .md-help-icon-button--fallback");
+ const tooltip = document.querySelector("lht-help-tooltip .md-tooltip-content");
+
+ expect(button).not.toBeNull();
+ expect(button.tagName).toBe("BUTTON");
+ expect(button.getAttribute("aria-label")).toBe("説明ラベル");
+ expect(tooltip).not.toBeNull();
+ expect(tooltip.innerHTML).toContain("help ");
+ });
+
+ it("bundles the self-contained CSS contract for anchoring and visibility", () => {
+ expect(componentsCss).toContain("lht-help-tooltip {");
+ expect(componentsCss).toContain("position: relative;");
+ expect(componentsCss).toContain("overflow: visible;");
+ expect(componentsCss).toContain("lht-help-tooltip .md-tooltip-group {");
+ expect(componentsCss).toContain("lht-help-tooltip .md-tooltip-content {");
+ expect(componentsCss).toContain("display: none;");
+ expect(componentsCss).toContain("lht-help-tooltip .md-tooltip-group:hover .md-tooltip-content,");
+ expect(componentsCss).toContain("lht-help-tooltip .md-tooltip-group:focus-within .md-tooltip-content {");
+ expect(componentsCss).toContain("display: block;");
+ });
+
+ it("supports placement auto and clamps to the lower-overflow side", () => {
+ document.body.innerHTML = `
+
+ help
+
+ `;
+
+ const element = document.querySelector("lht-help-tooltip");
+ const group = element.querySelector(".md-tooltip-group");
+ const tooltip = element.querySelector(".md-tooltip-content");
+
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 320 });
+ Object.defineProperty(window, "innerHeight", { configurable: true, value: 640 });
+ group.getBoundingClientRect = () => ({
+ left: 260,
+ right: 290,
+ top: 100,
+ bottom: 130,
+ width: 30,
+ height: 30
+ });
+ tooltip.getBoundingClientRect = () => ({
+ width: 120,
+ height: 60
+ });
+
+ element._applyTooltipPlacement();
+
+ expect(tooltip.dataset.placement).toBe("right");
+ expect(tooltip.style.left).toBe("-76px");
+ expect(tooltip.style.top).toBe("-15px");
+ });
+
+ it("hides by default, becomes visible while active, and resets on leave", () => {
+ mountTooltipStyles();
+ document.body.innerHTML = `
+
+ help
+
+ `;
+
+ const element = document.querySelector("lht-help-tooltip");
+ const tooltip = element.querySelector(".md-tooltip-content");
+
+ expect(window.getComputedStyle(tooltip).display).toBe("none");
+
+ element._handleTooltipEnter();
+ expect(window.getComputedStyle(tooltip).display).toBe("block");
+ expect(tooltip.style.left).not.toBe("");
+ expect(tooltip.style.top).not.toBe("");
+
+ element._handleTooltipLeave();
+ expect(window.getComputedStyle(tooltip).display).toBe("none");
+ expect(tooltip.style.left).toBe("");
+ expect(tooltip.style.top).toBe("");
+ });
+
+ it("supports Escape to force-hide the active tooltip", () => {
+ document.body.innerHTML = `
+
+ help
+
+ `;
+
+ const element = document.querySelector("lht-help-tooltip");
+ const group = element.querySelector(".md-tooltip-group");
+ const button = group.querySelector(".md-help-icon-button");
+
+ element._handleTooltipEnter();
+ button.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+
+ expect(group.getAttribute("data-force-hidden")).toBe("true");
+ expect(element._activeTooltip).toBe(false);
+
+ element._handleTooltipEnter();
+ expect(group.hasAttribute("data-force-hidden")).toBe(false);
+ });
+});
+
+describe("lht-text-field-help fallback", () => {
+ it("renders native input fallback when md-outlined-text-field is unavailable", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const field = document.querySelector("lht-text-field-help input");
+ const supportingText = document.querySelector("lht-text-field-help .lht-text-field-help__supporting-text");
+
+ expect(field).not.toBeNull();
+ expect(field.id).toBe("nameField");
+ expect(field.value).toBe("Alice");
+ expect(field.getAttribute("aria-label")).toBe("Name");
+ expect(field.title).toBe("Enter your name");
+ expect(supportingText).not.toBeNull();
+ expect(supportingText.textContent).toBe("Enter your name");
+ expect(supportingText.hidden).toBe(true);
+ });
+
+ it("renders native textarea fallback when rows is specified", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const field = document.querySelector("lht-text-field-help textarea");
+
+ expect(field).not.toBeNull();
+ expect(field.id).toBe("memoField");
+ expect(field.getAttribute("rows")).toBe("4");
+ expect(field.value).toBe("hello");
+ });
+
+ it("shows help text on focus and hides it after blur delay", () => {
+ vi.useFakeTimers();
+ document.body.innerHTML = `
+
+ `;
+
+ const field = document.querySelector("lht-text-field-help input");
+ const supportingText = document.querySelector("lht-text-field-help .lht-text-field-help__supporting-text");
+
+ field.dispatchEvent(new Event("focus"));
+ expect(supportingText.hidden).toBe(false);
+ expect(supportingText.getAttribute("aria-hidden")).toBe("false");
+
+ field.dispatchEvent(new Event("blur"));
+ vi.advanceTimersByTime(239);
+ expect(supportingText.hidden).toBe(false);
+
+ vi.advanceTimersByTime(1);
+ expect(supportingText.hidden).toBe(true);
+ expect(supportingText.getAttribute("aria-hidden")).toBe("true");
+ vi.useRealTimers();
+ });
+
+ it("uses the default 160ms hide delay when hide-delay-ms is omitted", () => {
+ vi.useFakeTimers();
+ document.body.innerHTML = `
+
+ `;
+
+ const field = document.querySelector("lht-text-field-help input");
+ const supportingText = document.querySelector("lht-text-field-help .lht-text-field-help__supporting-text");
+
+ field.dispatchEvent(new Event("focus"));
+ field.dispatchEvent(new Event("blur"));
+
+ vi.advanceTimersByTime(159);
+ expect(supportingText.hidden).toBe(false);
+
+ vi.advanceTimersByTime(1);
+ expect(supportingText.hidden).toBe(true);
+ vi.useRealTimers();
+ });
+
+ it("supports clearable fallback text fields and dispatches input/change when cleared", async () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const field = document.querySelector("lht-text-field-help input");
+ const clearButton = document.querySelector("lht-text-field-help .lht-text-field-help__clear-button");
+ const inputListener = vi.fn();
+ const changeListener = vi.fn();
+
+ field.addEventListener("input", inputListener);
+ field.addEventListener("change", changeListener);
+
+ await waitForMicrotask();
+
+ expect(clearButton).not.toBeNull();
+ expect(clearButton.hidden).toBe(false);
+
+ clearButton.click();
+
+ expect(field.value).toBe("");
+ expect(clearButton.hidden).toBe(true);
+ expect(inputListener).toHaveBeenCalledTimes(1);
+ expect(changeListener).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("lht-file-select events", () => {
+ it("dispatches before-open and auto-clicks input by default", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const element = document.querySelector("lht-file-select");
+ const button = document.getElementById("fileSelectBtn");
+ const input = document.getElementById("fileInput");
+ const beforeOpen = vi.fn();
+ const clickSpy = vi.fn();
+
+ element.addEventListener("lht-file-select:before-open", beforeOpen);
+ input.click = clickSpy;
+
+ button.click();
+
+ expect(beforeOpen).toHaveBeenCalledTimes(1);
+ expect(clickSpy).toHaveBeenCalledTimes(1);
+ expect(beforeOpen.mock.calls[0][0].detail.autoOpen).toBe(true);
+ });
+
+ it("supports host-owned open flow via auto-open=false and emits change event", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const element = document.querySelector("lht-file-select");
+ const button = document.getElementById("fileSelectBtn");
+ const input = document.getElementById("fileInput");
+ const fileName = document.getElementById("fileNameText");
+ const beforeOpen = vi.fn((event) => {
+ expect(event.detail.autoOpen).toBe(false);
+ });
+ const changeListener = vi.fn();
+ const clickSpy = vi.fn();
+
+ element.addEventListener("lht-file-select:before-open", beforeOpen);
+ element.addEventListener("lht-file-select:change", changeListener);
+ input.click = clickSpy;
+
+ button.click();
+
+ expect(beforeOpen).toHaveBeenCalledTimes(1);
+ expect(clickSpy).not.toHaveBeenCalled();
+
+ Object.defineProperty(input, "files", {
+ configurable: true,
+ value: [{ name: "score.musicxml" }]
+ });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+
+ expect(fileName.textContent).toBe("score.musicxml");
+ expect(changeListener).toHaveBeenCalledTimes(1);
+ expect(changeListener.mock.calls[0][0].detail.names).toEqual(["score.musicxml"]);
+ });
+});
+
+describe("lht-error-alert variants", () => {
+ it("uses alert/assertive for error variant by default", () => {
+ document.body.innerHTML = ` `;
+
+ const element = document.querySelector("lht-error-alert");
+
+ expect(element.getAttribute("variant")).toBe("error");
+ expect(element.getAttribute("role")).toBe("alert");
+ expect(element.getAttribute("aria-live")).toBe("assertive");
+ });
+
+ it("uses status/polite for warning and info variants", () => {
+ document.body.innerHTML = `
+
+
+ `;
+
+ const warning = document.querySelector('lht-error-alert[variant="warning"]');
+ const info = document.querySelector('lht-error-alert[variant="info"]');
+
+ expect(warning.getAttribute("role")).toBe("status");
+ expect(warning.getAttribute("aria-live")).toBe("polite");
+ expect(info.getAttribute("role")).toBe("status");
+ expect(info.getAttribute("aria-live")).toBe("polite");
+ });
+});
+
+describe("lht-command-block fallback", () => {
+ it("renders native copy buttons when md-icon-button is unavailable", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const buttons = document.querySelectorAll("lht-command-block button.md-copy-button--fallback");
+ const code = document.querySelector("lht-command-block code#cmd");
+
+ expect(code).not.toBeNull();
+ expect(buttons).toHaveLength(2);
+ });
+});
+
+describe("lht-switch-help fallback", () => {
+ it("renders supported fallback DOM when md-switch is unavailable", () => {
+ const onChange = vi.fn();
+ window.testSwitchChange = onChange;
+
+ document.body.innerHTML = `
+
+ help
+
+ `;
+
+ const input = document.getElementById("demo-switch");
+ const visual = document.querySelector("lht-switch-help .md-switch-input + .md-switch");
+
+ expect(input).not.toBeNull();
+ expect(input.tagName).toBe("INPUT");
+ expect(input.checked).toBe(true);
+ expect(visual).not.toBeNull();
+
+ input.checked = false;
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+
+ expect(input.getAttribute("aria-checked")).toBe("false");
+ expect(onChange).toHaveBeenCalledTimes(1);
+
+ delete window.testSwitchChange;
+ });
+});
+
+describe("lht critical components mode matrix", () => {
+ it("renders self-contained fallback DOM when material components are unavailable", () => {
+ renderCriticalComponentsMarkup();
+
+ expect(document.querySelector("lht-help-tooltip .md-help-icon-button--fallback")).not.toBeNull();
+ expect(document.querySelector("lht-text-field-help input")).not.toBeNull();
+ expect(document.querySelector("lht-select-help select")).not.toBeNull();
+ expect(document.querySelector("lht-file-select button.lht-file-select__button--fallback")).not.toBeNull();
+ expect(document.querySelector("lht-switch-help input.md-switch-input")).not.toBeNull();
+ expect(document.querySelector("lht-command-block button.md-copy-button--fallback")).not.toBeNull();
+ });
+
+ it("renders md-* primitives when material components are registered", () => {
+ defineMaterialTestDoubles();
+ renderCriticalComponentsMarkup();
+
+ expect(document.querySelector("lht-help-tooltip md-icon-button")).not.toBeNull();
+ expect(document.querySelector("lht-text-field-help md-outlined-text-field")).not.toBeNull();
+ expect(document.querySelector("lht-select-help md-outlined-select")).not.toBeNull();
+ expect(document.querySelector("lht-file-select md-filled-button")).not.toBeNull();
+ expect(document.querySelector("lht-switch-help md-switch")).not.toBeNull();
+ expect(document.querySelector("lht-command-block md-icon-button")).not.toBeNull();
+ });
+});
+
+describe("lht components in material-loaded mode", () => {
+ it("uses md-* elements when the corresponding custom elements are registered", () => {
+ defineMaterialTestDoubles();
+
+ document.body.innerHTML = `
+ body
+
+
+
+
+
+ `;
+
+ expect(document.querySelector("lht-help-tooltip md-icon-button")).not.toBeNull();
+ expect(document.querySelector("lht-text-field-help md-outlined-text-field")).not.toBeNull();
+ expect(document.querySelector("lht-select-help md-outlined-select")).not.toBeNull();
+ expect(document.querySelector("lht-file-select md-filled-button")).not.toBeNull();
+ expect(document.querySelector("lht-switch-help md-switch")).not.toBeNull();
+ expect(document.querySelector("lht-command-block md-icon-button")).not.toBeNull();
+ });
+});
diff --git a/lht-cmn/css/components.css b/lht-cmn/css/components.css
new file mode 100644
index 0000000..ca90ee0
--- /dev/null
+++ b/lht-cmn/css/components.css
@@ -0,0 +1,1049 @@
+/*
+ * lht-cmn components.css
+ * Version: v20260308
+ * Copyright 2026 Toshiki Iga
+ * Licensed under the Apache License, Version 2.0
+ */
+
+lht-help-tooltip {
+ display: inline-flex;
+ align-items: center;
+ position: relative;
+ overflow: visible;
+}
+
+lht-help-tooltip .md-tooltip-group {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ vertical-align: middle;
+ overflow: visible;
+}
+
+lht-help-tooltip .md-tooltip-content {
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ margin-top: 0.5rem;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 150ms ease;
+ z-index: 50;
+ width: min(20rem, 90vw);
+ max-width: 90vw;
+}
+
+lht-help-tooltip .md-tooltip-group:hover .md-tooltip-content,
+lht-help-tooltip .md-tooltip-group:focus-within .md-tooltip-content {
+ opacity: 1;
+}
+
+lht-help-tooltip .md-tooltip {
+ background: #2b2831;
+ color: #f7f4fb;
+ border-radius: 12px;
+ padding: 0.75rem 1rem;
+ font-size: 0.8125rem;
+ font-weight: 400;
+ line-height: 1.5;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
+}
+
+lht-help-tooltip .md-tooltip--wide {
+ width: min(32rem, 90vw);
+}
+
+lht-help-tooltip .md-tooltip--rich {
+ border: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.md-icon-btn {
+ width: 40px;
+ height: 40px;
+ border-radius: 20px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: #f0edf5;
+ color: #3f3b46;
+ border: none;
+ cursor: pointer;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
+}
+
+.md-icon-btn:hover {
+ background: #ebe6f3;
+}
+
+.md-menu-button {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+}
+
+.md-menu-panel {
+ position: absolute;
+ top: 56px;
+ right: 16px;
+ background: var(--md-sys-color-surface, #fffbfe);
+ border: 1px solid #e6e1ee;
+ border-radius: 12px;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+ padding: 4px 0;
+ min-width: 160px;
+ z-index: 20;
+}
+
+.md-menu-link {
+ display: block;
+ padding: 8px 16px;
+ color: var(--md-sys-color-on-surface, #1d1b20);
+ text-decoration: none;
+}
+
+.md-menu-link:hover {
+ background: #f2edf8;
+}
+
+.md-hidden {
+ display: none !important;
+}
+
+/*
+ * Hide raw pre-upgrade content until each component finishes connectedCallback()
+ * and marks itself initialized. This prevents tooltip/help text from flashing
+ * into the normal layout before the component rewrites its internal DOM.
+ */
+lht-help-tooltip:not([data-initialized="true"]),
+lht-text-field-help:not([data-initialized="true"]),
+lht-select-help:not([data-initialized="true"]),
+lht-file-select:not([data-initialized="true"]),
+lht-switch-help:not([data-initialized="true"]),
+lht-command-block:not([data-initialized="true"]),
+lht-page-menu:not([data-initialized="true"]),
+lht-page-hero:not([data-initialized="true"]),
+lht-index-card-link:not([data-initialized="true"]),
+lht-loading-overlay:not([data-initialized="true"]),
+lht-toast:not([data-initialized="true"]),
+lht-error-alert:not([data-initialized="true"]),
+lht-input-mode-toggle:not([data-initialized="true"]),
+lht-preview-output:not([data-initialized="true"]) {
+ visibility: hidden;
+}
+
+/* Keep accidental horizontal overflow from creating blank right-space on narrow screens. */
+.md-page {
+ overflow-x: clip;
+}
+
+/* Tooltip defaults are centralized in lht to avoid per-page overflow regressions. */
+lht-help-tooltip .md-tooltip-content {
+ display: none;
+ max-width: min(24rem, calc(100vw - 2rem));
+}
+
+lht-help-tooltip .md-tooltip-group:hover .md-tooltip-content,
+lht-help-tooltip .md-tooltip-group:focus-within .md-tooltip-content {
+ display: block;
+}
+
+lht-help-tooltip .md-tooltip-group[data-force-hidden="true"] .md-tooltip-content {
+ display: none !important;
+}
+
+@media (max-width: 640px) {
+ lht-help-tooltip .md-tooltip-content {
+ left: auto;
+ right: 0;
+ transform: none;
+ width: min(24rem, calc(100vw - 2rem));
+ max-width: calc(100vw - 2rem);
+ }
+}
+
+lht-page-hero {
+ display: block;
+ margin-bottom: 1.2rem;
+ padding: 1rem 1rem 0.9rem;
+ position: relative;
+ z-index: 40;
+ overflow: visible;
+ border-radius: 20px;
+ background: linear-gradient(180deg, #f6f1ff 0%, #fbf8ff 100%);
+ border: 1px solid #e7dff6;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
+}
+
+lht-page-hero .lht-page-hero__title-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ margin-bottom: 0.45rem;
+}
+
+lht-page-hero .lht-page-hero__title-main {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.28rem;
+ min-width: 0;
+ max-width: 100%;
+}
+
+lht-page-hero .lht-page-hero__title {
+ margin: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 0;
+ max-width: 100%;
+ font-size: clamp(1.12rem, 4.2vw, 1.45rem);
+ line-height: 1.2;
+ letter-spacing: 0.01em;
+ color: #2f1f52;
+ overflow-wrap: anywhere;
+}
+
+lht-page-hero .lht-page-hero__icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.7rem;
+ height: 1.7rem;
+ border-radius: 0.85rem;
+ background: rgba(98, 0, 238, 0.14);
+ flex: 0 0 auto;
+}
+
+lht-page-hero .lht-page-hero__actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ margin-left: auto;
+}
+
+lht-page-hero .lht-page-hero__subtitle {
+ display: inline-flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ color: #514a5f;
+ font-size: 0.86rem;
+ line-height: 1.45;
+}
+
+lht-page-hero .lht-page-hero__title-main .md-tooltip-group {
+ transform: translateY(1px);
+ flex: 0 0 auto;
+ z-index: 41;
+}
+
+lht-page-hero .lht-page-hero__title-main .md-tooltip-content {
+ z-index: 260;
+}
+
+lht-page-hero .lht-page-menu {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+}
+
+lht-page-hero .md-menu-button {
+ position: static;
+ top: auto;
+ right: auto;
+ margin: 0;
+}
+
+lht-page-hero .md-menu-panel {
+ top: calc(100% + 0.45rem);
+ right: 0;
+ left: auto;
+ z-index: 280;
+}
+
+@media (max-width: 640px) {
+ lht-page-hero {
+ padding: 0.9rem 0.85rem 0.8rem;
+ }
+
+ lht-page-hero .lht-page-hero__title-row {
+ gap: 0.5rem;
+ }
+
+ lht-page-hero .lht-page-hero__actions {
+ width: 100%;
+ justify-content: flex-end;
+ }
+
+ lht-page-hero .lht-page-hero__icon {
+ width: 1.55rem;
+ height: 1.55rem;
+ }
+
+ lht-page-hero .lht-page-hero__subtitle {
+ font-size: 0.82rem;
+ }
+}
+
+@media (max-width: 430px) {
+ lht-page-hero .lht-page-hero__title-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: start;
+ gap: 0.4rem;
+ }
+
+ lht-page-hero .lht-page-hero__title-main {
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+ }
+
+ lht-page-hero .lht-page-hero__title {
+ font-size: clamp(1rem, 4vw, 1.18rem);
+ max-width: 100%;
+ }
+
+ lht-page-hero .lht-page-hero__actions {
+ width: auto;
+ min-width: 0;
+ margin-left: auto;
+ gap: 0.3rem;
+ justify-content: flex-end;
+ align-self: start;
+ }
+
+ lht-page-hero .md-menu-button,
+ lht-page-hero .lht-page-hero__actions .md-icon-btn,
+ lht-page-hero .lht-page-hero__actions .md-help-icon-button,
+ lht-page-hero .lht-page-hero__actions .md-help-icon-button--fallback {
+ width: 2.1rem;
+ height: 2.1rem;
+ }
+}
+
+lht-command-block {
+ display: block;
+}
+
+lht-command-block .md-copy-button--fallback {
+ border: none;
+ width: 2rem;
+ height: 2rem;
+ border-radius: 999px;
+ background: #efe9fa;
+ color: #3f3564;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ padding: 0;
+}
+
+lht-command-block .md-copy-button--fallback .md-icon-small {
+ width: 16px;
+ height: 16px;
+}
+
+lht-text-field-help {
+ display: block;
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+lht-text-field-help .lht-text-field-help__control-wrap {
+ position: relative;
+}
+
+lht-text-field-help .lht-text-field-help__fallback-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+lht-text-field-help .lht-text-field-help__fallback {
+ width: 100%;
+ border-radius: 12px;
+ border: 1px solid var(--md-sys-color-outline, #79747e);
+ background: var(--md-sys-color-surface, #fffbfe);
+ padding: 0.9rem 0.75rem;
+ font-size: 1rem;
+ line-height: 1.4;
+ color: var(--md-sys-color-on-surface, #1d1b20);
+ box-sizing: border-box;
+ font: inherit;
+ resize: vertical;
+}
+
+lht-text-field-help .lht-text-field-help__fallback.lht-text-field-help__field--clearable {
+ padding-right: 2.8rem;
+}
+
+lht-text-field-help .lht-text-field-help__supporting-text {
+ min-height: 1rem;
+ padding: 0 1rem;
+ font-size: 0.75rem;
+ line-height: 1rem;
+ color: var(--md-sys-color-on-surface-variant, #49454f);
+ box-sizing: border-box;
+}
+
+lht-text-field-help .md-outlined-field.lht-text-field-help__field--clearable {
+ --md-outlined-text-field-trailing-space: 40px;
+ --md-outlined-field-trailing-space: 40px;
+}
+
+lht-text-field-help .lht-text-field-help__clear-button {
+ position: absolute;
+ top: 24px;
+ right: 14px;
+ width: 28px;
+ height: 28px;
+ border: none;
+ border-radius: 999px;
+ background: rgba(103, 80, 164, 0.10);
+ color: #625b71;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ padding: 0;
+ transform: translateY(-50%);
+ transition: background 150ms ease, color 150ms ease, transform 150ms ease;
+ z-index: 2;
+}
+
+lht-text-field-help .lht-text-field-help__clear-button:hover {
+ background: rgba(103, 80, 164, 0.18);
+ color: #4f378b;
+}
+
+lht-text-field-help .lht-text-field-help__clear-button:active {
+ transform: translateY(-50%) scale(0.96);
+}
+
+lht-text-field-help .lht-text-field-help__clear-button:focus-visible {
+ outline: 2px solid #6750a4;
+ outline-offset: 2px;
+}
+
+lht-text-field-help .lht-text-field-help__clear-button::before,
+lht-text-field-help .lht-text-field-help__clear-button::after {
+ content: "";
+ position: absolute;
+ width: 12px;
+ height: 2px;
+ border-radius: 999px;
+ background: currentColor;
+}
+
+lht-text-field-help .lht-text-field-help__clear-button::before {
+ transform: rotate(45deg);
+}
+
+lht-text-field-help .lht-text-field-help__clear-button::after {
+ transform: rotate(-45deg);
+}
+
+lht-select-help {
+ display: block;
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+lht-select-help .lht-select-help__fallback-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+lht-select-help .lht-select-help__fallback {
+ width: 100%;
+ border-radius: 12px;
+ border: 1px solid var(--md-sys-color-outline, #79747e);
+ background: var(--md-sys-color-surface, #fffbfe);
+ padding: 0.75rem;
+ font-size: 0.95rem;
+ color: var(--md-sys-color-on-surface, #1d1b20);
+ box-sizing: border-box;
+}
+
+lht-select-help .lht-select-help__supporting-text {
+ min-height: 1rem;
+ padding: 0 1rem;
+ font-size: 0.75rem;
+ line-height: 1rem;
+ color: var(--md-sys-color-on-surface-variant, #49454f);
+ box-sizing: border-box;
+}
+
+lht-file-select {
+ display: block;
+}
+
+lht-file-select .lht-file-select {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.35rem;
+}
+
+lht-file-select .lht-file-select__button {
+ --md-filled-button-container-color: var(--md-sys-color-primary, #6200ee);
+ --md-filled-button-label-text-color: #ffffff;
+ --md-filled-button-container-shape: 999px;
+ --md-filled-button-container-height: 54px;
+ --md-filled-button-leading-space: 20px;
+ --md-filled-button-trailing-space: 24px;
+ --md-filled-button-icon-size: 20px;
+ --md-focus-ring-color: #6c3eea;
+ font-size: 1rem;
+ font-weight: 700;
+ box-shadow: 0 8px 18px rgba(98, 0, 238, 0.24);
+}
+
+lht-file-select .lht-file-select__button--fallback {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.6rem;
+ border: none;
+ border-radius: 999px;
+ padding: 0.85rem 1.55rem;
+ line-height: 1;
+ color: #ffffff;
+ background: var(--md-sys-color-primary, #6200ee);
+ cursor: pointer;
+}
+
+lht-file-select .lht-file-select__button-icon {
+ width: 1.2rem;
+ height: 1.2rem;
+ flex: 0 0 auto;
+}
+
+lht-file-select .lht-file-select__button-text {
+ white-space: nowrap;
+}
+
+lht-file-select .lht-file-select__file-name {
+ font-size: 0.85rem;
+ color: var(--md-sys-color-on-surface-variant, #49454f);
+ word-break: break-all;
+}
+
+lht-loading-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 320;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+ background: rgba(17, 13, 27, 0.45);
+ backdrop-filter: blur(2px);
+}
+
+lht-loading-overlay[active] {
+ display: flex;
+}
+
+lht-loading-overlay[aria-hidden="true"] {
+ pointer-events: none;
+}
+
+lht-loading-overlay .lht-loading-overlay__dialog {
+ min-width: 13rem;
+ border-radius: 16px;
+ border: 1px solid #ded4ef;
+ background: #ffffff;
+ box-shadow: 0 18px 36px rgba(24, 18, 36, 0.24);
+ padding: 1rem 1.2rem;
+ display: grid;
+ justify-items: center;
+ gap: 0.65rem;
+}
+
+lht-loading-overlay .lht-loading-overlay__spinner {
+ width: 2rem;
+ height: 2rem;
+ border-radius: 999px;
+ border: 3px solid #d8caee;
+ border-top-color: var(--md-sys-color-primary, #6200ee);
+ animation: lht-loading-spin 0.95s linear infinite;
+}
+
+lht-loading-overlay .lht-loading-overlay__text {
+ margin: 0;
+ color: #383247;
+ font-size: 0.92rem;
+ font-weight: 600;
+}
+
+lht-toast {
+ position: fixed;
+ left: 50%;
+ bottom: max(1rem, env(safe-area-inset-bottom, 0px) + 0.5rem);
+ transform: translate(-50%, 18px);
+ z-index: 340;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 180ms ease, transform 180ms ease;
+}
+
+lht-toast[active] {
+ opacity: 1;
+ transform: translate(-50%, 0);
+}
+
+lht-toast .lht-toast__body {
+ min-width: 8.5rem;
+ max-width: min(30rem, calc(100vw - 2rem));
+ border-radius: 12px;
+ padding: 0.62rem 0.9rem;
+ background: rgba(43, 34, 63, 0.94);
+ color: #ffffff;
+ font-size: 0.88rem;
+ font-weight: 600;
+ text-align: center;
+ box-shadow: 0 10px 24px rgba(18, 14, 28, 0.35);
+}
+
+lht-error-alert {
+ display: none;
+ margin: 0;
+}
+
+lht-error-alert[active] {
+ display: block;
+}
+
+lht-error-alert .lht-error-alert__body {
+ margin: 0;
+ border-radius: 12px;
+ border: 1px solid #f3c7c9;
+ background: #fff3f3;
+ color: #9a1a1f;
+ padding: 0.58rem 0.72rem;
+ font-size: 0.88rem;
+ line-height: 1.45;
+}
+
+lht-error-alert[variant="warning"] .lht-error-alert__body {
+ border-color: #f0d59b;
+ background: #fff8e6;
+ color: #8a5a00;
+}
+
+lht-error-alert[variant="info"] .lht-error-alert__body {
+ border-color: #bdd7f5;
+ background: #eef6ff;
+ color: #175ea8;
+}
+
+lht-input-mode-toggle {
+ display: block;
+}
+
+lht-input-mode-toggle .lht-input-mode-toggle__group {
+ display: inline-flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.7rem;
+}
+
+lht-input-mode-toggle .lht-input-mode-toggle__option {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.36rem;
+ color: var(--md-sys-color-on-surface, #1d1b20);
+ font-size: 0.92rem;
+}
+
+lht-input-mode-toggle .lht-input-mode-toggle__option input[type="radio"] {
+ inline-size: 1rem;
+ block-size: 1rem;
+ margin: 0;
+ accent-color: var(--md-sys-color-primary, #6200ee);
+}
+
+lht-preview-output {
+ display: block;
+}
+
+lht-preview-output .lht-preview-output__root {
+ position: relative;
+}
+
+lht-preview-output .lht-preview-output__preview {
+ margin: 0;
+ min-height: 6.4rem;
+ border-radius: 12px;
+ border: 1px solid #d4cede;
+ background: #f8f6fb;
+ color: #231f2b;
+ padding: 0.78rem 0.84rem;
+ line-height: 1.45;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+lht-preview-output .lht-preview-output__copy-button {
+ position: absolute;
+ top: 0.48rem;
+ right: 0.48rem;
+ width: 2rem;
+ height: 2rem;
+ border: none;
+ border-radius: 999px;
+ background: #efe9fa;
+ color: #3f3564;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+
+lht-preview-output .lht-preview-output__copy-icon {
+ width: 1rem;
+ height: 1rem;
+}
+
+@keyframes lht-loading-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+md-outlined-text-field.md-outlined-field {
+ flex: 1 1 16rem;
+ min-width: 12rem;
+ width: 100%;
+ position: relative;
+ color-scheme: light;
+ border-radius: 12px;
+ --md-outlined-text-field-container-shape: 12px;
+ --md-outlined-field-container-shape: 12px;
+ --md-outlined-text-field-top-space: 10px;
+ --md-outlined-text-field-bottom-space: 10px;
+ --md-outlined-field-top-space: 10px;
+ --md-outlined-field-bottom-space: 10px;
+ --md-outlined-text-field-content-space: 12px;
+ --md-outlined-text-field-leading-space: 12px;
+ --md-outlined-text-field-trailing-space: 12px;
+ --md-outlined-text-field-outline-color: #bdb7c8;
+ --md-outlined-field-outline-color: #bdb7c8;
+ --md-outlined-text-field-hover-outline-color: #b1aac0;
+ --md-outlined-field-hover-outline-color: #b1aac0;
+ --md-outlined-text-field-focus-outline-color: #6c3eea;
+ --md-outlined-field-focus-outline-color: #6c3eea;
+ --md-outlined-text-field-focus-outline-width: 1.5px;
+ --md-outlined-field-focus-outline-width: 1.5px;
+ --md-outlined-text-field-focus-state-layer-color: #6c3eea;
+ --md-outlined-field-focus-state-layer-color: #6c3eea;
+ --md-outlined-text-field-focus-state-layer-opacity: 0;
+ --md-outlined-field-focus-state-layer-opacity: 0;
+ --md-outlined-text-field-error-focus-outline-width: 1px;
+ --md-outlined-field-error-focus-outline-width: 1px;
+ --md-outlined-text-field-caret-color: #6c3eea;
+ --md-outlined-field-caret-color: #6c3eea;
+ transition: none;
+}
+
+md-outlined-text-field.md-outlined-field::part(outline) {
+ transition: filter 160ms ease;
+}
+
+md-outlined-text-field.md-outlined-field:focus-within::part(outline) {
+ filter: drop-shadow(0 0 4px rgba(108, 62, 234, 0.26)) drop-shadow(0 0 12px rgba(108, 62, 234, 0.22));
+}
+
+md-outlined-text-field.md-outlined-field::part(container) {
+ transition: box-shadow 160ms ease;
+}
+
+md-outlined-text-field.md-outlined-field:focus-within::part(container) {
+ box-shadow: 0 0 0 2px rgba(108, 62, 234, 0.12), 0 0 14px rgba(108, 62, 234, 0.18);
+}
+
+md-outlined-select.md-outlined-field {
+ flex: 1 1 16rem;
+ min-width: 12rem;
+ width: 100%;
+ position: relative;
+ color-scheme: light;
+ border-radius: 12px;
+ --md-outlined-select-text-field-container-shape: 12px;
+ --md-outlined-select-text-field-top-space: 10px;
+ --md-outlined-select-text-field-bottom-space: 10px;
+ --md-outlined-select-text-field-content-space: 12px;
+ --md-outlined-select-text-field-leading-space: 12px;
+ --md-outlined-select-text-field-trailing-space: 12px;
+ --md-outlined-field-top-space: 10px;
+ --md-outlined-field-bottom-space: 10px;
+ --md-outlined-field-content-space: 12px;
+ --md-outlined-field-leading-space: 12px;
+ --md-outlined-field-trailing-space: 12px;
+ --md-outlined-select-text-field-outline-color: #bdb7c8;
+ --md-outlined-select-text-field-hover-outline-color: #b1aac0;
+ --md-outlined-select-text-field-focus-outline-color: #6c3eea;
+ --md-outlined-select-text-field-focus-outline-width: 1.5px;
+ --md-outlined-select-text-field-focus-state-layer-color: #6c3eea;
+ --md-outlined-select-text-field-focus-state-layer-opacity: 0;
+ --md-outlined-select-text-field-caret-color: #6c3eea;
+ --md-menu-item-one-line-container-height: 44px;
+ --md-menu-item-top-space: 8px;
+ --md-menu-item-bottom-space: 8px;
+ transition: none;
+}
+
+md-outlined-select.md-outlined-field::part(outline) {
+ transition: filter 160ms ease;
+}
+
+md-outlined-select.md-outlined-field:focus-within::part(outline) {
+ filter: drop-shadow(0 0 4px rgba(108, 62, 234, 0.26)) drop-shadow(0 0 12px rgba(108, 62, 234, 0.22));
+}
+
+md-outlined-select.md-outlined-field::part(container) {
+ transition: box-shadow 160ms ease;
+}
+
+md-outlined-select.md-outlined-field:focus-within::part(container) {
+ box-shadow: 0 0 0 2px rgba(108, 62, 234, 0.12), 0 0 14px rgba(108, 62, 234, 0.18);
+}
+
+lht-switch-help {
+ display: inline-flex;
+ align-items: center;
+}
+
+lht-switch-help .md-switch-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.24rem;
+ font-size: 0.95rem;
+ color: var(--md-sys-color-on-surface);
+}
+
+lht-switch-help .md-switch-label md-switch {
+ --md-switch-track-width: 44px;
+ --md-switch-track-height: 24px;
+ --md-switch-track-outline-width: 1px;
+ --md-switch-handle-width: 20px;
+ --md-switch-handle-height: 20px;
+ --md-switch-selected-handle-width: 20px;
+ --md-switch-selected-handle-height: 20px;
+ --md-switch-with-icon-handle-width: 20px;
+ --md-switch-with-icon-handle-height: 20px;
+ --md-switch-state-layer-size: 24px;
+ --md-focus-ring-color: #6c3eea;
+ flex: 0 0 44px;
+ inline-size: 44px;
+ min-inline-size: 44px;
+ vertical-align: middle;
+}
+
+lht-switch-help .md-switch-label .md-switch-input {
+ position: absolute;
+ opacity: 0;
+ pointer-events: none;
+}
+
+lht-switch-help .md-switch-label .md-switch {
+ width: 44px;
+ height: 24px;
+ border-radius: 999px;
+ background: #d7d2de;
+ position: relative;
+ transition: background 150ms ease;
+ flex: 0 0 44px;
+}
+
+lht-switch-help .md-switch-label .md-switch::after {
+ content: "";
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: #ffffff;
+ position: absolute;
+ top: 3px;
+ left: 3px;
+ transition: transform 150ms ease;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
+}
+
+lht-switch-help .md-switch-label .md-switch-input:checked + .md-switch {
+ background: var(--md-sys-color-primary, #6200ee);
+}
+
+lht-switch-help .md-switch-label .md-switch-input:checked + .md-switch::after {
+ transform: translateX(20px);
+}
+
+lht-switch-help .md-switch-label .md-info-chip {
+ margin-left: 0;
+}
+
+/* Switch rows are often near the right edge; anchor tooltip right to avoid clipping. */
+lht-switch-help .md-switch-label .md-tooltip-content {
+ left: auto;
+ right: 0;
+ transform: none;
+ max-width: calc(100vw - 2rem);
+}
+
+.md-help-icon-button {
+ --md-icon-button-icon-size: 18px;
+ --md-icon-button-state-layer-size: 30px;
+ --md-focus-ring-color: #6c3eea;
+ color: #6f6a79;
+}
+
+.md-help-icon-button--fallback {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ border: none;
+ border-radius: 999px;
+ background: transparent;
+ cursor: help;
+}
+
+.md-help-icon-button--fallback .md-info-icon {
+ width: 18px;
+ height: 18px;
+}
+
+lht-index-card-link {
+ display: block;
+}
+
+.md-link-card {
+ position: relative;
+ display: block;
+ padding: 1rem;
+ border-radius: 16px;
+ background: var(--md-sys-color-surface, #fffbfe);
+ border: 1px solid #ece7f3;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
+ transition: box-shadow 150ms ease, border-color 150ms ease, background 150ms ease;
+ overflow: hidden;
+ outline: none;
+ color: inherit;
+ text-decoration: none;
+}
+
+.md-link-card:hover {
+ box-shadow: 0 6px 16px rgba(35, 29, 49, 0.12);
+ border-color: #d7cfee;
+}
+
+.md-link-card::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: var(--md-sys-color-state-layer, rgba(103, 80, 164, 0.08));
+ opacity: 0;
+ transition: opacity 150ms ease;
+}
+
+.md-link-card:hover::after {
+ opacity: 1;
+}
+
+.md-link-card:focus-visible {
+ border-color: rgba(98, 0, 238, 0.55);
+ box-shadow: 0 0 0 3px var(--md-sys-color-focus-ring, rgba(98, 0, 238, 0.22)), 0 6px 16px rgba(35, 29, 49, 0.12);
+}
+
+.md-link-card:focus-visible::after {
+ opacity: 1;
+}
+
+.md-link-card:active::after {
+ opacity: 0.18;
+}
+
+.md-link-card > * {
+ position: relative;
+ z-index: 1;
+}
+
+.md-card-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.md-card-title {
+ font-size: var(--md-typography-title-small, 1.1rem);
+ font-weight: 600;
+ color: #2f2a35;
+}
+
+.md-card-arrow {
+ color: #8f8a98;
+ font-size: 1.2rem;
+}
+
+.md-link-card:hover .md-card-arrow {
+ color: #6d6483;
+}
+
+.md-card-desc {
+ margin-top: 0.5rem;
+ font-size: var(--md-typography-body-small, 0.95rem);
+ color: #5f5a6b;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+@media (hover: hover) and (pointer: fine) {
+ .md-link-card:hover,
+ .md-link-card:focus-visible {
+ overflow: visible;
+ z-index: 2;
+ }
+
+ .md-link-card:hover .md-card-desc,
+ .md-link-card:focus-visible .md-card-desc {
+ display: block;
+ }
+}
+
+.lht-index-card-link__icon {
+ display: inline-flex;
+ align-items: center;
+ margin-right: 0.3rem;
+}
+
+.lht-index-card-link__badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin-left: 0.45rem;
+ padding: 0.08rem 0.45rem;
+ border-radius: 999px;
+ font-size: 0.65rem;
+ font-weight: 700;
+ color: #534a64;
+ background: rgba(108, 62, 234, 0.13);
+}
+
+.lht-index-card-link__desc--clamp {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: var(--lht-desc-lines, 3);
+ overflow: hidden;
+}
diff --git a/lht-cmn/js/components.js b/lht-cmn/js/components.js
new file mode 100644
index 0000000..3f867df
--- /dev/null
+++ b/lht-cmn/js/components.js
@@ -0,0 +1,1659 @@
+/*
+ * lht-cmn components.js
+ * Version: v20260308
+ * Copyright 2026 Toshiki Iga
+ * Licensed under the Apache License, Version 2.0
+ */
+
+class LhtHelpTooltip extends HTMLElement {
+ connectedCallback() {
+ if (this.dataset.initialized === "true") return;
+ this.dataset.initialized = "true";
+
+ const label = this.getAttribute("label") || "説明";
+ const isWide = this.hasAttribute("wide");
+ const helpContentHtml = this.innerHTML.trim();
+ const placement = this._normalizePlacement(this.getAttribute("placement"));
+
+ this.textContent = "";
+
+ const group = document.createElement("span");
+ group.className = "md-tooltip-group";
+
+ const hasMdIconButton = !!(window.customElements && window.customElements.get("md-icon-button"));
+ const button = document.createElement(hasMdIconButton ? "md-icon-button" : "button");
+ button.className = "md-help-icon-button";
+ if (!hasMdIconButton) {
+ button.type = "button";
+ button.classList.add("md-help-icon-button--fallback");
+ }
+ button.setAttribute("aria-label", label);
+ button.innerHTML = ' ';
+
+ const tooltip = document.createElement("span");
+ tooltip.className = `md-tooltip-content md-tooltip md-tooltip--rich${isWide ? " md-tooltip--wide" : ""}`;
+ tooltip.innerHTML = helpContentHtml;
+ tooltip.dataset.placement = placement;
+
+ group.appendChild(button);
+ group.appendChild(tooltip);
+ this.appendChild(group);
+
+ this._group = group;
+ this._tooltip = tooltip;
+ this._activeTooltip = false;
+ this._handleTooltipEnter = () => {
+ this._activeTooltip = true;
+ group.removeAttribute("data-force-hidden");
+ this._applyTooltipPlacement();
+ };
+ this._handleTooltipLeave = () => {
+ this._activeTooltip = false;
+ group.removeAttribute("data-force-hidden");
+ this._resetTooltipPlacement();
+ };
+ this._handleTooltipKeydown = (event) => {
+ if (event.key !== "Escape") return;
+ event.preventDefault();
+ event.stopPropagation();
+ this._activeTooltip = false;
+ group.setAttribute("data-force-hidden", "true");
+ this._resetTooltipPlacement();
+ if (document.activeElement && group.contains(document.activeElement)) {
+ document.activeElement.blur();
+ }
+ };
+ this._handleTooltipResize = () => {
+ if (!this._activeTooltip) return;
+ this._applyTooltipPlacement();
+ };
+
+ group.addEventListener("mouseenter", this._handleTooltipEnter);
+ group.addEventListener("focusin", this._handleTooltipEnter);
+ group.addEventListener("mouseleave", this._handleTooltipLeave);
+ group.addEventListener("keydown", this._handleTooltipKeydown);
+ group.addEventListener("focusout", () => {
+ requestAnimationFrame(() => {
+ if (!group.matches(":focus-within")) {
+ this._handleTooltipLeave();
+ }
+ });
+ });
+ window.addEventListener("resize", this._handleTooltipResize);
+ }
+
+ disconnectedCallback() {
+ if (this._group && this._handleTooltipEnter) {
+ this._group.removeEventListener("mouseenter", this._handleTooltipEnter);
+ this._group.removeEventListener("focusin", this._handleTooltipEnter);
+ this._group.removeEventListener("mouseleave", this._handleTooltipLeave);
+ this._group.removeEventListener("keydown", this._handleTooltipKeydown);
+ }
+ if (this._handleTooltipResize) {
+ window.removeEventListener("resize", this._handleTooltipResize);
+ }
+ }
+
+ _normalizePlacement(rawPlacement) {
+ const normalized = (rawPlacement || "auto").trim().toLowerCase();
+ return ["auto", "left", "right", "top", "bottom"].includes(normalized) ? normalized : "auto";
+ }
+
+ _applyTooltipPlacement() {
+ const tooltip = this._tooltip;
+ const group = this._group;
+ if (!tooltip || !group) return;
+
+ const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
+ const safeWidth = Math.max(120, viewportWidth - 32);
+ tooltip.style.maxWidth = `${safeWidth}px`;
+ tooltip.style.visibility = "hidden";
+ tooltip.style.display = "block";
+
+ const anchorRect = group.getBoundingClientRect();
+ const tooltipRect = tooltip.getBoundingClientRect();
+ const placement = this._normalizePlacement(this.getAttribute("placement"));
+ const appliedPlacement = placement === "auto"
+ ? this._pickAutoPlacement(anchorRect, tooltipRect, viewportWidth, viewportHeight)
+ : placement;
+ const position = this._computeTooltipPosition(appliedPlacement, anchorRect, tooltipRect, viewportWidth, viewportHeight);
+ const relativeLeft = position.left - anchorRect.left;
+ const relativeTop = position.top - anchorRect.top;
+
+ tooltip.dataset.placement = appliedPlacement;
+ tooltip.style.left = `${relativeLeft}px`;
+ tooltip.style.top = `${relativeTop}px`;
+ tooltip.style.right = "auto";
+ tooltip.style.bottom = "auto";
+ tooltip.style.transform = "none";
+ tooltip.style.marginTop = "0";
+ tooltip.style.visibility = "";
+ }
+
+ _resetTooltipPlacement() {
+ const tooltip = this._tooltip;
+ if (!tooltip) return;
+ tooltip.dataset.placement = this._normalizePlacement(this.getAttribute("placement"));
+ tooltip.style.left = "";
+ tooltip.style.top = "";
+ tooltip.style.right = "";
+ tooltip.style.bottom = "";
+ tooltip.style.transform = "";
+ tooltip.style.marginTop = "";
+ tooltip.style.maxWidth = "";
+ tooltip.style.visibility = "";
+ tooltip.style.display = "";
+ }
+
+ _pickAutoPlacement(anchorRect, tooltipRect, viewportWidth, viewportHeight) {
+ const candidates = ["right", "left", "bottom", "top"];
+ let bestPlacement = "right";
+ let bestScore = Number.POSITIVE_INFINITY;
+
+ for (const candidate of candidates) {
+ const position = this._computeTooltipPosition(candidate, anchorRect, tooltipRect, viewportWidth, viewportHeight);
+ const score = this._computeOverflowScore(position, tooltipRect, viewportWidth, viewportHeight);
+ if (score < bestScore) {
+ bestScore = score;
+ bestPlacement = candidate;
+ }
+ }
+
+ return bestPlacement;
+ }
+
+ _computeTooltipPosition(placement, anchorRect, tooltipRect, viewportWidth, viewportHeight) {
+ const gap = 8;
+ const minInset = 16;
+ const maxLeft = Math.max(minInset, viewportWidth - tooltipRect.width - minInset);
+ const maxTop = Math.max(minInset, viewportHeight - tooltipRect.height - minInset);
+
+ if (placement === "left") {
+ return {
+ left: Math.max(minInset, anchorRect.left - tooltipRect.width - gap),
+ top: this._clamp(anchorRect.top + (anchorRect.height - tooltipRect.height) / 2, minInset, maxTop)
+ };
+ }
+ if (placement === "right") {
+ return {
+ left: Math.min(maxLeft, anchorRect.right + gap),
+ top: this._clamp(anchorRect.top + (anchorRect.height - tooltipRect.height) / 2, minInset, maxTop)
+ };
+ }
+ if (placement === "top") {
+ return {
+ left: this._clamp(anchorRect.left + (anchorRect.width - tooltipRect.width) / 2, minInset, maxLeft),
+ top: Math.max(minInset, anchorRect.top - tooltipRect.height - gap)
+ };
+ }
+ return {
+ left: this._clamp(anchorRect.left + (anchorRect.width - tooltipRect.width) / 2, minInset, maxLeft),
+ top: Math.min(maxTop, anchorRect.bottom + gap)
+ };
+ }
+
+ _computeOverflowScore(position, tooltipRect, viewportWidth, viewportHeight) {
+ const overflowLeft = Math.max(0, 16 - position.left);
+ const overflowRight = Math.max(0, position.left + tooltipRect.width + 16 - viewportWidth);
+ const overflowTop = Math.max(0, 16 - position.top);
+ const overflowBottom = Math.max(0, position.top + tooltipRect.height + 16 - viewportHeight);
+ return overflowLeft + overflowRight + overflowTop + overflowBottom;
+ }
+
+ _clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+ }
+}
+
+class LhtTextFieldHelp extends HTMLElement {
+ connectedCallback() {
+ if (this.dataset.initialized === "true") return;
+ this.dataset.initialized = "true";
+
+ const fieldId = (this.getAttribute("field-id") || "").trim();
+ if (!fieldId) return;
+
+ const hasMdOutlinedTextField = !!(window.customElements && window.customElements.get("md-outlined-text-field"));
+ const isTextarea = (this.getAttribute("type") || "").trim().toLowerCase() === "textarea" || this.hasAttribute("rows");
+ const isClearable = this.hasAttribute("clearable") && !isTextarea;
+ const field = hasMdOutlinedTextField
+ ? document.createElement("md-outlined-text-field")
+ : document.createElement(isTextarea ? "textarea" : "input");
+ field.id = fieldId;
+ this._isFallbackTextField = !hasMdOutlinedTextField;
+ let fallbackWrapper = null;
+ let fallbackSupportingText = null;
+ let controlWrap = null;
+ let clearButton = null;
+
+ const label = (this.getAttribute("label") || "").trim();
+ if (label) {
+ if (this._isFallbackTextField) {
+ field.setAttribute("aria-label", label);
+ } else {
+ field.setAttribute("label", label);
+ }
+ }
+
+ const placeholder = this.getAttribute("placeholder");
+ if (placeholder != null) field.setAttribute("placeholder", placeholder);
+
+ const autocomplete = this.getAttribute("autocomplete");
+ if (autocomplete != null) field.setAttribute("autocomplete", autocomplete);
+
+ const type = this.getAttribute("type");
+ if (isTextarea && !this._isFallbackTextField) {
+ field.setAttribute("type", "textarea");
+ } else if (type != null && !isTextarea) {
+ field.setAttribute("type", type);
+ }
+
+ const min = this.getAttribute("min");
+ if (min != null) field.setAttribute("min", min);
+
+ const max = this.getAttribute("max");
+ if (max != null) field.setAttribute("max", max);
+
+ const step = this.getAttribute("step");
+ if (step != null) field.setAttribute("step", step);
+
+ const rows = this.getAttribute("rows");
+ if (rows != null) field.setAttribute("rows", rows);
+
+ const value = this.getAttribute("value");
+ if (value != null) {
+ if (this._isFallbackTextField) {
+ field.value = value;
+ } else {
+ field.setAttribute("value", value);
+ }
+ }
+
+ const fieldClass = (this.getAttribute("field-class") || "").trim();
+ if (fieldClass) {
+ fieldClass.split(/\s+/).filter(Boolean).forEach((name) => field.classList.add(name));
+ }
+ field.classList.add(this._isFallbackTextField ? "lht-text-field-help__fallback" : "md-outlined-field");
+ if (isClearable) {
+ field.classList.add("lht-text-field-help__field--clearable");
+ }
+
+ if (this.hasAttribute("required")) {
+ field.required = true;
+ field.setAttribute("required", "");
+ }
+ if (this.hasAttribute("readonly")) {
+ if (this._isFallbackTextField) {
+ field.readOnly = true;
+ } else {
+ field.setAttribute("readonly", "");
+ }
+ }
+ if (this.hasAttribute("disabled")) field.disabled = true;
+
+ if (isClearable) {
+ controlWrap = document.createElement("div");
+ controlWrap.className = "lht-text-field-help__control-wrap";
+
+ clearButton = document.createElement("button");
+ clearButton.type = "button";
+ clearButton.className = "lht-text-field-help__clear-button";
+ clearButton.hidden = true;
+ clearButton.setAttribute("aria-label", `${label || fieldId}をクリア`);
+
+ const syncClearButtonVisibility = () => {
+ const currentValue = String(field.value || "");
+ clearButton.hidden = currentValue.length === 0 || !!field.disabled;
+ };
+
+ clearButton.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ field.value = "";
+ if (!this._isFallbackTextField) {
+ field.setAttribute("value", "");
+ }
+ syncClearButtonVisibility();
+ field.focus();
+ field.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
+ field.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
+ });
+
+ field.addEventListener("input", syncClearButtonVisibility);
+ field.addEventListener("change", syncClearButtonVisibility);
+ queueMicrotask(syncClearButtonVisibility);
+ }
+
+ const helpText = (this.getAttribute("help-text") || "").trim();
+ const hideDelayMsAttr = this.getAttribute("hide-delay-ms");
+ const hideDelayMsRaw = hideDelayMsAttr == null ? Number.NaN : Number(hideDelayMsAttr);
+ const hideDelayMs = Number.isFinite(hideDelayMsRaw) && hideDelayMsRaw >= 0 ? hideDelayMsRaw : 160;
+ if (helpText) {
+ if (this._isFallbackTextField) {
+ field.title = helpText;
+ fallbackWrapper = document.createElement("div");
+ fallbackWrapper.className = "lht-text-field-help__fallback-wrap";
+ fallbackSupportingText = document.createElement("div");
+ fallbackSupportingText.className = "lht-text-field-help__supporting-text";
+ fallbackSupportingText.textContent = helpText;
+ fallbackSupportingText.hidden = true;
+ fallbackSupportingText.setAttribute("aria-hidden", "true");
+ fallbackSupportingText.setAttribute("aria-live", "polite");
+ } else {
+ }
+ let blurHideTimer = null;
+ field.addEventListener("focus", () => {
+ if (blurHideTimer) {
+ clearTimeout(blurHideTimer);
+ blurHideTimer = null;
+ }
+ if (this._isFallbackTextField) {
+ fallbackSupportingText.hidden = false;
+ fallbackSupportingText.setAttribute("aria-hidden", "false");
+ } else {
+ field.supportingText = helpText;
+ }
+ });
+ field.addEventListener("blur", () => {
+ if (blurHideTimer) {
+ clearTimeout(blurHideTimer);
+ }
+ blurHideTimer = setTimeout(() => {
+ if (this._isFallbackTextField) {
+ fallbackSupportingText.hidden = true;
+ fallbackSupportingText.setAttribute("aria-hidden", "true");
+ } else {
+ field.supportingText = "";
+ }
+ blurHideTimer = null;
+ }, hideDelayMs);
+ });
+ }
+
+ this.textContent = "";
+ if (fallbackWrapper) {
+ if (controlWrap) {
+ controlWrap.appendChild(field);
+ controlWrap.appendChild(clearButton);
+ fallbackWrapper.appendChild(controlWrap);
+ } else {
+ fallbackWrapper.appendChild(field);
+ }
+ fallbackWrapper.appendChild(fallbackSupportingText);
+ this.appendChild(fallbackWrapper);
+ return;
+ }
+ if (controlWrap) {
+ controlWrap.appendChild(field);
+ controlWrap.appendChild(clearButton);
+ this.appendChild(controlWrap);
+ return;
+ }
+ this.appendChild(field);
+ }
+}
+
+class LhtSelectHelp extends HTMLElement {
+ connectedCallback() {
+ if (this.dataset.initialized === "true") return;
+ this.dataset.initialized = "true";
+
+ const fieldId = (this.getAttribute("field-id") || "").trim();
+ if (!fieldId) return;
+ const hasDeclarativeOptions = this._hasDeclarativeOptions();
+
+ const hasMdOutlinedSelect = !!(window.customElements && window.customElements.get("md-outlined-select"));
+ const field = document.createElement(hasMdOutlinedSelect ? "md-outlined-select" : "select");
+ field.id = fieldId;
+ this._lhtField = field;
+ this._isFallbackSelect = !hasMdOutlinedSelect;
+ let fallbackWrapper = null;
+ let fallbackSupportingText = null;
+
+ const label = (this.getAttribute("label") || "").trim();
+ if (label) {
+ if (this._isFallbackSelect) {
+ field.setAttribute("aria-label", label);
+ } else {
+ field.setAttribute("label", label);
+ }
+ }
+
+ const value = this.getAttribute("value");
+ if (value != null) field.value = value;
+
+ const fieldClass = (this.getAttribute("field-class") || "").trim();
+ if (fieldClass) {
+ fieldClass.split(/\s+/).filter(Boolean).forEach((name) => field.classList.add(name));
+ }
+ if (this._isFallbackSelect) {
+ field.classList.add("lht-select-help__fallback");
+ } else {
+ field.classList.add("md-outlined-field");
+ }
+
+ if (this.hasAttribute("required")) {
+ field.required = true;
+ field.setAttribute("required", "");
+ }
+ if (this.hasAttribute("disabled")) field.disabled = true;
+
+ const helpText = (this.getAttribute("help-text") || "").trim();
+ const hideDelayMsAttr = this.getAttribute("hide-delay-ms");
+ const hideDelayMsRaw = hideDelayMsAttr == null ? Number.NaN : Number(hideDelayMsAttr);
+ const hideDelayMs = Number.isFinite(hideDelayMsRaw) && hideDelayMsRaw >= 0 ? hideDelayMsRaw : 160;
+ if (helpText) {
+ if (this._isFallbackSelect) {
+ field.title = helpText;
+ fallbackWrapper = document.createElement("div");
+ fallbackWrapper.className = "lht-select-help__fallback-wrap";
+ fallbackSupportingText = document.createElement("div");
+ fallbackSupportingText.className = "lht-select-help__supporting-text";
+ fallbackSupportingText.textContent = helpText;
+ fallbackSupportingText.hidden = true;
+ fallbackSupportingText.setAttribute("aria-hidden", "true");
+ fallbackSupportingText.setAttribute("aria-live", "polite");
+ } else {
+ }
+ let blurHideTimer = null;
+ field.addEventListener("focus", () => {
+ if (blurHideTimer) {
+ clearTimeout(blurHideTimer);
+ blurHideTimer = null;
+ }
+ if (this._isFallbackSelect) {
+ fallbackSupportingText.hidden = false;
+ fallbackSupportingText.setAttribute("aria-hidden", "false");
+ } else {
+ field.supportingText = helpText;
+ }
+ });
+ field.addEventListener("blur", () => {
+ if (blurHideTimer) {
+ clearTimeout(blurHideTimer);
+ }
+ blurHideTimer = setTimeout(() => {
+ if (this._isFallbackSelect) {
+ fallbackSupportingText.hidden = true;
+ fallbackSupportingText.setAttribute("aria-hidden", "true");
+ } else {
+ field.supportingText = "";
+ }
+ blurHideTimer = null;
+ }, hideDelayMs);
+ });
+ }
+
+ if (fallbackWrapper) {
+ fallbackWrapper.appendChild(field);
+ fallbackWrapper.appendChild(fallbackSupportingText);
+ this.appendChild(fallbackWrapper);
+ } else {
+ this.appendChild(field);
+ }
+ this.hydrateOptions();
+
+ if (!hasDeclarativeOptions) {
+ this._optionsObserver = new MutationObserver(() => {
+ this.hydrateOptions();
+ });
+ this._optionsObserver.observe(this, { childList: true, subtree: true });
+ requestAnimationFrame(() => this.hydrateOptions());
+ }
+ }
+
+ disconnectedCallback() {
+ if (this._optionsObserver) {
+ this._optionsObserver.disconnect();
+ this._optionsObserver = null;
+ }
+ }
+
+ _hasDeclarativeOptions() {
+ return this.hasAttribute("options") || !!this.querySelector("script[type='application/json'][slot='options']");
+ }
+
+ _normalizeOptions(rawOptions) {
+ return rawOptions
+ .map((entry) => {
+ const value = String(entry?.value ?? entry?.label ?? "");
+ const label = String(entry?.label ?? entry?.text ?? entry?.value ?? "");
+ return {
+ value,
+ label,
+ selected: !!entry?.selected,
+ disabled: !!entry?.disabled
+ };
+ })
+ .filter((entry) => entry.value || entry.label);
+ }
+
+ _readDeclarativeOptions() {
+ const optionsJson = (this.getAttribute("options") || "").trim();
+ if (optionsJson) {
+ try {
+ const parsed = JSON.parse(optionsJson);
+ if (Array.isArray(parsed)) return this._normalizeOptions(parsed);
+ } catch (_) {
+ // JSON 不正時は次の入力ソースへフォールバック
+ }
+ }
+
+ const script = this.querySelector("script[type='application/json'][slot='options']");
+ if (script) {
+ try {
+ const parsed = JSON.parse(script.textContent || "[]");
+ if (Array.isArray(parsed)) return this._normalizeOptions(parsed);
+ } catch (_) {
+ // JSON 不正時は空扱い
+ }
+ return [];
+ }
+
+ return null;
+ }
+
+ _readChildOptionElements() {
+ const sourceOptions = Array.from(this.querySelectorAll("option"));
+ if (sourceOptions.length === 0) return [];
+ return sourceOptions.map((sourceOption) => ({
+ value: sourceOption.getAttribute("value") ?? sourceOption.textContent ?? "",
+ label: sourceOption.textContent ?? "",
+ selected: sourceOption.hasAttribute("selected"),
+ disabled: sourceOption.hasAttribute("disabled")
+ }));
+ }
+
+ _setFieldOptions(options) {
+ const field = this._lhtField;
+ if (!field) return;
+ const previousValue = field.value;
+ field.innerHTML = "";
+
+ for (const entry of options) {
+ if (this._isFallbackSelect) {
+ const option = document.createElement("option");
+ option.value = entry.value;
+ option.textContent = entry.label;
+ if (entry.disabled) option.disabled = true;
+ if (entry.selected) {
+ option.selected = true;
+ field.value = entry.value;
+ }
+ field.appendChild(option);
+ } else {
+ const option = document.createElement("md-select-option");
+ option.value = entry.value;
+ if (entry.disabled) option.disabled = true;
+ if (entry.selected) {
+ option.selected = true;
+ option.setAttribute("selected", "");
+ field.value = entry.value;
+ }
+ const headline = document.createElement("div");
+ headline.slot = "headline";
+ headline.textContent = entry.label;
+ option.appendChild(headline);
+ field.appendChild(option);
+ }
+ }
+
+ if (!field.value && previousValue) {
+ field.value = previousValue;
+ }
+ }
+
+ hydrateOptions() {
+ const declarativeOptions = this._readDeclarativeOptions();
+ if (Array.isArray(declarativeOptions)) {
+ this._setFieldOptions(declarativeOptions);
+ const jsonScript = this.querySelector("script[type='application/json'][slot='options']");
+ if (jsonScript) jsonScript.remove();
+ if (this._optionsObserver) {
+ this._optionsObserver.disconnect();
+ this._optionsObserver = null;
+ }
+ return;
+ }
+
+ const optionsFromChildren = this._readChildOptionElements();
+ if (optionsFromChildren.length === 0) return;
+ this._setFieldOptions(optionsFromChildren);
+ this.querySelectorAll("option").forEach((option) => option.remove());
+ if (this._optionsObserver) {
+ this._optionsObserver.disconnect();
+ this._optionsObserver = null;
+ }
+ }
+
+ setOptions(rawOptions, config = {}) {
+ const options = this._normalizeOptions(Array.isArray(rawOptions) ? rawOptions : []);
+ const preserveValue = config?.preserveValue !== false;
+ const field = this._lhtField;
+ const previousValue = preserveValue ? (field?.value || "") : "";
+
+ if (field) {
+ field.innerHTML = "";
+ }
+ this.querySelectorAll("option, script[type='application/json'][slot='options']").forEach((node) => node.remove());
+ if (this._optionsObserver) {
+ this._optionsObserver.disconnect();
+ this._optionsObserver = null;
+ }
+
+ const nextOptions = preserveValue && previousValue
+ ? options.map((entry) => ({
+ ...entry,
+ selected: entry.value === previousValue || entry.selected
+ }))
+ : options;
+
+ this._setFieldOptions(nextOptions);
+ if (field && previousValue && !nextOptions.some((entry) => entry.value === previousValue)) {
+ field.value = "";
+ }
+ }
+
+ getValue() {
+ return this._lhtField?.value ?? "";
+ }
+
+ setValue(value) {
+ if (!this._lhtField) return;
+ this._lhtField.value = value == null ? "" : String(value);
+ }
+}
+
+class LhtLoadingOverlay extends HTMLElement {
+ static get observedAttributes() {
+ return ["active", "text"];
+ }
+
+ connectedCallback() {
+ if (this.dataset.initialized === "true") return;
+ this.dataset.initialized = "true";
+
+ this.setAttribute("role", "status");
+ this.setAttribute("aria-live", "polite");
+
+ const text = (this.getAttribute("text") || "Loading...").trim();
+
+ this.textContent = "";
+
+ const dialog = document.createElement("div");
+ dialog.className = "lht-loading-overlay__dialog";
+
+ const spinner = document.createElement("div");
+ spinner.className = "lht-loading-overlay__spinner";
+ spinner.setAttribute("aria-hidden", "true");
+
+ const message = document.createElement("p");
+ message.className = "lht-loading-overlay__text";
+ message.textContent = text;
+
+ dialog.appendChild(spinner);
+ dialog.appendChild(message);
+ this.appendChild(dialog);
+
+ this._messageNode = message;
+ this.setActive(this.hasAttribute("active"));
+ }
+
+ attributeChangedCallback(name, _oldValue, newValue) {
+ if (name === "text" && this._messageNode) {
+ const text = (newValue || "Loading...").trim();
+ this._messageNode.textContent = text || "Loading...";
+ return;
+ }
+ if (name === "active") {
+ this.setActive(newValue !== null);
+ }
+ }
+
+ isActive() {
+ return this.hasAttribute("active");
+ }
+
+ setActive(inProgress) {
+ const next = !!inProgress;
+ this.toggleAttribute("active", next);
+ this.setAttribute("aria-hidden", next ? "false" : "true");
+
+ const busyTargetId = (this.getAttribute("busy-target-id") || "").trim();
+ if (busyTargetId) {
+ const target = document.getElementById(busyTargetId);
+ if (target) target.setAttribute("aria-busy", next ? "true" : "false");
+ }
+
+ const disableTargetIds = (this.getAttribute("disable-target-ids") || "")
+ .split(",")
+ .map((id) => id.trim())
+ .filter(Boolean);
+ for (const id of disableTargetIds) {
+ const element = document.getElementById(id);
+ if (!element || !("disabled" in element)) continue;
+ element.disabled = next;
+ }
+ }
+
+ waitForNextPaint() {
+ return new Promise((resolve) => {
+ requestAnimationFrame(() => resolve());
+ });
+ }
+}
+
+class LhtToast extends HTMLElement {
+ static get observedAttributes() {
+ return ["text", "active"];
+ }
+
+ connectedCallback() {
+ if (this.dataset.initialized === "true") return;
+ this.dataset.initialized = "true";
+
+ this.setAttribute("role", "status");
+ this.setAttribute("aria-live", "polite");
+ this.setAttribute("aria-atomic", "true");
+
+ const initialText = (this.getAttribute("text") || this.textContent || "完了").trim();
+ const text = initialText || "完了";
+
+ this.textContent = "";
+
+ const body = document.createElement("div");
+ body.className = "lht-toast__body";
+ body.textContent = text;
+ this.appendChild(body);
+ this._body = body;
+
+ this.setActive(this.hasAttribute("active"));
+
+ if (typeof window.showToast !== "function") {
+ window.showToast = (message, durationMs) => {
+ this.show(message, durationMs);
+ };
+ }
+ }
+
+ disconnectedCallback() {
+ if (this._hideTimer) {
+ clearTimeout(this._hideTimer);
+ this._hideTimer = null;
+ }
+ }
+
+ attributeChangedCallback(name, _oldValue, newValue) {
+ if (name === "text") {
+ if (!this._body) return;
+ const text = (newValue || "").trim();
+ if (text) this._body.textContent = text;
+ return;
+ }
+ if (name === "active") {
+ this.setActive(newValue !== null);
+ }
+ }
+
+ show(message, durationMs) {
+ if (this._hideTimer) {
+ clearTimeout(this._hideTimer);
+ this._hideTimer = null;
+ }
+
+ const defaultDurationMs = Number(this.getAttribute("duration-ms"));
+ const fallbackDuration = Number.isFinite(defaultDurationMs) && defaultDurationMs > 0 ? defaultDurationMs : 1600;
+ const nextDuration = Number(durationMs);
+ const hideAfterMs = Number.isFinite(nextDuration) && nextDuration > 0 ? nextDuration : fallbackDuration;
+
+ const text = (message || this.getAttribute("text") || this._body?.textContent || "完了").trim();
+ if (this._body) this._body.textContent = text || "完了";
+
+ this.setActive(true);
+
+ this._hideTimer = setTimeout(() => {
+ this.hide();
+ }, hideAfterMs);
+ }
+
+ hide() {
+ if (this._hideTimer) {
+ clearTimeout(this._hideTimer);
+ this._hideTimer = null;
+ }
+ this.setActive(false);
+ }
+
+ setActive(active) {
+ const next = !!active;
+ this.toggleAttribute("active", next);
+ this.setAttribute("data-visible", next ? "true" : "false");
+ this.setAttribute("aria-hidden", next ? "false" : "true");
+ }
+}
+
+class LhtErrorAlert extends HTMLElement {
+ static get observedAttributes() {
+ return ["text", "active", "variant"];
+ }
+
+ connectedCallback() {
+ if (this.dataset.initialized === "true") return;
+ this.dataset.initialized = "true";
+
+ this.setAttribute("aria-atomic", "true");
+
+ const initialText = (this.getAttribute("text") || this.textContent || "").trim();
+
+ this.textContent = "";
+
+ const body = document.createElement("p");
+ body.className = "lht-error-alert__body";
+ body.textContent = initialText;
+ this.appendChild(body);
+ this._body = body;
+
+ this._syncVariant();
+ this.setActive(this.hasAttribute("active"));
+ }
+
+ attributeChangedCallback(name, _oldValue, newValue) {
+ if (name === "text") {
+ const text = (newValue || "").trim();
+ if (this._body) this._body.textContent = text;
+ return;
+ }
+ if (name === "variant") {
+ this._syncVariant();
+ return;
+ }
+ if (name === "active") {
+ this.setActive(newValue !== null);
+ }
+ }
+
+ isVisible() {
+ return this.getAttribute("data-visible") === "true";
+ }
+
+ show(message) {
+ const text = (message || this.getAttribute("text") || "").trim();
+ if (this._body) this._body.textContent = text;
+ this.setActive(text.length > 0);
+ }
+
+ clear() {
+ if (this._body) this._body.textContent = "";
+ this.hide();
+ }
+
+ hide() {
+ this.setActive(false);
+ }
+
+ setActive(active) {
+ const next = !!active;
+ this.toggleAttribute("active", next);
+ this.setAttribute("data-visible", next ? "true" : "false");
+ this.setAttribute("aria-hidden", next ? "false" : "true");
+ }
+
+ _normalizeVariant(value) {
+ const normalized = (value || "error").trim().toLowerCase();
+ return ["error", "warning", "info"].includes(normalized) ? normalized : "error";
+ }
+
+ _syncVariant() {
+ const variant = this._normalizeVariant(this.getAttribute("variant"));
+ if (this.getAttribute("variant") !== variant) {
+ this.setAttribute("variant", variant);
+ return;
+ }
+ this.setAttribute("data-variant", variant);
+
+ if (variant === "error") {
+ this.setAttribute("role", "alert");
+ this.setAttribute("aria-live", "assertive");
+ return;
+ }
+
+ this.setAttribute("role", "status");
+ this.setAttribute("aria-live", "polite");
+ }
+}
+
+class LhtInputModeToggle extends HTMLElement {
+ connectedCallback() {
+ if (this.dataset.initialized === "true") return;
+ this.dataset.initialized = "true";
+
+ const groupLabel = (this.getAttribute("group-label") || "入力方式").trim();
+ const groupName = (this.getAttribute("name") || "inputMode").trim();
+ const fileId = (this.getAttribute("file-id") || "inputModeFile").trim();
+ const sourceId = (this.getAttribute("source-id") || "inputModeSource").trim();
+ const fileLabel = (this.getAttribute("file-label") || "ファイル読込").trim();
+ const sourceLabel = (this.getAttribute("source-label") || "ソースコード入力").trim();
+ const defaultMode = (this.getAttribute("default-mode") || "file").trim().toLowerCase();
+ const disabled = this.hasAttribute("disabled");
+
+ this.textContent = "";
+ this.classList.add("lht-input-mode-toggle");
+
+ const group = document.createElement("div");
+ group.className = "lht-input-mode-toggle__group";
+ group.setAttribute("role", "radiogroup");
+ group.setAttribute("aria-label", groupLabel);
+
+ const fileOption = this.createOption({
+ id: fileId,
+ name: groupName,
+ label: fileLabel,
+ value: "file",
+ checked: defaultMode !== "source",
+ disabled
+ });
+
+ const sourceOption = this.createOption({
+ id: sourceId,
+ name: groupName,
+ label: sourceLabel,
+ value: "source",
+ checked: defaultMode === "source",
+ disabled
+ });
+
+ group.appendChild(fileOption.label);
+ group.appendChild(sourceOption.label);
+ this.appendChild(group);
+
+ this._fileRadio = fileOption.input;
+ this._sourceRadio = sourceOption.input;
+
+ const onChange = () => this.applyModeUi();
+ this._fileRadio.addEventListener("change", onChange);
+ this._sourceRadio.addEventListener("change", onChange);
+
+ this.applyModeUi();
+ }
+
+ createOption({ id, name, label, value, checked, disabled }) {
+ const optionLabel = document.createElement("label");
+ optionLabel.className = "lht-input-mode-toggle__option";
+
+ const input = document.createElement("input");
+ input.id = id;
+ input.type = "radio";
+ input.name = name;
+ input.value = value;
+ input.checked = !!checked;
+ input.disabled = !!disabled;
+
+ const text = document.createElement("span");
+ text.textContent = label;
+
+ optionLabel.appendChild(input);
+ optionLabel.appendChild(text);
+ return { label: optionLabel, input };
+ }
+
+ getMode() {
+ return this._sourceRadio?.checked ? "source" : "file";
+ }
+
+ setMode(mode) {
+ const normalized = (mode || "").trim().toLowerCase();
+ const sourceMode = normalized === "source";
+ if (this._sourceRadio) this._sourceRadio.checked = sourceMode;
+ if (this._fileRadio) this._fileRadio.checked = !sourceMode;
+ this.applyModeUi();
+ }
+
+ applyModeUi() {
+ const sourceMode = this.getMode() === "source";
+ const sourceTargetId = (this.getAttribute("source-target-id") || "").trim();
+ const fileTargetId = (this.getAttribute("file-target-id") || "").trim();
+
+ if (sourceTargetId) {
+ const sourceTarget = document.getElementById(sourceTargetId);
+ if (sourceTarget) sourceTarget.classList.toggle("md-hidden", !sourceMode);
+ }
+ if (fileTargetId) {
+ const fileTarget = document.getElementById(fileTargetId);
+ if (fileTarget) fileTarget.classList.toggle("md-hidden", sourceMode);
+ }
+
+ const onChangeFnName = (this.getAttribute("on-change") || "").trim();
+ if (onChangeFnName) {
+ const fn = window[onChangeFnName];
+ if (typeof fn === "function") fn(this.getMode());
+ }
+
+ this.dispatchEvent(new CustomEvent("input-mode-change", {
+ detail: { mode: this.getMode() },
+ bubbles: true
+ }));
+ }
+}
+
+class LhtPreviewOutput extends HTMLElement {
+ connectedCallback() {
+ if (this.dataset.initialized === "true") return;
+ this.dataset.initialized = "true";
+
+ const previewId = (this.getAttribute("preview-id") || "previewText").trim();
+ const copyButtonId = (this.getAttribute("copy-button-id") || "copyBtn").trim();
+ const copyTargetId = (this.getAttribute("copy-target-id") || previewId).trim();
+ const placeholder = this.getAttribute("placeholder") || "未変換";
+ const copyLabel = (this.getAttribute("copy-label") || "コピー").trim();
+ const copyAriaLabel = (this.getAttribute("copy-aria-label") || `${copyLabel}をコピー`).trim();
+ const previewTag = (this.getAttribute("preview-tag") || "div").trim().toLowerCase();
+ const showCopyButton = !this.hasAttribute("no-copy");
+
+ this.textContent = "";
+ this.classList.add("lht-preview-output");
+
+ const root = document.createElement("div");
+ root.className = "lht-preview-output__root";
+
+ const preview = document.createElement(previewTag === "pre" ? "pre" : "div");
+ preview.id = previewId;
+ preview.className = "lht-preview-output__preview";
+ preview.textContent = placeholder;
+ root.appendChild(preview);
+ this._previewNode = preview;
+
+ if (showCopyButton) {
+ const copyButton = document.createElement("button");
+ copyButton.type = "button";
+ copyButton.id = copyButtonId;
+ copyButton.className = "lht-preview-output__copy-button";
+ copyButton.setAttribute("aria-label", copyAriaLabel);
+ copyButton.innerHTML = ' ';
+ copyButton.addEventListener("click", () => this.copy(copyTargetId));
+ root.appendChild(copyButton);
+ this._copyButton = copyButton;
+ }
+
+ this.appendChild(root);
+ }
+
+ getText() {
+ return (this._previewNode?.textContent || "").trim();
+ }
+
+ setText(text) {
+ if (!this._previewNode) return;
+ this._previewNode.textContent = text == null ? "" : String(text);
+ }
+
+ clear() {
+ if (!this._previewNode) return;
+ const placeholder = this.getAttribute("placeholder") || "";
+ this._previewNode.textContent = placeholder;
+ }
+
+ async copy(targetId) {
+ const target = document.getElementById(targetId);
+ const text = (target?.textContent || "").trim();
+ if (!text) return;
+ try {
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
+ await navigator.clipboard.writeText(text);
+ } else {
+ const temp = document.createElement("textarea");
+ temp.value = text;
+ document.body.appendChild(temp);
+ temp.select();
+ document.execCommand("copy");
+ document.body.removeChild(temp);
+ }
+ if (typeof window.showToast === "function") {
+ window.showToast("コピーしました");
+ }
+ } catch (_) {
+ // コピー不可環境では失敗を握りつぶす
+ }
+ }
+}
+
+class LhtFileSelect extends HTMLElement {
+ connectedCallback() {
+ if (this.dataset.initialized === "true") return;
+ this.dataset.initialized = "true";
+
+ const inputId = (this.getAttribute("input-id") || "fileInput").trim();
+ const buttonId = (this.getAttribute("button-id") || "fileSelectBtn").trim();
+ const fileNameId = (this.getAttribute("file-name-id") || "fileNameText").trim();
+ const accept = (this.getAttribute("accept") || "").trim();
+ const buttonLabel = (this.getAttribute("button-label") || "ファイルを選択").trim();
+ const placeholder = (this.getAttribute("placeholder") || "未選択").trim();
+ const showFileName = this.hasAttribute("show-file-name");
+ const autoOpenValue = (this.getAttribute("auto-open") || "").trim().toLowerCase();
+ const autoOpen = autoOpenValue !== "false";
+
+ this.textContent = "";
+
+ const root = document.createElement("div");
+ root.className = "lht-file-select";
+
+ const hasMdFilledButton = !!(window.customElements && window.customElements.get("md-filled-button"));
+ const triggerButton = document.createElement(hasMdFilledButton ? "md-filled-button" : "button");
+ if (!hasMdFilledButton) {
+ triggerButton.type = "button";
+ }
+ triggerButton.id = buttonId;
+ triggerButton.className = `lht-file-select__button${hasMdFilledButton ? "" : " lht-file-select__button--fallback"}`;
+
+ const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ icon.setAttribute("slot", "icon");
+ icon.setAttribute("aria-hidden", "true");
+ icon.setAttribute("viewBox", "0 0 24 24");
+ icon.setAttribute("class", "lht-file-select__button-icon");
+ icon.setAttribute("fill", "none");
+ icon.setAttribute("stroke", "currentColor");
+ icon.setAttribute("stroke-width", "1.9");
+ icon.setAttribute("stroke-linecap", "round");
+ icon.setAttribute("stroke-linejoin", "round");
+ icon.innerHTML = ' ';
+
+ const labelNode = document.createElement("span");
+ labelNode.className = "lht-file-select__button-text";
+ labelNode.textContent = buttonLabel;
+ triggerButton.appendChild(icon);
+ triggerButton.appendChild(labelNode);
+
+ const input = document.createElement("input");
+ input.id = inputId;
+ input.type = "file";
+ input.className = "md-file";
+ input.hidden = true;
+ if (accept) input.setAttribute("accept", accept);
+ if (this.hasAttribute("multiple")) input.multiple = true;
+
+ const fileName = document.createElement("span");
+ fileName.id = fileNameId;
+ fileName.className = "lht-file-select__file-name";
+ fileName.textContent = placeholder;
+ if (!showFileName) fileName.hidden = true;
+
+ if (this.hasAttribute("disabled")) {
+ input.disabled = true;
+ triggerButton.disabled = true;
+ }
+
+ triggerButton.addEventListener("click", () => {
+ const beforeOpenEvent = new CustomEvent("lht-file-select:before-open", {
+ detail: {
+ inputId,
+ buttonId,
+ input,
+ triggerButton,
+ autoOpen
+ },
+ bubbles: true,
+ cancelable: true
+ });
+ const canAutoOpen = this.dispatchEvent(beforeOpenEvent);
+ if (autoOpen && canAutoOpen) {
+ input.click();
+ }
+ });
+ input.addEventListener("change", () => {
+ const names = Array.from(input.files || []).map((file) => file.name).filter(Boolean);
+ fileName.textContent = names.length > 0 ? names.join(", ") : placeholder;
+ this.dispatchEvent(new CustomEvent("lht-file-select:change", {
+ detail: {
+ files: Array.from(input.files || []),
+ names,
+ input,
+ fileName
+ },
+ bubbles: true
+ }));
+ });
+
+ root.appendChild(triggerButton);
+ root.appendChild(fileName);
+ this.appendChild(root);
+ this.appendChild(input);
+ }
+}
+
+class LhtSwitchHelp extends HTMLElement {
+ connectedCallback() {
+ if (this.dataset.initialized === "true") return;
+ this.dataset.initialized = "true";
+
+ const switchId = (this.getAttribute("switch-id") || "").trim();
+ if (!switchId) return;
+ const labelText = (this.getAttribute("label") || "").trim();
+ const helpLabel = (this.getAttribute("help-label") || `${labelText}の説明`).trim();
+ const helpContentHtml = this.innerHTML.trim();
+ const onChangeFnName = (this.getAttribute("on-change") || "").trim();
+ const isChecked = this.hasAttribute("checked");
+ const isHelpWide = this.hasAttribute("help-wide");
+
+ this.textContent = "";
+
+ const label = document.createElement("label");
+ label.className = "md-switch-label";
+
+ const hasMdSwitch = !!(window.customElements && window.customElements.get("md-switch"));
+ const switchControl = hasMdSwitch
+ ? this._createMaterialSwitch(switchId, isChecked)
+ : this._createFallbackSwitch(switchId, isChecked);
+
+ if (onChangeFnName) {
+ switchControl.control.addEventListener("change", () => {
+ const fn = window[onChangeFnName];
+ if (typeof fn === "function") {
+ fn();
+ }
+ });
+ }
+ label.appendChild(switchControl.node);
+
+ const labelSpan = document.createElement("span");
+ labelSpan.textContent = labelText;
+ label.appendChild(labelSpan);
+
+ if (helpContentHtml) {
+ const help = document.createElement("lht-help-tooltip");
+ help.setAttribute("label", helpLabel);
+ if (isHelpWide) {
+ help.setAttribute("wide", "");
+ }
+ help.innerHTML = helpContentHtml;
+ label.appendChild(help);
+ }
+
+ this.appendChild(label);
+ }
+
+ _createMaterialSwitch(switchId, isChecked) {
+ const mdSwitch = document.createElement("md-switch");
+ mdSwitch.id = switchId;
+ Object.defineProperty(mdSwitch, "checked", {
+ get() {
+ return !!mdSwitch.selected;
+ },
+ set(value) {
+ mdSwitch.selected = !!value;
+ }
+ });
+ if (isChecked) {
+ mdSwitch.selected = true;
+ mdSwitch.setAttribute("selected", "");
+ }
+ return { node: mdSwitch, control: mdSwitch };
+ }
+
+ _createFallbackSwitch(switchId, isChecked) {
+ const input = document.createElement("input");
+ input.id = switchId;
+ input.type = "checkbox";
+ input.className = "md-switch-input";
+ input.checked = isChecked;
+ input.setAttribute("role", "switch");
+ input.setAttribute("aria-checked", isChecked ? "true" : "false");
+
+ input.addEventListener("change", () => {
+ input.setAttribute("aria-checked", input.checked ? "true" : "false");
+ });
+
+ const visual = document.createElement("span");
+ visual.className = "md-switch";
+ visual.setAttribute("aria-hidden", "true");
+
+ const fragment = document.createDocumentFragment();
+ fragment.appendChild(input);
+ fragment.appendChild(visual);
+ return { node: fragment, control: input };
+ }
+}
+
+class LhtCommandBlock extends HTMLElement {
+ connectedCallback() {
+ if (this.dataset.initialized === "true") return;
+ this.dataset.initialized = "true";
+
+ const commandId = (this.getAttribute("command-id") || "").trim();
+ if (!commandId) return;
+ const copyButtons = (this.getAttribute("copy-buttons") || "single").trim().toLowerCase();
+ const isDual = copyButtons === "dual";
+
+ this.textContent = "";
+
+ const block = document.createElement("div");
+ block.className = "md-code-block";
+
+ const code = document.createElement("code");
+ code.id = commandId;
+ code.className = `md-code${isDual ? " md-code--dual-copy" : ""}`;
+ block.appendChild(code);
+
+ const topCopyButton = this.createCopyButton("コピー", () => this.copyFromCommand(commandId));
+ block.appendChild(topCopyButton);
+
+ if (isDual) {
+ const bottomCopyButton = this.createCopyButton("コピー(右下)", () => this.copyFromCommand(commandId));
+ bottomCopyButton.classList.add("md-copy-button--bottom-right");
+ block.appendChild(bottomCopyButton);
+ }
+
+ this.appendChild(block);
+ }
+
+ createCopyButton(label, onClick) {
+ const hasMdIconButton = !!(window.customElements && window.customElements.get("md-icon-button"));
+ const button = document.createElement(hasMdIconButton ? "md-icon-button" : "button");
+ button.className = `md-copy-button md-copy-button--surface${hasMdIconButton ? "" : " md-copy-button--fallback"}`;
+ if (!hasMdIconButton) {
+ button.type = "button";
+ }
+ button.setAttribute("aria-label", label);
+ button.innerHTML = ' ';
+ button.addEventListener("click", onClick);
+ return button;
+ }
+
+ async copyFromCommand(commandId) {
+ const commandElement = document.getElementById(commandId);
+ if (!commandElement) return;
+ const text = (commandElement.textContent || "").trim();
+ if (!text) return;
+ try {
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
+ await navigator.clipboard.writeText(text);
+ } else {
+ const temp = document.createElement("textarea");
+ temp.value = text;
+ document.body.appendChild(temp);
+ temp.select();
+ document.execCommand("copy");
+ document.body.removeChild(temp);
+ }
+ if (typeof window.showToast === "function") {
+ window.showToast("コピーしました");
+ }
+ } catch (_) {
+ // Clipboard API 利用不可環境では失敗を無視
+ }
+ }
+}
+
+class LhtIndexCardLink extends HTMLElement {
+ connectedCallback() {
+ if (this.dataset.initialized === "true") return;
+ this.dataset.initialized = "true";
+
+ const href = (this.getAttribute("href") || "").trim();
+ if (!href) return;
+
+ const title = (this.getAttribute("title") || "").trim();
+ const descAttr = (this.getAttribute("desc") || "").trim();
+ const iconAttr = (this.getAttribute("icon") || "").trim();
+ const target = (this.getAttribute("target") || "").trim();
+ const relAttr = (this.getAttribute("rel") || "").trim();
+ const variant = (this.getAttribute("variant") || "default").trim().toLowerCase();
+ const arrowMode = (this.getAttribute("arrow") || "auto").trim().toLowerCase();
+ const badgeText = (this.getAttribute("badge") || "").trim();
+ const descLines = (this.getAttribute("desc-lines") || "").trim();
+
+ if (!title || !descAttr) {
+ const missing = [];
+ if (!title) missing.push("title");
+ if (!descAttr) missing.push("desc");
+ // Fail fast for authoring mistakes in index cards.
+ console.warn(`[lht-index-card-link] Missing required attribute(s): ${missing.join(", ")}`, this);
+ return;
+ }
+
+ this.textContent = "";
+
+ const link = document.createElement("a");
+ link.href = href;
+ link.className = "md-link-card";
+ const isExternalHref = /^(https?:)?\/\//i.test(href);
+ const isExternal = variant === "external" || isExternalHref || target === "_blank";
+ const effectiveTarget = target || (isExternal ? "_blank" : "");
+ if (effectiveTarget) link.target = effectiveTarget;
+ if (effectiveTarget === "_blank") {
+ link.rel = relAttr || "noopener noreferrer";
+ } else if (relAttr) {
+ link.rel = relAttr;
+ }
+ if (variant === "simple") link.classList.add("lht-index-card-link--simple");
+ if (isExternal) link.classList.add("lht-index-card-link--external");
+
+ const head = document.createElement("div");
+ head.className = "md-card-head";
+
+ const h3 = document.createElement("h3");
+ h3.className = "md-card-title";
+ if (iconAttr) {
+ const iconContainer = document.createElement("span");
+ iconContainer.className = "lht-index-card-link__icon";
+ iconContainer.textContent = iconAttr;
+ h3.appendChild(iconContainer);
+ }
+ const titleContainer = document.createElement("span");
+ titleContainer.className = "lht-index-card-link__title";
+ titleContainer.textContent = title;
+ h3.appendChild(titleContainer);
+ if (badgeText) {
+ const badge = document.createElement("span");
+ badge.className = "lht-index-card-link__badge";
+ badge.textContent = badgeText;
+ h3.appendChild(badge);
+ }
+
+ const arrow = document.createElement("span");
+ arrow.className = "md-card-arrow";
+ const showArrow = arrowMode === "auto" ? variant !== "simple" : arrowMode !== "none";
+ arrow.textContent = isExternal ? "↗" : "→";
+ if (!showArrow) arrow.hidden = true;
+
+ const desc = document.createElement("p");
+ desc.className = "md-card-desc";
+ desc.textContent = descAttr;
+ if (descLines && /^\d+$/.test(descLines)) {
+ desc.classList.add("lht-index-card-link__desc--clamp");
+ desc.style.setProperty("--lht-desc-lines", descLines);
+ }
+
+ head.appendChild(h3);
+ head.appendChild(arrow);
+ link.appendChild(head);
+ link.appendChild(desc);
+ this.appendChild(link);
+ }
+}
+
+class LhtPageHero extends HTMLElement {
+ connectedCallback() {
+ if (this.dataset.initialized === "true") return;
+ this.dataset.initialized = "true";
+
+ const title = (this.getAttribute("title") || "").trim();
+ if (!title) return;
+ const subtitle = (this.getAttribute("subtitle") || "").trim();
+ const icon = (this.getAttribute("icon") || "").trim();
+ const helpLabel = (this.getAttribute("help-label") || "説明").trim();
+ const homeHref = (this.getAttribute("menu-home-href") || "../index.html").trim();
+ const homeLabel = (this.getAttribute("menu-home-label") || "トップへ戻る").trim();
+ const useWideHelp = this.hasAttribute("help-wide");
+ const showMenu = !this.hasAttribute("no-menu");
+ const helpHtml = this.innerHTML.trim();
+ const actionHref = (this.getAttribute("action-href") || "").trim();
+ const actionLabel = (this.getAttribute("action-label") || "").trim();
+ const actionAriaLabel = (this.getAttribute("action-aria-label") || actionLabel).trim();
+ const actionIconId = (this.getAttribute("action-icon-id") || "").trim();
+
+ this.textContent = "";
+ this.classList.add("lht-page-hero");
+
+ const topRow = document.createElement("div");
+ topRow.className = "lht-page-hero__title-row";
+
+ const titleMain = document.createElement("span");
+ titleMain.className = "lht-page-hero__title-main";
+
+ const heading = document.createElement("h1");
+ heading.className = "lht-page-hero__title";
+ if (icon) {
+ const iconNode = document.createElement("span");
+ iconNode.className = "lht-page-hero__icon";
+ iconNode.setAttribute("aria-hidden", "true");
+ iconNode.textContent = icon;
+ heading.appendChild(iconNode);
+ }
+ const titleNode = document.createElement("span");
+ titleNode.textContent = title;
+ heading.appendChild(titleNode);
+ titleMain.appendChild(heading);
+
+ if (helpHtml) {
+ const help = document.createElement("lht-help-tooltip");
+ help.setAttribute("label", helpLabel);
+ if (useWideHelp) {
+ help.setAttribute("wide", "");
+ }
+ help.innerHTML = helpHtml;
+ titleMain.appendChild(help);
+ }
+
+ if (actionHref && actionLabel) {
+ const actionLink = document.createElement("a");
+ actionLink.href = actionHref;
+ actionLink.className = "ms-hero-link";
+ actionLink.target = "_blank";
+ actionLink.rel = "noopener noreferrer";
+ if (actionAriaLabel) {
+ actionLink.setAttribute("aria-label", actionAriaLabel);
+ }
+ if (actionIconId) {
+ const iconSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ iconSvg.setAttribute("aria-hidden", "true");
+ iconSvg.setAttribute("viewBox", "0 0 16 16");
+ iconSvg.setAttribute("class", "ms-btn-icon");
+ iconSvg.setAttribute("fill", "currentColor");
+ const useNode = document.createElementNS("http://www.w3.org/2000/svg", "use");
+ useNode.setAttribute("href", `#${actionIconId}`);
+ useNode.setAttributeNS("http://www.w3.org/1999/xlink", "xlink:href", `#${actionIconId}`);
+ iconSvg.appendChild(useNode);
+ actionLink.appendChild(iconSvg);
+ }
+ const labelNode = document.createElement("span");
+ labelNode.textContent = actionLabel;
+ actionLink.appendChild(labelNode);
+ titleMain.appendChild(actionLink);
+ }
+
+ topRow.appendChild(titleMain);
+
+ if (showMenu) {
+ const actions = document.createElement("span");
+ actions.className = "lht-page-hero__actions";
+ const menu = document.createElement("lht-page-menu");
+ menu.setAttribute("home-href", homeHref);
+ menu.setAttribute("home-label", homeLabel);
+ actions.appendChild(menu);
+ topRow.appendChild(actions);
+ }
+
+ this.appendChild(topRow);
+
+ if (subtitle) {
+ const subtitleNode = document.createElement("div");
+ subtitleNode.className = "lht-page-hero__subtitle";
+ subtitleNode.textContent = subtitle;
+ this.appendChild(subtitleNode);
+ }
+ }
+}
+
+class LhtPageMenu extends HTMLElement {
+ connectedCallback() {
+ if (this.dataset.initialized === "true") return;
+ this.dataset.initialized = "true";
+
+ const homeHref = (this.getAttribute("home-href") || "../index.html").trim();
+ const homeLabel = (this.getAttribute("home-label") || "トップへ戻る").trim();
+
+ this.textContent = "";
+ this.classList.add("lht-page-menu");
+
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "md-menu-button md-icon-btn";
+ button.setAttribute("aria-label", "メニュー");
+ button.innerHTML = ' ';
+
+ const panel = document.createElement("div");
+ panel.className = "md-menu-panel md-hidden";
+
+ const link = document.createElement("a");
+ link.className = "md-menu-link";
+ link.href = homeHref;
+ link.textContent = homeLabel;
+ panel.appendChild(link);
+
+ button.addEventListener("click", () => {
+ panel.classList.toggle("md-hidden");
+ });
+
+ document.addEventListener("pointerdown", (event) => {
+ if (!this.contains(event.target)) {
+ panel.classList.add("md-hidden");
+ }
+ });
+
+ this.appendChild(button);
+ this.appendChild(panel);
+ }
+}
+
+if (!customElements.get("lht-help-tooltip")) {
+ customElements.define("lht-help-tooltip", LhtHelpTooltip);
+}
+if (!customElements.get("lht-text-field-help")) {
+ customElements.define("lht-text-field-help", LhtTextFieldHelp);
+}
+if (!customElements.get("lht-select-help")) {
+ customElements.define("lht-select-help", LhtSelectHelp);
+}
+if (!customElements.get("lht-file-select")) {
+ customElements.define("lht-file-select", LhtFileSelect);
+}
+if (!customElements.get("lht-loading-overlay")) {
+ customElements.define("lht-loading-overlay", LhtLoadingOverlay);
+}
+if (!customElements.get("lht-toast")) {
+ customElements.define("lht-toast", LhtToast);
+}
+if (!customElements.get("lht-error-alert")) {
+ customElements.define("lht-error-alert", LhtErrorAlert);
+}
+if (!customElements.get("lht-input-mode-toggle")) {
+ customElements.define("lht-input-mode-toggle", LhtInputModeToggle);
+}
+if (!customElements.get("lht-preview-output")) {
+ customElements.define("lht-preview-output", LhtPreviewOutput);
+}
+if (!customElements.get("lht-switch-help")) {
+ customElements.define("lht-switch-help", LhtSwitchHelp);
+}
+if (!customElements.get("lht-command-block")) {
+ customElements.define("lht-command-block", LhtCommandBlock);
+}
+if (!customElements.get("lht-index-card-link")) {
+ customElements.define("lht-index-card-link", LhtIndexCardLink);
+}
+if (!customElements.get("lht-page-hero")) {
+ customElements.define("lht-page-hero", LhtPageHero);
+}
+if (!customElements.get("lht-page-menu")) {
+ customElements.define("lht-page-menu", LhtPageMenu);
+}
diff --git a/lht-cmn/vendor/material-web-outlined-text-field.bundle.js b/lht-cmn/vendor/material-web-outlined-text-field.bundle.js
new file mode 100644
index 0000000..a63cb74
--- /dev/null
+++ b/lht-cmn/vendor/material-web-outlined-text-field.bundle.js
@@ -0,0 +1,459 @@
+(()=>{function s(o,e,t,r){var i=arguments.length,n=i<3?e:r===null?r=Object.getOwnPropertyDescriptor(e,t):r,a;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")n=Reflect.decorate(o,e,t,r);else for(var d=o.length-1;d>=0;d--)(a=o[d])&&(n=(i<3?a(n):i>3?a(e,t,n):a(e,t))||n);return i>3&&n&&Object.defineProperty(e,t,n),n}var E=o=>(e,t)=>{t!==void 0?t.addInitializer(()=>{customElements.define(o,e)}):customElements.define(o,e)};var Qe=globalThis,et=Qe.ShadowRoot&&(Qe.ShadyCSS===void 0||Qe.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,Bt=Symbol(),$r=new WeakMap,Ne=class{constructor(e,t,r){if(this._$cssResult$=!0,r!==Bt)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e,this.t=t}get styleSheet(){let e=this.o,t=this.t;if(et&&e===void 0){let r=t!==void 0&&t.length===1;r&&(e=$r.get(t)),e===void 0&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),r&&$r.set(t,e))}return e}toString(){return this.cssText}},Tr=o=>new Ne(typeof o=="string"?o:o+"",void 0,Bt),g=(o,...e)=>{let t=o.length===1?o[0]:e.reduce((r,i,n)=>r+(a=>{if(a._$cssResult$===!0)return a.cssText;if(typeof a=="number")return a;throw Error("Value passed to 'css' function must be a 'css' function result: "+a+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+o[n+1],o[0]);return new Ne(t,o,Bt)},Sr=(o,e)=>{if(et)o.adoptedStyleSheets=e.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(let t of e){let r=document.createElement("style"),i=Qe.litNonce;i!==void 0&&r.setAttribute("nonce",i),r.textContent=t.cssText,o.appendChild(r)}},Ft=et?o=>o:o=>o instanceof CSSStyleSheet?(e=>{let t="";for(let r of e.cssRules)t+=r.cssText;return Tr(t)})(o):o;var{is:Io,defineProperty:ko,getOwnPropertyDescriptor:Oo,getOwnPropertyNames:Ro,getOwnPropertySymbols:Po,getPrototypeOf:Lo}=Object,ce=globalThis,Ir=ce.trustedTypes,Do=Ir?Ir.emptyScript:"",zo=ce.reactiveElementPolyfillSupport,Be=(o,e)=>o,Fe={toAttribute(o,e){switch(e){case Boolean:o=o?Do:null;break;case Object:case Array:o=o==null?o:JSON.stringify(o)}return o},fromAttribute(o,e){let t=o;switch(e){case Boolean:t=o!==null;break;case Number:t=o===null?null:Number(o);break;case Object:case Array:try{t=JSON.parse(o)}catch{t=null}}return t}},tt=(o,e)=>!Io(o,e),kr={attribute:!0,type:String,converter:Fe,reflect:!1,useDefault:!1,hasChanged:tt};Symbol.metadata??(Symbol.metadata=Symbol("metadata")),ce.litPropertyMetadata??(ce.litPropertyMetadata=new WeakMap);var re=class extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??(this.l=[])).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,t=kr){if(t.state&&(t.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(e)&&((t=Object.create(t)).wrapped=!0),this.elementProperties.set(e,t),!t.noAccessor){let r=Symbol(),i=this.getPropertyDescriptor(e,r,t);i!==void 0&&ko(this.prototype,e,i)}}static getPropertyDescriptor(e,t,r){let{get:i,set:n}=Oo(this.prototype,e)??{get(){return this[t]},set(a){this[t]=a}};return{get:i,set(a){let d=i?.call(this);n?.call(this,a),this.requestUpdate(e,d,r)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??kr}static _$Ei(){if(this.hasOwnProperty(Be("elementProperties")))return;let e=Lo(this);e.finalize(),e.l!==void 0&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(Be("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(Be("properties"))){let t=this.properties,r=[...Ro(t),...Po(t)];for(let i of r)this.createProperty(i,t[i])}let e=this[Symbol.metadata];if(e!==null){let t=litPropertyMetadata.get(e);if(t!==void 0)for(let[r,i]of t)this.elementProperties.set(r,i)}this._$Eh=new Map;for(let[t,r]of this.elementProperties){let i=this._$Eu(t,r);i!==void 0&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){let t=[];if(Array.isArray(e)){let r=new Set(e.flat(1/0).reverse());for(let i of r)t.unshift(Ft(i))}else e!==void 0&&t.push(Ft(e));return t}static _$Eu(e,t){let r=t.attribute;return r===!1?void 0:typeof r=="string"?r:typeof e=="string"?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(e=>e(this))}addController(e){(this._$EO??(this._$EO=new Set)).add(e),this.renderRoot!==void 0&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){let e=new Map,t=this.constructor.elementProperties;for(let r of t.keys())this.hasOwnProperty(r)&&(e.set(r,this[r]),delete this[r]);e.size>0&&(this._$Ep=e)}createRenderRoot(){let e=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return Sr(e,this.constructor.elementStyles),e}connectedCallback(){this.renderRoot??(this.renderRoot=this.createRenderRoot()),this.enableUpdating(!0),this._$EO?.forEach(e=>e.hostConnected?.())}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach(e=>e.hostDisconnected?.())}attributeChangedCallback(e,t,r){this._$AK(e,r)}_$ET(e,t){let r=this.constructor.elementProperties.get(e),i=this.constructor._$Eu(e,r);if(i!==void 0&&r.reflect===!0){let n=(r.converter?.toAttribute!==void 0?r.converter:Fe).toAttribute(t,r.type);this._$Em=e,n==null?this.removeAttribute(i):this.setAttribute(i,n),this._$Em=null}}_$AK(e,t){let r=this.constructor,i=r._$Eh.get(e);if(i!==void 0&&this._$Em!==i){let n=r.getPropertyOptions(i),a=typeof n.converter=="function"?{fromAttribute:n.converter}:n.converter?.fromAttribute!==void 0?n.converter:Fe;this._$Em=i;let d=a.fromAttribute(t,n.type);this[i]=d??this._$Ej?.get(i)??d,this._$Em=null}}requestUpdate(e,t,r,i=!1,n){if(e!==void 0){let a=this.constructor;if(i===!1&&(n=this[e]),r??(r=a.getPropertyOptions(e)),!((r.hasChanged??tt)(n,t)||r.useDefault&&r.reflect&&n===this._$Ej?.get(e)&&!this.hasAttribute(a._$Eu(e,r))))return;this.C(e,t,r)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(e,t,{useDefault:r,reflect:i,wrapped:n},a){r&&!(this._$Ej??(this._$Ej=new Map)).has(e)&&(this._$Ej.set(e,a??t??this[e]),n!==!0||a!==void 0)||(this._$AL.has(e)||(this.hasUpdated||r||(t=void 0),this._$AL.set(e,t)),i===!0&&this._$Em!==e&&(this._$Eq??(this._$Eq=new Set)).add(e))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}let e=this.scheduleUpdate();return e!=null&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??(this.renderRoot=this.createRenderRoot()),this._$Ep){for(let[i,n]of this._$Ep)this[i]=n;this._$Ep=void 0}let r=this.constructor.elementProperties;if(r.size>0)for(let[i,n]of r){let{wrapped:a}=n,d=this[i];a!==!0||this._$AL.has(i)||d===void 0||this.C(i,void 0,n,d)}}let e=!1,t=this._$AL;try{e=this.shouldUpdate(t),e?(this.willUpdate(t),this._$EO?.forEach(r=>r.hostUpdate?.()),this.update(t)):this._$EM()}catch(r){throw e=!1,this._$EM(),r}e&&this._$AE(t)}willUpdate(e){}_$AE(e){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Eq&&(this._$Eq=this._$Eq.forEach(t=>this._$ET(t,this[t]))),this._$EM()}updated(e){}firstUpdated(e){}};re.elementStyles=[],re.shadowRootOptions={mode:"open"},re[Be("elementProperties")]=new Map,re[Be("finalized")]=new Map,zo?.({ReactiveElement:re}),(ce.reactiveElementVersions??(ce.reactiveElementVersions=[])).push("2.1.2");var Mo={attribute:!0,type:String,converter:Fe,reflect:!1,hasChanged:tt},No=(o=Mo,e,t)=>{let{kind:r,metadata:i}=t,n=globalThis.litPropertyMetadata.get(i);if(n===void 0&&globalThis.litPropertyMetadata.set(i,n=new Map),r==="setter"&&((o=Object.create(o)).wrapped=!0),n.set(t.name,o),r==="accessor"){let{name:a}=t;return{set(d){let c=e.get.call(this);e.set.call(this,d),this.requestUpdate(a,c,o,!0,d)},init(d){return d!==void 0&&this.C(a,void 0,o,d),d}}}if(r==="setter"){let{name:a}=t;return function(d){let c=this[a];e.call(this,d),this.requestUpdate(a,c,o,!0,d)}}throw Error("Unsupported decorator location: "+r)};function l(o){return(e,t)=>typeof t=="object"?No(o,e,t):((r,i,n)=>{let a=i.hasOwnProperty(n);return i.constructor.createProperty(n,r),a?Object.getOwnPropertyDescriptor(i,n):void 0})(o,e,t)}function k(o){return l({...o,state:!0,attribute:!1})}var Q=(o,e,t)=>(t.configurable=!0,t.enumerable=!0,Reflect.decorate&&typeof e!="object"&&Object.defineProperty(o,e,t),t);function S(o,e){return(t,r,i)=>{let n=a=>a.renderRoot?.querySelector(o)??null;if(e){let{get:a,set:d}=typeof r=="object"?t:i??(()=>{let c=Symbol();return{get(){return this[c]},set(h){this[c]=h}}})();return Q(t,r,{get(){let c=a.call(this);return c===void 0&&(c=n(this),(c!==null||this.hasUpdated)&&d.call(this,c)),c}})}return Q(t,r,{get(){return n(this)}})}}var Bo;function Or(o){return(e,t)=>Q(e,t,{get(){return(this.renderRoot??Bo??(Bo=document.createDocumentFragment())).querySelectorAll(o)}})}function N(o){return(e,t)=>{let{slot:r,selector:i}=o??{},n="slot"+(r?`[name=${r}]`:":not([name])");return Q(e,t,{get(){let a=this.renderRoot?.querySelector(n),d=a?.assignedElements(o)??[];return i===void 0?d:d.filter(c=>c.matches(i))}})}}function rt(o){return(e,t)=>{let{slot:r}=o??{},i="slot"+(r?`[name=${r}]`:":not([name])");return Q(e,t,{get(){return this.renderRoot?.querySelector(i)?.assignedNodes(o)??[]}})}}var qe=globalThis,Rr=o=>o,ot=qe.trustedTypes,Pr=ot?ot.createPolicy("lit-html",{createHTML:o=>o}):void 0,qt="$lit$",oe=`lit$${Math.random().toFixed(9).slice(2)}$`,Vt="?"+oe,Fo=`<${Vt}>`,_e=document,Ve=()=>_e.createComment(""),He=o=>o===null||typeof o!="object"&&typeof o!="function",Ht=Array.isArray,Br=o=>Ht(o)||typeof o?.[Symbol.iterator]=="function",Ut=`[
+\f\r]`,Ue=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,Lr=/-->/g,Dr=/>/g,ye=RegExp(`>|${Ut}(?:([^\\s"'>=/]+)(${Ut}*=${Ut}*(?:[^
+\f\r"'\`<>=]|("|')|))|$)`,"g"),zr=/'/g,Mr=/"/g,Fr=/^(?:script|style|textarea|title)$/i,jt=o=>(e,...t)=>({_$litType$:o,strings:e,values:t}),u=jt(1),Ur=jt(2),qr=jt(3),U=Symbol.for("lit-noChange"),p=Symbol.for("lit-nothing"),Nr=new WeakMap,xe=_e.createTreeWalker(_e,129);function Vr(o,e){if(!Ht(o)||!o.hasOwnProperty("raw"))throw Error("invalid template strings array");return Pr!==void 0?Pr.createHTML(e):e}var Hr=(o,e)=>{let t=o.length-1,r=[],i,n=e===2?"":e===3?"":"",a=Ue;for(let d=0;d"?(a=i??Ue,f=-1):m[1]===void 0?f=-2:(f=a.lastIndex-m[2].length,h=m[1],a=m[3]===void 0?ye:m[3]==='"'?Mr:zr):a===Mr||a===zr?a=ye:a===Lr||a===Dr?a=Ue:(a=ye,i=void 0);let y=a===ye&&o[d+1].startsWith("/>")?" ":"";n+=a===Ue?c+Fo:f>=0?(r.push(h),c.slice(0,f)+qt+c.slice(f)+oe+y):c+oe+(f===-2?d:y)}return[Vr(o,n+(o[t]||">")+(e===2?" ":e===3?"":"")),r]},je=class o{constructor({strings:e,_$litType$:t},r){let i;this.parts=[];let n=0,a=0,d=e.length-1,c=this.parts,[h,m]=Hr(e,t);if(this.el=o.createElement(h,r),xe.currentNode=this.el.content,t===2||t===3){let f=this.el.content.firstChild;f.replaceWith(...f.childNodes)}for(;(i=xe.nextNode())!==null&&c.length0){i.textContent=ot?ot.emptyScript:"";for(let y=0;y2||r[0]!==""||r[1]!==""?(this._$AH=Array(r.length-1).fill(new String),this.strings=r):this._$AH=p}_$AI(e,t=this,r,i){let n=this.strings,a=!1;if(n===void 0)e=we(this,e,t,0),a=!He(e)||e!==this._$AH&&e!==U,a&&(this._$AH=e);else{let d=e,c,h;for(e=n[0],c=0;c{let r=t?.renderBefore??e,i=r._$litPart$;if(i===void 0){let n=t?.renderBefore??null;r._$litPart$=i=new Te(e.insertBefore(Ve(),n),n,void 0,t??{})}return i._$AI(o),i};var We=globalThis,b=class extends re{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){var t;let e=super.createRenderRoot();return(t=this.renderOptions).renderBefore??(t.renderBefore=e.firstChild),e}update(e){let t=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(e),this._$Do=Se(t,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return U}};b._$litElement$=!0,b.finalized=!0,We.litElementHydrateSupport?.({LitElement:b});var qo=We.litElementPolyfillSupport;qo?.({LitElement:b});(We.litElementVersions??(We.litElementVersions=[])).push("4.2.2");var X={ATTRIBUTE:1,CHILD:2,PROPERTY:3,BOOLEAN_ATTRIBUTE:4,EVENT:5,ELEMENT:6},Ie=o=>(...e)=>({_$litDirective$:o,values:e}),pe=class{constructor(e){}get _$AU(){return this._$AM._$AU}_$AT(e,t,r){this._$Ct=e,this._$AM=t,this._$Ci=r}_$AS(e,t){return this.update(e,t)}update(e,t){return this.render(...t)}};var R=Ie(class extends pe{constructor(o){if(super(o),o.type!==X.ATTRIBUTE||o.name!=="class"||o.strings?.length>2)throw Error("`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.")}render(o){return" "+Object.keys(o).filter(e=>o[e]).join(" ")+" "}update(o,[e]){if(this.st===void 0){this.st=new Set,o.strings!==void 0&&(this.nt=new Set(o.strings.join(" ").split(/\s/).filter(r=>r!=="")));for(let r in e)e[r]&&!this.nt?.has(r)&&this.st.add(r);return this.render(e)}let t=o.element.classList;for(let r of this.st)r in e||(t.remove(r),this.st.delete(r));for(let r in e){let i=!!e[r];i===this.st.has(r)||this.nt?.has(r)||(i?(t.add(r),this.st.add(r)):(t.remove(r),this.st.delete(r)))}return U}});var ie={STANDARD:"cubic-bezier(0.2, 0, 0, 1)",STANDARD_ACCELERATE:"cubic-bezier(.3,0,1,1)",STANDARD_DECELERATE:"cubic-bezier(0,0,0,1)",EMPHASIZED:"cubic-bezier(.3,0,0,1)",EMPHASIZED_ACCELERATE:"cubic-bezier(.3,0,.8,.15)",EMPHASIZED_DECELERATE:"cubic-bezier(.05,.7,.1,1)"};function Wr(){let o=null;return{start(){return o?.abort(),o=new AbortController,o.signal},finish(){o=null}}}var $=class extends b{constructor(){super(...arguments),this.disabled=!1,this.error=!1,this.focused=!1,this.label="",this.noAsterisk=!1,this.populated=!1,this.required=!1,this.resizable=!1,this.supportingText="",this.errorText="",this.count=-1,this.max=-1,this.hasStart=!1,this.hasEnd=!1,this.isAnimating=!1,this.refreshErrorAlert=!1,this.disableTransitions=!1}get counterText(){let e=this.count??-1,t=this.max??-1;return e<0||t<=0?"":`${e} / ${t}`}get supportingOrErrorText(){return this.error&&this.errorText?this.errorText:this.supportingText}reannounceError(){this.refreshErrorAlert=!0}update(e){e.has("disabled")&&e.get("disabled")!==void 0&&(this.disableTransitions=!0),this.disabled&&this.focused&&(e.set("focused",!0),this.focused=!1),this.animateLabelIfNeeded({wasFocused:e.get("focused"),wasPopulated:e.get("populated")}),super.update(e)}render(){let e=this.renderLabel(!0),t=this.renderLabel(!1),r=this.renderOutline?.(e),i={disabled:this.disabled,"disable-transitions":this.disableTransitions,error:this.error&&!this.disabled,focused:this.focused,"with-start":this.hasStart,"with-end":this.hasEnd,populated:this.populated,resizable:this.resizable,required:this.required,"no-label":!this.label};return u`
+
+
+ ${this.renderBackground?.()}
+
+ ${this.renderStateLayer?.()} ${this.renderIndicator?.()} ${r}
+
+
+
+
+
+
+ ${t} ${r?p:e}
+
+
+
+
+
+
+
+
+
+
+ ${this.renderSupportingText()}
+
+ `}updated(e){(e.has("supportingText")||e.has("errorText")||e.has("count")||e.has("max"))&&this.updateSlottedAriaDescribedBy(),this.refreshErrorAlert&&requestAnimationFrame(()=>{this.refreshErrorAlert=!1}),this.disableTransitions&&requestAnimationFrame(()=>{this.disableTransitions=!1})}renderSupportingText(){let{supportingOrErrorText:e,counterText:t}=this;if(!e&&!t)return p;let r=u`${e} `,i=t?u`${t} `:p,a=this.error&&this.errorText&&!this.refreshErrorAlert?"alert":p;return u`
+ ${r}${i}
+
+ `}updateSlottedAriaDescribedBy(){for(let e of this.slottedAriaDescribedBy)Se(u`${this.supportingOrErrorText} ${this.counterText}`,e),e.setAttribute("hidden","")}renderLabel(e){if(!this.label)return p;let t;e?t=this.focused||this.populated||this.isAnimating:t=!this.focused&&!this.populated&&!this.isAnimating;let r={hidden:!t,floating:e,resting:!e},i=`${this.label}${this.required&&!this.noAsterisk?"*":""}`;return u`
+ ${i}
+ `}animateLabelIfNeeded({wasFocused:e,wasPopulated:t}){if(!this.label)return;e??(e=this.focused),t??(t=this.populated);let r=e||t,i=this.focused||this.populated;r!==i&&(this.isAnimating=!0,this.labelAnimation?.cancel(),this.labelAnimation=this.floatingLabelEl?.animate(this.getLabelKeyframes(),{duration:150,easing:ie.STANDARD}),this.labelAnimation?.addEventListener("finish",()=>{this.isAnimating=!1}))}getLabelKeyframes(){let{floatingLabelEl:e,restingLabelEl:t}=this;if(!e||!t)return[];let{x:r,y:i,height:n}=e.getBoundingClientRect(),{x:a,y:d,height:c}=t.getBoundingClientRect(),h=e.scrollWidth,m=t.scrollWidth,f=m/h,x=a-r,y=d-i+Math.round((c-n*f)/2),I=`translateX(${x}px) translateY(${y}px) scale(${f})`,w="translateX(0) translateY(0) scale(1)",O=t.clientWidth,C=m>O?`${O/f}px`:"";return this.focused||this.populated?[{transform:I,width:C},{transform:w,width:C}]:[{transform:w,width:C},{transform:I,width:C}]}getSurfacePositionClientRect(){return this.containerEl.getBoundingClientRect()}};s([l({type:Boolean})],$.prototype,"disabled",void 0);s([l({type:Boolean})],$.prototype,"error",void 0);s([l({type:Boolean})],$.prototype,"focused",void 0);s([l()],$.prototype,"label",void 0);s([l({type:Boolean,attribute:"no-asterisk"})],$.prototype,"noAsterisk",void 0);s([l({type:Boolean})],$.prototype,"populated",void 0);s([l({type:Boolean})],$.prototype,"required",void 0);s([l({type:Boolean})],$.prototype,"resizable",void 0);s([l({attribute:"supporting-text"})],$.prototype,"supportingText",void 0);s([l({attribute:"error-text"})],$.prototype,"errorText",void 0);s([l({type:Number})],$.prototype,"count",void 0);s([l({type:Number})],$.prototype,"max",void 0);s([l({type:Boolean,attribute:"has-start"})],$.prototype,"hasStart",void 0);s([l({type:Boolean,attribute:"has-end"})],$.prototype,"hasEnd",void 0);s([N({slot:"aria-describedby"})],$.prototype,"slottedAriaDescribedBy",void 0);s([k()],$.prototype,"isAnimating",void 0);s([k()],$.prototype,"refreshErrorAlert",void 0);s([k()],$.prototype,"disableTransitions",void 0);s([S(".label.floating")],$.prototype,"floatingLabelEl",void 0);s([S(".label.resting")],$.prototype,"restingLabelEl",void 0);s([S(".container")],$.prototype,"containerEl",void 0);var dt=class extends ${renderOutline(e){return u`
+
+ `}};var Kr=g`@layer styles{:host{--_bottom-space: var(--md-outlined-field-bottom-space, 16px);--_content-color: var(--md-outlined-field-content-color, var(--md-sys-color-on-surface, #1d1b20));--_content-font: var(--md-outlined-field-content-font, var(--md-sys-typescale-body-large-font, var(--md-ref-typeface-plain, Roboto)));--_content-line-height: var(--md-outlined-field-content-line-height, var(--md-sys-typescale-body-large-line-height, 1.5rem));--_content-size: var(--md-outlined-field-content-size, var(--md-sys-typescale-body-large-size, 1rem));--_content-space: var(--md-outlined-field-content-space, 16px);--_content-weight: var(--md-outlined-field-content-weight, var(--md-sys-typescale-body-large-weight, var(--md-ref-typeface-weight-regular, 400)));--_disabled-content-color: var(--md-outlined-field-disabled-content-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-content-opacity: var(--md-outlined-field-disabled-content-opacity, 0.38);--_disabled-label-text-color: var(--md-outlined-field-disabled-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-label-text-opacity: var(--md-outlined-field-disabled-label-text-opacity, 0.38);--_disabled-leading-content-color: var(--md-outlined-field-disabled-leading-content-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-leading-content-opacity: var(--md-outlined-field-disabled-leading-content-opacity, 0.38);--_disabled-outline-color: var(--md-outlined-field-disabled-outline-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-outline-opacity: var(--md-outlined-field-disabled-outline-opacity, 0.12);--_disabled-outline-width: var(--md-outlined-field-disabled-outline-width, 1px);--_disabled-supporting-text-color: var(--md-outlined-field-disabled-supporting-text-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-supporting-text-opacity: var(--md-outlined-field-disabled-supporting-text-opacity, 0.38);--_disabled-trailing-content-color: var(--md-outlined-field-disabled-trailing-content-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-trailing-content-opacity: var(--md-outlined-field-disabled-trailing-content-opacity, 0.38);--_error-content-color: var(--md-outlined-field-error-content-color, var(--md-sys-color-on-surface, #1d1b20));--_error-focus-content-color: var(--md-outlined-field-error-focus-content-color, var(--md-sys-color-on-surface, #1d1b20));--_error-focus-label-text-color: var(--md-outlined-field-error-focus-label-text-color, var(--md-sys-color-error, #b3261e));--_error-focus-leading-content-color: var(--md-outlined-field-error-focus-leading-content-color, var(--md-sys-color-on-surface-variant, #49454f));--_error-focus-outline-color: var(--md-outlined-field-error-focus-outline-color, var(--md-sys-color-error, #b3261e));--_error-focus-supporting-text-color: var(--md-outlined-field-error-focus-supporting-text-color, var(--md-sys-color-error, #b3261e));--_error-focus-trailing-content-color: var(--md-outlined-field-error-focus-trailing-content-color, var(--md-sys-color-error, #b3261e));--_error-hover-content-color: var(--md-outlined-field-error-hover-content-color, var(--md-sys-color-on-surface, #1d1b20));--_error-hover-label-text-color: var(--md-outlined-field-error-hover-label-text-color, var(--md-sys-color-on-error-container, #410e0b));--_error-hover-leading-content-color: var(--md-outlined-field-error-hover-leading-content-color, var(--md-sys-color-on-surface-variant, #49454f));--_error-hover-outline-color: var(--md-outlined-field-error-hover-outline-color, var(--md-sys-color-on-error-container, #410e0b));--_error-hover-supporting-text-color: var(--md-outlined-field-error-hover-supporting-text-color, var(--md-sys-color-error, #b3261e));--_error-hover-trailing-content-color: var(--md-outlined-field-error-hover-trailing-content-color, var(--md-sys-color-on-error-container, #410e0b));--_error-label-text-color: var(--md-outlined-field-error-label-text-color, var(--md-sys-color-error, #b3261e));--_error-leading-content-color: var(--md-outlined-field-error-leading-content-color, var(--md-sys-color-on-surface-variant, #49454f));--_error-outline-color: var(--md-outlined-field-error-outline-color, var(--md-sys-color-error, #b3261e));--_error-supporting-text-color: var(--md-outlined-field-error-supporting-text-color, var(--md-sys-color-error, #b3261e));--_error-trailing-content-color: var(--md-outlined-field-error-trailing-content-color, var(--md-sys-color-error, #b3261e));--_focus-content-color: var(--md-outlined-field-focus-content-color, var(--md-sys-color-on-surface, #1d1b20));--_focus-label-text-color: var(--md-outlined-field-focus-label-text-color, var(--md-sys-color-primary, #6750a4));--_focus-leading-content-color: var(--md-outlined-field-focus-leading-content-color, var(--md-sys-color-on-surface-variant, #49454f));--_focus-outline-color: var(--md-outlined-field-focus-outline-color, var(--md-sys-color-primary, #6750a4));--_focus-outline-width: var(--md-outlined-field-focus-outline-width, 3px);--_focus-supporting-text-color: var(--md-outlined-field-focus-supporting-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_focus-trailing-content-color: var(--md-outlined-field-focus-trailing-content-color, var(--md-sys-color-on-surface-variant, #49454f));--_hover-content-color: var(--md-outlined-field-hover-content-color, var(--md-sys-color-on-surface, #1d1b20));--_hover-label-text-color: var(--md-outlined-field-hover-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_hover-leading-content-color: var(--md-outlined-field-hover-leading-content-color, var(--md-sys-color-on-surface-variant, #49454f));--_hover-outline-color: var(--md-outlined-field-hover-outline-color, var(--md-sys-color-on-surface, #1d1b20));--_hover-outline-width: var(--md-outlined-field-hover-outline-width, 1px);--_hover-supporting-text-color: var(--md-outlined-field-hover-supporting-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_hover-trailing-content-color: var(--md-outlined-field-hover-trailing-content-color, var(--md-sys-color-on-surface-variant, #49454f));--_label-text-color: var(--md-outlined-field-label-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_label-text-font: var(--md-outlined-field-label-text-font, var(--md-sys-typescale-body-large-font, var(--md-ref-typeface-plain, Roboto)));--_label-text-line-height: var(--md-outlined-field-label-text-line-height, var(--md-sys-typescale-body-large-line-height, 1.5rem));--_label-text-padding-bottom: var(--md-outlined-field-label-text-padding-bottom, 8px);--_label-text-populated-line-height: var(--md-outlined-field-label-text-populated-line-height, var(--md-sys-typescale-body-small-line-height, 1rem));--_label-text-populated-size: var(--md-outlined-field-label-text-populated-size, var(--md-sys-typescale-body-small-size, 0.75rem));--_label-text-size: var(--md-outlined-field-label-text-size, var(--md-sys-typescale-body-large-size, 1rem));--_label-text-weight: var(--md-outlined-field-label-text-weight, var(--md-sys-typescale-body-large-weight, var(--md-ref-typeface-weight-regular, 400)));--_leading-content-color: var(--md-outlined-field-leading-content-color, var(--md-sys-color-on-surface-variant, #49454f));--_leading-space: var(--md-outlined-field-leading-space, 16px);--_outline-color: var(--md-outlined-field-outline-color, var(--md-sys-color-outline, #79747e));--_outline-label-padding: var(--md-outlined-field-outline-label-padding, 4px);--_outline-width: var(--md-outlined-field-outline-width, 1px);--_supporting-text-color: var(--md-outlined-field-supporting-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_supporting-text-font: var(--md-outlined-field-supporting-text-font, var(--md-sys-typescale-body-small-font, var(--md-ref-typeface-plain, Roboto)));--_supporting-text-leading-space: var(--md-outlined-field-supporting-text-leading-space, 16px);--_supporting-text-line-height: var(--md-outlined-field-supporting-text-line-height, var(--md-sys-typescale-body-small-line-height, 1rem));--_supporting-text-size: var(--md-outlined-field-supporting-text-size, var(--md-sys-typescale-body-small-size, 0.75rem));--_supporting-text-top-space: var(--md-outlined-field-supporting-text-top-space, 4px);--_supporting-text-trailing-space: var(--md-outlined-field-supporting-text-trailing-space, 16px);--_supporting-text-weight: var(--md-outlined-field-supporting-text-weight, var(--md-sys-typescale-body-small-weight, var(--md-ref-typeface-weight-regular, 400)));--_top-space: var(--md-outlined-field-top-space, 16px);--_trailing-content-color: var(--md-outlined-field-trailing-content-color, var(--md-sys-color-on-surface-variant, #49454f));--_trailing-space: var(--md-outlined-field-trailing-space, 16px);--_with-leading-content-leading-space: var(--md-outlined-field-with-leading-content-leading-space, 12px);--_with-trailing-content-trailing-space: var(--md-outlined-field-with-trailing-content-trailing-space, 12px);--_container-shape-start-start: var(--md-outlined-field-container-shape-start-start, var(--md-outlined-field-container-shape, var(--md-sys-shape-corner-extra-small, 4px)));--_container-shape-start-end: var(--md-outlined-field-container-shape-start-end, var(--md-outlined-field-container-shape, var(--md-sys-shape-corner-extra-small, 4px)));--_container-shape-end-end: var(--md-outlined-field-container-shape-end-end, var(--md-outlined-field-container-shape, var(--md-sys-shape-corner-extra-small, 4px)));--_container-shape-end-start: var(--md-outlined-field-container-shape-end-start, var(--md-outlined-field-container-shape, var(--md-sys-shape-corner-extra-small, 4px)))}.outline{border-color:var(--_outline-color);border-radius:inherit;display:flex;pointer-events:none;height:100%;position:absolute;width:100%;z-index:1}.outline-start::before,.outline-start::after,.outline-panel-inactive::before,.outline-panel-inactive::after,.outline-panel-active::before,.outline-panel-active::after,.outline-end::before,.outline-end::after{border:inherit;content:"";inset:0;position:absolute}.outline-start,.outline-end{border:inherit;border-radius:inherit;box-sizing:border-box;position:relative}.outline-start::before,.outline-start::after,.outline-end::before,.outline-end::after{border-bottom-style:solid;border-top-style:solid}.outline-start::after,.outline-end::after{opacity:0;transition:opacity 150ms cubic-bezier(0.2, 0, 0, 1)}.focused .outline-start::after,.focused .outline-end::after{opacity:1}.outline-start::before,.outline-start::after{border-inline-start-style:solid;border-inline-end-style:none;border-start-start-radius:inherit;border-start-end-radius:0;border-end-start-radius:inherit;border-end-end-radius:0;margin-inline-end:var(--_outline-label-padding)}.outline-end{flex-grow:1;margin-inline-start:calc(-1*var(--_outline-label-padding))}.outline-end::before,.outline-end::after{border-inline-start-style:none;border-inline-end-style:solid;border-start-start-radius:0;border-start-end-radius:inherit;border-end-start-radius:0;border-end-end-radius:inherit}.outline-notch{align-items:flex-start;border:inherit;display:flex;margin-inline-start:calc(-1*var(--_outline-label-padding));margin-inline-end:var(--_outline-label-padding);max-width:calc(100% - var(--_leading-space) - var(--_trailing-space));padding:0 var(--_outline-label-padding);position:relative}.no-label .outline-notch{display:none}.outline-panel-inactive,.outline-panel-active{border:inherit;border-bottom-style:solid;inset:0;position:absolute}.outline-panel-inactive::before,.outline-panel-inactive::after,.outline-panel-active::before,.outline-panel-active::after{border-top-style:solid;border-bottom:none;bottom:auto;transform:scaleX(1);transition:transform 150ms cubic-bezier(0.2, 0, 0, 1)}.outline-panel-inactive::before,.outline-panel-active::before{right:50%;transform-origin:top left}.outline-panel-inactive::after,.outline-panel-active::after{left:50%;transform-origin:top right}.populated .outline-panel-inactive::before,.populated .outline-panel-inactive::after,.populated .outline-panel-active::before,.populated .outline-panel-active::after,.focused .outline-panel-inactive::before,.focused .outline-panel-inactive::after,.focused .outline-panel-active::before,.focused .outline-panel-active::after{transform:scaleX(0)}.outline-panel-active{opacity:0;transition:opacity 150ms cubic-bezier(0.2, 0, 0, 1)}.focused .outline-panel-active{opacity:1}.outline-label{display:flex;max-width:100%;transform:translateY(calc(-100% + var(--_label-text-padding-bottom)))}.outline-start,.field:not(.with-start) .content ::slotted(*){padding-inline-start:max(var(--_leading-space),max(var(--_container-shape-start-start),var(--_container-shape-end-start)) + var(--_outline-label-padding))}.field:not(.with-start) .label-wrapper{margin-inline-start:max(var(--_leading-space),max(var(--_container-shape-start-start),var(--_container-shape-end-start)) + var(--_outline-label-padding))}.field:not(.with-end) .content ::slotted(*){padding-inline-end:max(var(--_trailing-space),max(var(--_container-shape-start-end),var(--_container-shape-end-end)))}.field:not(.with-end) .label-wrapper{margin-inline-end:max(var(--_trailing-space),max(var(--_container-shape-start-end),var(--_container-shape-end-end)))}.outline-start::before,.outline-end::before,.outline-panel-inactive,.outline-panel-inactive::before,.outline-panel-inactive::after{border-width:var(--_outline-width)}:hover .outline{border-color:var(--_hover-outline-color);color:var(--_hover-outline-color)}:hover .outline-start::before,:hover .outline-end::before,:hover .outline-panel-inactive,:hover .outline-panel-inactive::before,:hover .outline-panel-inactive::after{border-width:var(--_hover-outline-width)}.focused .outline{border-color:var(--_focus-outline-color);color:var(--_focus-outline-color)}.outline-start::after,.outline-end::after,.outline-panel-active,.outline-panel-active::before,.outline-panel-active::after{border-width:var(--_focus-outline-width)}.disabled .outline{border-color:var(--_disabled-outline-color);color:var(--_disabled-outline-color)}.disabled .outline-start,.disabled .outline-end,.disabled .outline-panel-inactive{opacity:var(--_disabled-outline-opacity)}.disabled .outline-start::before,.disabled .outline-end::before,.disabled .outline-panel-inactive,.disabled .outline-panel-inactive::before,.disabled .outline-panel-inactive::after{border-width:var(--_disabled-outline-width)}.error .outline{border-color:var(--_error-outline-color);color:var(--_error-outline-color)}.error:hover .outline{border-color:var(--_error-hover-outline-color);color:var(--_error-hover-outline-color)}.error.focused .outline{border-color:var(--_error-focus-outline-color);color:var(--_error-focus-outline-color)}.resizable .container{bottom:var(--_focus-outline-width);inset-inline-end:var(--_focus-outline-width);clip-path:inset(var(--_focus-outline-width) 0 0 var(--_focus-outline-width))}.resizable .container>*{top:var(--_focus-outline-width);inset-inline-start:var(--_focus-outline-width)}.resizable .container:dir(rtl){clip-path:inset(var(--_focus-outline-width) var(--_focus-outline-width) 0 0)}}@layer hcm{@media(forced-colors: active){.disabled .outline{border-color:GrayText;color:GrayText}.disabled :is(.outline-start,.outline-end,.outline-panel-inactive){opacity:1}}}
+`;var Gr=g`:host{display:inline-flex;resize:both}.field{display:flex;flex:1;flex-direction:column;writing-mode:horizontal-tb;max-width:100%}.container-overflow{border-start-start-radius:var(--_container-shape-start-start);border-start-end-radius:var(--_container-shape-start-end);border-end-end-radius:var(--_container-shape-end-end);border-end-start-radius:var(--_container-shape-end-start);display:flex;height:100%;position:relative}.container{align-items:center;border-radius:inherit;display:flex;flex:1;max-height:100%;min-height:100%;min-width:min-content;position:relative}.field,.container-overflow{resize:inherit}.resizable:not(.disabled) .container{resize:inherit;overflow:hidden}.disabled{pointer-events:none}slot[name=container]{border-radius:inherit}slot[name=container]::slotted(*){border-radius:inherit;inset:0;pointer-events:none;position:absolute}@layer styles{.start,.middle,.end{display:flex;box-sizing:border-box;height:100%;position:relative}.start{color:var(--_leading-content-color)}.end{color:var(--_trailing-content-color)}.start,.end{align-items:center;justify-content:center}.with-start .start{margin-inline:var(--_with-leading-content-leading-space) var(--_content-space)}.with-end .end{margin-inline:var(--_content-space) var(--_with-trailing-content-trailing-space)}.middle{align-items:stretch;align-self:baseline;flex:1}.content{color:var(--_content-color);display:flex;flex:1;opacity:0;transition:opacity 83ms cubic-bezier(0.2, 0, 0, 1)}.no-label .content,.focused .content,.populated .content{opacity:1;transition-delay:67ms}:is(.disabled,.disable-transitions) .content{transition:none}.content ::slotted(*){all:unset;color:currentColor;font-family:var(--_content-font);font-size:var(--_content-size);line-height:var(--_content-line-height);font-weight:var(--_content-weight);width:100%;overflow-wrap:revert;white-space:revert}.content ::slotted(:not(textarea)){padding-top:var(--_top-space);padding-bottom:var(--_bottom-space)}.content ::slotted(textarea){margin-top:var(--_top-space);margin-bottom:var(--_bottom-space)}:hover .content{color:var(--_hover-content-color)}:hover .start{color:var(--_hover-leading-content-color)}:hover .end{color:var(--_hover-trailing-content-color)}.focused .content{color:var(--_focus-content-color)}.focused .start{color:var(--_focus-leading-content-color)}.focused .end{color:var(--_focus-trailing-content-color)}.disabled .content{color:var(--_disabled-content-color)}.disabled.no-label .content,.disabled.focused .content,.disabled.populated .content{opacity:var(--_disabled-content-opacity)}.disabled .start{color:var(--_disabled-leading-content-color);opacity:var(--_disabled-leading-content-opacity)}.disabled .end{color:var(--_disabled-trailing-content-color);opacity:var(--_disabled-trailing-content-opacity)}.error .content{color:var(--_error-content-color)}.error .start{color:var(--_error-leading-content-color)}.error .end{color:var(--_error-trailing-content-color)}.error:hover .content{color:var(--_error-hover-content-color)}.error:hover .start{color:var(--_error-hover-leading-content-color)}.error:hover .end{color:var(--_error-hover-trailing-content-color)}.error.focused .content{color:var(--_error-focus-content-color)}.error.focused .start{color:var(--_error-focus-leading-content-color)}.error.focused .end{color:var(--_error-focus-trailing-content-color)}}@layer hcm{@media(forced-colors: active){.disabled :is(.start,.content,.end){color:GrayText;opacity:1}}}@layer styles{.label{box-sizing:border-box;color:var(--_label-text-color);overflow:hidden;max-width:100%;text-overflow:ellipsis;white-space:nowrap;z-index:1;font-family:var(--_label-text-font);font-size:var(--_label-text-size);line-height:var(--_label-text-line-height);font-weight:var(--_label-text-weight);width:min-content}.label-wrapper{inset:0;pointer-events:none;position:absolute}.label.resting{position:absolute;top:var(--_top-space)}.label.floating{font-size:var(--_label-text-populated-size);line-height:var(--_label-text-populated-line-height);transform-origin:top left}.label.hidden{opacity:0}.no-label .label{display:none}.label-wrapper{inset:0;position:absolute;text-align:initial}:hover .label{color:var(--_hover-label-text-color)}.focused .label{color:var(--_focus-label-text-color)}.disabled .label{color:var(--_disabled-label-text-color)}.disabled .label:not(.hidden){opacity:var(--_disabled-label-text-opacity)}.error .label{color:var(--_error-label-text-color)}.error:hover .label{color:var(--_error-hover-label-text-color)}.error.focused .label{color:var(--_error-focus-label-text-color)}}@layer hcm{@media(forced-colors: active){.disabled .label:not(.hidden){color:GrayText;opacity:1}}}@layer styles{.supporting-text{color:var(--_supporting-text-color);display:flex;font-family:var(--_supporting-text-font);font-size:var(--_supporting-text-size);line-height:var(--_supporting-text-line-height);font-weight:var(--_supporting-text-weight);gap:16px;justify-content:space-between;padding-inline-start:var(--_supporting-text-leading-space);padding-inline-end:var(--_supporting-text-trailing-space);padding-top:var(--_supporting-text-top-space)}.supporting-text :nth-child(2){flex-shrink:0}:hover .supporting-text{color:var(--_hover-supporting-text-color)}.focus .supporting-text{color:var(--_focus-supporting-text-color)}.disabled .supporting-text{color:var(--_disabled-supporting-text-color);opacity:var(--_disabled-supporting-text-opacity)}.error .supporting-text{color:var(--_error-supporting-text-color)}.error:hover .supporting-text{color:var(--_error-hover-supporting-text-color)}.error.focus .supporting-text{color:var(--_error-focus-supporting-text-color)}}@layer hcm{@media(forced-colors: active){.disabled .supporting-text{color:GrayText;opacity:1}}}
+`;var Wt=class extends dt{};Wt.styles=[Gr,Kr];Wt=s([E("md-outlined-field")],Wt);var Xr=Symbol.for(""),Vo=o=>{if(o?.r===Xr)return o?._$litStatic$};var G=(o,...e)=>({_$litStatic$:e.reduce((t,r,i)=>t+(n=>{if(n._$litStatic$!==void 0)return n._$litStatic$;throw Error(`Value passed to 'literal' function must be a 'literal' result: ${n}. Use 'unsafeStatic' to pass non-literal values, but
+ take care to ensure page security.`)})(r)+o[i+1],o[0]),r:Xr}),Yr=new Map,Kt=o=>(e,...t)=>{let r=t.length,i,n,a=[],d=[],c,h=0,m=!1;for(;ho.strings===void 0;var Ho={},Qr=(o,e=Ho)=>o._$AH=e;var Gt=Ie(class extends pe{constructor(o){if(super(o),o.type!==X.PROPERTY&&o.type!==X.ATTRIBUTE&&o.type!==X.BOOLEAN_ATTRIBUTE)throw Error("The `live` directive is not allowed on child or event bindings");if(!Jr(o))throw Error("`live` bindings can only contain a single expression")}render(o){return o}update(o,[e]){if(e===U||e===p)return e;let t=o.element,r=o.name;if(o.type===X.PROPERTY){if(e===t[r])return U}else if(o.type===X.BOOLEAN_ATTRIBUTE){if(!!e===t.hasAttribute(r))return U}else if(o.type===X.ATTRIBUTE&&t.getAttribute(r)===e+"")return U;return Qr(o),e}});var eo="important",jo=" !"+eo,Ae=Ie(class extends pe{constructor(o){if(super(o),o.type!==X.ATTRIBUTE||o.name!=="style"||o.strings?.length>2)throw Error("The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute.")}render(o){return Object.keys(o).reduce((e,t)=>{let r=o[t];return r==null?e:e+`${t=t.includes("-")?t:t.replace(/(?:^(webkit|moz|ms|o)|)(?=[A-Z])/g,"-$&").toLowerCase()}:${r};`},"")}update(o,[e]){let{style:t}=o.element;if(this.ft===void 0)return this.ft=new Set(Object.keys(e)),this.render(e);for(let r of this.ft)e[r]==null&&(this.ft.delete(r),r.includes("-")?t.removeProperty(r):t[r]=null);for(let r in e){let i=e[r];if(i!=null){this.ft.add(r);let n=typeof i=="string"&&i.endsWith(jo);r.includes("-")||n?t.setProperty(r,n?i.slice(0,-11):i,n?eo:""):t[r]=i}}return U}});var Yt=["role","ariaAtomic","ariaAutoComplete","ariaBusy","ariaChecked","ariaColCount","ariaColIndex","ariaColSpan","ariaCurrent","ariaDisabled","ariaExpanded","ariaHasPopup","ariaHidden","ariaInvalid","ariaKeyShortcuts","ariaLabel","ariaLevel","ariaLive","ariaModal","ariaMultiLine","ariaMultiSelectable","ariaOrientation","ariaPlaceholder","ariaPosInSet","ariaPressed","ariaReadOnly","ariaRequired","ariaRoleDescription","ariaRowCount","ariaRowIndex","ariaRowSpan","ariaSelected","ariaSetSize","ariaSort","ariaValueMax","ariaValueMin","ariaValueNow","ariaValueText"],Wo=Yt.map(Xt);function ct(o){return Wo.includes(o)}function Xt(o){return o.replace("aria","aria-").replace(/Elements?/g,"").toLowerCase()}var pt=Symbol("privateIgnoreAttributeChangesFor");function W(o){var e;if(!1)return o;class t extends o{constructor(){super(...arguments),this[e]=new Set}attributeChangedCallback(i,n,a){if(!ct(i)){super.attributeChangedCallback(i,n,a);return}if(this[pt].has(i))return;this[pt].add(i),this.removeAttribute(i),this[pt].delete(i);let d=Jt(i);a===null?delete this.dataset[d]:this.dataset[d]=a,this.requestUpdate(Jt(i),n)}getAttribute(i){return ct(i)?super.getAttribute(Zt(i)):super.getAttribute(i)}removeAttribute(i){super.removeAttribute(i),ct(i)&&(super.removeAttribute(Zt(i)),this.requestUpdate())}}return e=pt,Ko(t),t}function Ko(o){for(let e of Yt){let t=Xt(e),r=Zt(t),i=Jt(t);o.createProperty(e,{attribute:t,noAccessor:!0}),o.createProperty(Symbol(r),{attribute:r,noAccessor:!0}),Object.defineProperty(o.prototype,e,{configurable:!0,enumerable:!0,get(){return this.dataset[i]??null},set(n){let a=this.dataset[i]??null;n!==a&&(n===null?delete this.dataset[i]:this.dataset[i]=n,this.requestUpdate(e,a))}})}}function Zt(o){return`data-${o}`}function Jt(o){return o.replace(/-\w/,e=>e[1].toUpperCase())}var to={fromAttribute(o){return o??""},toAttribute(o){return o||null}};function ke(o,e){e.bubbles&&(!o.shadowRoot||e.composed)&&e.stopPropagation();let t=Reflect.construct(e.constructor,[e.type,e]),r=o.dispatchEvent(t);return r||e.preventDefault(),r}var P=Symbol("internals"),Qt=Symbol("privateInternals");function ee(o){class e extends o{get[P](){return this[Qt]||(this[Qt]=this.attachInternals()),this[Qt]}}return e}var he=Symbol("createValidator"),fe=Symbol("getValidityAnchor"),er=Symbol("privateValidator"),ne=Symbol("privateSyncValidity"),ut=Symbol("privateCustomValidationMessage");function Oe(o){var e;class t extends o{constructor(){super(...arguments),this[e]=""}get validity(){return this[ne](),this[P].validity}get validationMessage(){return this[ne](),this[P].validationMessage}get willValidate(){return this[ne](),this[P].willValidate}checkValidity(){return this[ne](),this[P].checkValidity()}reportValidity(){return this[ne](),this[P].reportValidity()}setCustomValidity(i){this[ut]=i,this[ne]()}requestUpdate(i,n,a){super.requestUpdate(i,n,a),this[ne]()}firstUpdated(i){super.firstUpdated(i),this[ne]()}[(e=ut,ne)](){if(!1)return;this[er]||(this[er]=this[he]());let{validity:i,validationMessage:n}=this[er].getValidity(),a=!!this[ut],d=this[ut]||n;this[P].setValidity({...i,customError:a},d,this[fe]()??void 0)}[he](){throw new Error("Implement [createValidator]")}[fe](){throw new Error("Implement [getValidityAnchor]")}}return t}var se=Symbol("getFormValue"),ht=Symbol("getFormState");function Re(o){class e extends o{get form(){return this[P].form}get labels(){return this[P].labels}get name(){return this.getAttribute("name")??""}set name(r){this.setAttribute("name",r)}get disabled(){return this.hasAttribute("disabled")}set disabled(r){this.toggleAttribute("disabled",r)}attributeChangedCallback(r,i,n){if(r==="name"||r==="disabled"){let a=r==="disabled"?i!==null:i;this.requestUpdate(r,a);return}super.attributeChangedCallback(r,i,n)}requestUpdate(r,i,n){super.requestUpdate(r,i,n),this[P].setFormValue(this[se](),this[ht]())}[se](){throw new Error("Implement [getFormValue]")}[ht](){return this[se]()}formDisabledCallback(r){this.disabled=r}}return e.formAssociated=!0,s([l({noAccessor:!0})],e.prototype,"name",null),s([l({type:Boolean,noAccessor:!0})],e.prototype,"disabled",null),e}var Pe=Symbol("onReportValidity"),ft=Symbol("privateCleanupFormListeners"),mt=Symbol("privateDoNotReportInvalid"),vt=Symbol("privateIsSelfReportingValidity"),gt=Symbol("privateCallOnReportValidity");function bt(o){var e,t,r;class i extends o{constructor(...a){super(...a),this[e]=new AbortController,this[t]=!1,this[r]=!1,!!1&&this.addEventListener("invalid",d=>{this[mt]||!d.isTrusted||this.addEventListener("invalid",()=>{this[gt](d)},{once:!0})},{capture:!0})}checkValidity(){this[mt]=!0;let a=super.checkValidity();return this[mt]=!1,a}reportValidity(){this[vt]=!0;let a=super.reportValidity();return a&&this[gt](null),this[vt]=!1,a}[(e=ft,t=mt,r=vt,gt)](a){let d=a?.defaultPrevented;d||(this[Pe](a),!(!d&&a?.defaultPrevented))||(this[vt]||Xo(this[P].form,this))&&this.focus()}[Pe](a){throw new Error("Implement [onReportValidity]")}formAssociatedCallback(a){super.formAssociatedCallback&&super.formAssociatedCallback(a),this[ft].abort(),a&&(this[ft]=new AbortController,Go(this,a,()=>{this[gt](null)},this[ft].signal))}}return i}function Go(o,e,t,r){let i=Yo(e),n=!1,a,d=!1;i.addEventListener("before",()=>{d=!0,a=new AbortController,n=!1,o.addEventListener("invalid",()=>{n=!0},{signal:a.signal})},{signal:r}),i.addEventListener("after",()=>{d=!1,a?.abort(),!n&&t()},{signal:r}),e.addEventListener("submit",()=>{d||t()},{signal:r})}var tr=new WeakMap;function Yo(o){if(!tr.has(o)){let e=new EventTarget;tr.set(o,e);for(let t of["reportValidity","requestSubmit"]){let r=o[t];o[t]=function(){e.dispatchEvent(new Event("before"));let i=Reflect.apply(r,this,arguments);return e.dispatchEvent(new Event("after")),i}}}return tr.get(o)}function Xo(o,e){if(!o)return!0;let t;for(let r of o.elements)if(r.matches(":invalid")){t=r;break}return t===e}var me=class{constructor(e){this.getCurrentState=e,this.currentValidity={validity:{},validationMessage:""}}getValidity(){let e=this.getCurrentState();if(!(!this.prevState||!this.equals(this.prevState,e)))return this.currentValidity;let{validity:r,validationMessage:i}=this.computeValidity(e);return this.prevState=this.copy(e),this.currentValidity={validationMessage:i,validity:{badInput:r.badInput,customError:r.customError,patternMismatch:r.patternMismatch,rangeOverflow:r.rangeOverflow,rangeUnderflow:r.rangeUnderflow,stepMismatch:r.stepMismatch,tooLong:r.tooLong,tooShort:r.tooShort,typeMismatch:r.typeMismatch,valueMissing:r.valueMissing}},this.currentValidity}};var yt=class extends me{computeValidity({state:e,renderedControl:t}){let r=t;Ke(e)&&!r?(r=this.inputControl||document.createElement("input"),this.inputControl=r):r||(r=this.textAreaControl||document.createElement("textarea"),this.textAreaControl=r);let i=Ke(e)?r:null;if(i&&(i.type=e.type),r.value!==e.value&&(r.value=e.value),r.required=e.required,i){let n=e;n.pattern?i.pattern=n.pattern:i.removeAttribute("pattern"),n.min?i.min=n.min:i.removeAttribute("min"),n.max?i.max=n.max:i.removeAttribute("max"),n.step?i.step=n.step:i.removeAttribute("step")}return(e.minLength??-1)>-1?r.setAttribute("minlength",String(e.minLength)):r.removeAttribute("minlength"),(e.maxLength??-1)>-1?r.setAttribute("maxlength",String(e.maxLength)):r.removeAttribute("maxlength"),{validity:r.validity,validationMessage:r.validationMessage}}equals({state:e},{state:t}){let r=e.type===t.type&&e.value===t.value&&e.required===t.required&&e.minLength===t.minLength&&e.maxLength===t.maxLength;return!Ke(e)||!Ke(t)?r:r&&e.pattern===t.pattern&&e.min===t.min&&e.max===t.max&&e.step===t.step}copy({state:e}){return{state:Ke(e)?this.copyInput(e):this.copyTextArea(e),renderedControl:null}}copyInput(e){let{type:t,pattern:r,min:i,max:n,step:a}=e;return{...this.copySharedState(e),type:t,pattern:r,min:i,max:n,step:a}}copyTextArea(e){return{...this.copySharedState(e),type:e.type}}copySharedState({value:e,required:t,minLength:r,maxLength:i}){return{value:e,required:t,minLength:r,maxLength:i}}};function Ke(o){return o.type!=="textarea"}var Zo=W(bt(Oe(Re(ee(b))))),v=class extends Zo{constructor(){super(...arguments),this.error=!1,this.errorText="",this.label="",this.noAsterisk=!1,this.required=!1,this.value="",this.prefixText="",this.suffixText="",this.hasLeadingIcon=!1,this.hasTrailingIcon=!1,this.supportingText="",this.textDirection="",this.rows=2,this.cols=20,this.inputMode="",this.max="",this.maxLength=-1,this.min="",this.minLength=-1,this.noSpinner=!1,this.pattern="",this.placeholder="",this.readOnly=!1,this.multiple=!1,this.step="",this.type="text",this.autocomplete="",this.dirty=!1,this.focused=!1,this.nativeError=!1,this.nativeErrorText=""}get selectionDirection(){return this.getInputOrTextarea().selectionDirection}set selectionDirection(e){this.getInputOrTextarea().selectionDirection=e}get selectionEnd(){return this.getInputOrTextarea().selectionEnd}set selectionEnd(e){this.getInputOrTextarea().selectionEnd=e}get selectionStart(){return this.getInputOrTextarea().selectionStart}set selectionStart(e){this.getInputOrTextarea().selectionStart=e}get valueAsNumber(){let e=this.getInput();return e?e.valueAsNumber:NaN}set valueAsNumber(e){let t=this.getInput();t&&(t.valueAsNumber=e,this.value=t.value)}get valueAsDate(){let e=this.getInput();return e?e.valueAsDate:null}set valueAsDate(e){let t=this.getInput();t&&(t.valueAsDate=e,this.value=t.value)}get hasError(){return this.error||this.nativeError}select(){this.getInputOrTextarea().select()}setRangeText(...e){this.getInputOrTextarea().setRangeText(...e),this.value=this.getInputOrTextarea().value}setSelectionRange(e,t,r){this.getInputOrTextarea().setSelectionRange(e,t,r)}showPicker(){let e=this.getInput();e&&e.showPicker()}stepDown(e){let t=this.getInput();t&&(t.stepDown(e),this.value=t.value)}stepUp(e){let t=this.getInput();t&&(t.stepUp(e),this.value=t.value)}reset(){this.dirty=!1,this.value=this.getAttribute("value")??"",this.nativeError=!1,this.nativeErrorText=""}attributeChangedCallback(e,t,r){e==="value"&&this.dirty||super.attributeChangedCallback(e,t,r)}render(){let e={disabled:this.disabled,error:!this.disabled&&this.hasError,textarea:this.type==="textarea","no-spinner":this.noSpinner};return u`
+
+ ${this.renderField()}
+
+ `}updated(e){let t=this.getInputOrTextarea().value;this.value!==t&&(this.value=t)}renderField(){return ue`<${this.fieldTag}
+ class="field"
+ count=${this.value.length}
+ ?disabled=${this.disabled}
+ ?error=${this.hasError}
+ error-text=${this.getErrorText()}
+ ?focused=${this.focused}
+ ?has-end=${this.hasTrailingIcon}
+ ?has-start=${this.hasLeadingIcon}
+ label=${this.label}
+ ?no-asterisk=${this.noAsterisk}
+ max=${this.maxLength}
+ ?populated=${!!this.value}
+ ?required=${this.required}
+ ?resizable=${this.type==="textarea"}
+ supporting-text=${this.supportingText}
+ >
+ ${this.renderLeadingIcon()}
+ ${this.renderInputOrTextarea()}
+ ${this.renderTrailingIcon()}
+
+
+ ${this.fieldTag}>`}renderLeadingIcon(){return u`
+
+
+
+ `}renderTrailingIcon(){return u`
+
+
+
+ `}renderInputOrTextarea(){let e={direction:this.textDirection},t=this.ariaLabel||this.label||p,r=this.autocomplete,i=(this.maxLength??-1)>-1,n=(this.minLength??-1)>-1;if(this.type==="textarea")return u`
+
+ `;let a=this.renderPrefix(),d=this.renderSuffix(),c=this.inputMode;return u`
+
+ ${a}
+
+ ${d}
+
+ `}renderPrefix(){return this.renderAffix(this.prefixText,!1)}renderSuffix(){return this.renderAffix(this.suffixText,!0)}renderAffix(e,t){return e?u`${e} `:p}getErrorText(){return this.error?this.errorText:this.nativeErrorText}handleFocusChange(){this.focused=this.inputOrTextarea?.matches(":focus")??!1}handleInput(e){this.dirty=!0,this.value=e.target.value}redispatchEvent(e){ke(this,e)}getInputOrTextarea(){return this.inputOrTextarea||(this.connectedCallback(),this.scheduleUpdate()),this.isUpdatePending&&this.scheduleUpdate(),this.inputOrTextarea}getInput(){return this.type==="textarea"?null:this.getInputOrTextarea()}handleIconChange(){this.hasLeadingIcon=this.leadingIcons.length>0,this.hasTrailingIcon=this.trailingIcons.length>0}[se](){return this.value}formResetCallback(){this.reset()}formStateRestoreCallback(e){this.value=e}focus(){this.getInputOrTextarea().focus()}[he](){return new yt(()=>({state:this,renderedControl:this.inputOrTextarea}))}[fe](){return this.inputOrTextarea}[Pe](e){e?.preventDefault();let t=this.getErrorText();this.nativeError=!!e,this.nativeErrorText=this.validationMessage,t===this.getErrorText()&&this.field?.reannounceError()}};v.shadowRootOptions={...b.shadowRootOptions,delegatesFocus:!0};s([l({type:Boolean,reflect:!0})],v.prototype,"error",void 0);s([l({attribute:"error-text"})],v.prototype,"errorText",void 0);s([l()],v.prototype,"label",void 0);s([l({type:Boolean,attribute:"no-asterisk"})],v.prototype,"noAsterisk",void 0);s([l({type:Boolean,reflect:!0})],v.prototype,"required",void 0);s([l()],v.prototype,"value",void 0);s([l({attribute:"prefix-text"})],v.prototype,"prefixText",void 0);s([l({attribute:"suffix-text"})],v.prototype,"suffixText",void 0);s([l({type:Boolean,attribute:"has-leading-icon"})],v.prototype,"hasLeadingIcon",void 0);s([l({type:Boolean,attribute:"has-trailing-icon"})],v.prototype,"hasTrailingIcon",void 0);s([l({attribute:"supporting-text"})],v.prototype,"supportingText",void 0);s([l({attribute:"text-direction"})],v.prototype,"textDirection",void 0);s([l({type:Number})],v.prototype,"rows",void 0);s([l({type:Number})],v.prototype,"cols",void 0);s([l({reflect:!0})],v.prototype,"inputMode",void 0);s([l()],v.prototype,"max",void 0);s([l({type:Number})],v.prototype,"maxLength",void 0);s([l()],v.prototype,"min",void 0);s([l({type:Number})],v.prototype,"minLength",void 0);s([l({type:Boolean,attribute:"no-spinner"})],v.prototype,"noSpinner",void 0);s([l()],v.prototype,"pattern",void 0);s([l({reflect:!0,converter:to})],v.prototype,"placeholder",void 0);s([l({type:Boolean,reflect:!0})],v.prototype,"readOnly",void 0);s([l({type:Boolean,reflect:!0})],v.prototype,"multiple",void 0);s([l()],v.prototype,"step",void 0);s([l({reflect:!0})],v.prototype,"type",void 0);s([l({reflect:!0})],v.prototype,"autocomplete",void 0);s([k()],v.prototype,"dirty",void 0);s([k()],v.prototype,"focused",void 0);s([k()],v.prototype,"nativeError",void 0);s([k()],v.prototype,"nativeErrorText",void 0);s([S(".input")],v.prototype,"inputOrTextarea",void 0);s([S(".field")],v.prototype,"field",void 0);s([N({slot:"leading-icon"})],v.prototype,"leadingIcons",void 0);s([N({slot:"trailing-icon"})],v.prototype,"trailingIcons",void 0);var xt=class extends v{constructor(){super(...arguments),this.fieldTag=G`md-outlined-field`}};var ro=g`:host{display:inline-flex;outline:none;resize:both;text-align:start;-webkit-tap-highlight-color:rgba(0,0,0,0)}.text-field,.field{width:100%}.text-field{display:inline-flex}.field{cursor:text}.disabled .field{cursor:default}.text-field,.textarea .field{resize:inherit}slot[name=container]{border-radius:inherit}.icon{color:currentColor;display:flex;align-items:center;justify-content:center;fill:currentColor;position:relative}.icon ::slotted(*){display:flex;position:absolute}[has-start] .icon.leading{font-size:var(--_leading-icon-size);height:var(--_leading-icon-size);width:var(--_leading-icon-size)}[has-end] .icon.trailing{font-size:var(--_trailing-icon-size);height:var(--_trailing-icon-size);width:var(--_trailing-icon-size)}.input-wrapper{display:flex}.input-wrapper>*{all:inherit;padding:0}.input{caret-color:var(--_caret-color);overflow-x:hidden;text-align:inherit}.input::placeholder{color:currentColor;opacity:1}.input::-webkit-calendar-picker-indicator{display:none}.input::-webkit-search-decoration,.input::-webkit-search-cancel-button{display:none}@media(forced-colors: active){.input{background:none}}.no-spinner .input::-webkit-inner-spin-button,.no-spinner .input::-webkit-outer-spin-button{display:none}.no-spinner .input[type=number]{-moz-appearance:textfield}:focus-within .input{caret-color:var(--_focus-caret-color)}.error:focus-within .input{caret-color:var(--_error-focus-caret-color)}.text-field:not(.disabled) .prefix{color:var(--_input-text-prefix-color)}.text-field:not(.disabled) .suffix{color:var(--_input-text-suffix-color)}.text-field:not(.disabled) .input::placeholder{color:var(--_input-text-placeholder-color)}.prefix,.suffix{text-wrap:nowrap;width:min-content}.prefix{padding-inline-end:var(--_input-text-prefix-trailing-space)}.suffix{padding-inline-start:var(--_input-text-suffix-leading-space)}
+`;var rr=class extends xt{constructor(){super(...arguments),this.fieldTag=G`md-outlined-field`}};rr.styles=[ro,Zr];rr=s([E("md-outlined-text-field")],rr);var _t=class extends b{connectedCallback(){super.connectedCallback(),this.setAttribute("aria-hidden","true")}render(){return u` `}};var oo=g`:host,.shadow,.shadow::before,.shadow::after{border-radius:inherit;inset:0;position:absolute;transition-duration:inherit;transition-property:inherit;transition-timing-function:inherit}:host{display:flex;pointer-events:none;transition-property:box-shadow,opacity}.shadow::before,.shadow::after{content:"";transition-property:box-shadow,opacity;--_level: var(--md-elevation-level, 0);--_shadow-color: var(--md-elevation-shadow-color, var(--md-sys-color-shadow, #000))}.shadow::before{box-shadow:0px calc(1px*(clamp(0,var(--_level),1) + clamp(0,var(--_level) - 3,1) + 2*clamp(0,var(--_level) - 4,1))) calc(1px*(2*clamp(0,var(--_level),1) + clamp(0,var(--_level) - 2,1) + clamp(0,var(--_level) - 4,1))) 0px var(--_shadow-color);opacity:.3}.shadow::after{box-shadow:0px calc(1px*(clamp(0,var(--_level),1) + clamp(0,var(--_level) - 1,1) + 2*clamp(0,var(--_level) - 2,3))) calc(1px*(3*clamp(0,var(--_level),2) + 2*clamp(0,var(--_level) - 2,3))) calc(1px*(clamp(0,var(--_level),4) + 2*clamp(0,var(--_level) - 4,1))) var(--_shadow-color);opacity:.15}
+`;var or=class extends _t{};or.styles=[oo];or=s([E("md-elevation")],or);var io=Symbol("attachableController"),no;no=new MutationObserver(o=>{for(let e of o)e.target[io]?.hostConnected()});var Le=class{get htmlFor(){return this.host.getAttribute("for")}set htmlFor(e){e===null?this.host.removeAttribute("for"):this.host.setAttribute("for",e)}get control(){return this.host.hasAttribute("for")?!this.htmlFor||!this.host.isConnected?null:this.host.getRootNode().querySelector(`#${this.htmlFor}`):this.currentControl||this.host.parentElement}set control(e){e?this.attach(e):this.detach()}constructor(e,t){this.host=e,this.onControlChange=t,this.currentControl=null,e.addController(this),e[io]=this,no?.observe(e,{attributeFilter:["for"]})}attach(e){e!==this.currentControl&&(this.setCurrentControl(e),this.host.removeAttribute("for"))}detach(){this.setCurrentControl(null),this.host.setAttribute("for","")}hostConnected(){this.setCurrentControl(this.control)}hostDisconnected(){this.setCurrentControl(null)}setCurrentControl(e){this.onControlChange(this.currentControl,e),this.currentControl=e}};var Jo=["focusin","focusout","pointerdown"],De=class extends b{constructor(){super(...arguments),this.visible=!1,this.inward=!1,this.attachableController=new Le(this,this.onControlChange.bind(this))}get htmlFor(){return this.attachableController.htmlFor}set htmlFor(e){this.attachableController.htmlFor=e}get control(){return this.attachableController.control}set control(e){this.attachableController.control=e}attach(e){this.attachableController.attach(e)}detach(){this.attachableController.detach()}connectedCallback(){super.connectedCallback(),this.setAttribute("aria-hidden","true")}handleEvent(e){if(!e[so]){switch(e.type){default:return;case"focusin":this.visible=this.control?.matches(":focus-visible")??!1;break;case"focusout":case"pointerdown":this.visible=!1;break}e[so]=!0}}onControlChange(e,t){if(!!1)for(let r of Jo)e?.removeEventListener(r,this),t?.addEventListener(r,this)}update(e){e.has("visible")&&this.dispatchEvent(new Event("visibility-changed")),super.update(e)}};s([l({type:Boolean,reflect:!0})],De.prototype,"visible",void 0);s([l({type:Boolean,reflect:!0})],De.prototype,"inward",void 0);var so=Symbol("handledByFocusRing");var ao=g`:host{animation-delay:0s,calc(var(--md-focus-ring-duration, 600ms)*.25);animation-duration:calc(var(--md-focus-ring-duration, 600ms)*.25),calc(var(--md-focus-ring-duration, 600ms)*.75);animation-timing-function:cubic-bezier(0.2, 0, 0, 1);box-sizing:border-box;color:var(--md-focus-ring-color, var(--md-sys-color-secondary, #625b71));display:none;pointer-events:none;position:absolute}:host([visible]){display:flex}:host(:not([inward])){animation-name:outward-grow,outward-shrink;border-end-end-radius:calc(var(--md-focus-ring-shape-end-end, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) + var(--md-focus-ring-outward-offset, 2px));border-end-start-radius:calc(var(--md-focus-ring-shape-end-start, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) + var(--md-focus-ring-outward-offset, 2px));border-start-end-radius:calc(var(--md-focus-ring-shape-start-end, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) + var(--md-focus-ring-outward-offset, 2px));border-start-start-radius:calc(var(--md-focus-ring-shape-start-start, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) + var(--md-focus-ring-outward-offset, 2px));inset:calc(-1*var(--md-focus-ring-outward-offset, 2px));outline:var(--md-focus-ring-width, 3px) solid currentColor}:host([inward]){animation-name:inward-grow,inward-shrink;border-end-end-radius:calc(var(--md-focus-ring-shape-end-end, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) - var(--md-focus-ring-inward-offset, 0px));border-end-start-radius:calc(var(--md-focus-ring-shape-end-start, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) - var(--md-focus-ring-inward-offset, 0px));border-start-end-radius:calc(var(--md-focus-ring-shape-start-end, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) - var(--md-focus-ring-inward-offset, 0px));border-start-start-radius:calc(var(--md-focus-ring-shape-start-start, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) - var(--md-focus-ring-inward-offset, 0px));border:var(--md-focus-ring-width, 3px) solid currentColor;inset:var(--md-focus-ring-inward-offset, 0px)}@keyframes outward-grow{from{outline-width:0}to{outline-width:var(--md-focus-ring-active-width, 8px)}}@keyframes outward-shrink{from{outline-width:var(--md-focus-ring-active-width, 8px)}}@keyframes inward-grow{from{border-width:0}to{border-width:var(--md-focus-ring-active-width, 8px)}}@keyframes inward-shrink{from{border-width:var(--md-focus-ring-active-width, 8px)}}@media(prefers-reduced-motion){:host{animation:none}}
+`;var ir=class extends De{};ir.styles=[ao];ir=s([E("md-focus-ring")],ir);function nr(o,e=ae){let t=Ge(o,e);return t&&(t.tabIndex=0,t.focus()),t}function sr(o,e=ae){let t=ar(o,e);return t&&(t.tabIndex=0,t.focus()),t}function ve(o,e=ae){for(let t=0;t=0;t--){let r=o[t];if(e(r))return r}return null}function Qo(o,e,t=ae,r=!0){for(let i=1;ie&&!r)return null;let a=o[n];if(t(a))return a}return o[e]?o[e]:null}function lr(o,e,t=ae,r=!0){if(e){let i=Qo(o,e.index,t,r);return i&&(i.tabIndex=0,i.focus()),i}else return nr(o,t)}function dr(o,e,t=ae,r=!0){if(e){let i=ei(o,e.index,t,r);return i&&(i.tabIndex=0,i.focus()),i}else return sr(o,t)}function ae(o){return!o.disabled}var B={ArrowDown:"ArrowDown",ArrowLeft:"ArrowLeft",ArrowUp:"ArrowUp",ArrowRight:"ArrowRight",Home:"Home",End:"End"},wt=class{constructor(e){this.handleKeydown=m=>{let f=m.key;if(m.defaultPrevented||!this.isNavigableKey(f))return;let x=this.items;if(!x.length)return;let y=ve(x,this.isActivatable);m.preventDefault();let I=this.isRtl(),w=I?B.ArrowRight:B.ArrowLeft,O=I?B.ArrowLeft:B.ArrowRight,z=null;switch(f){case B.ArrowDown:case O:z=lr(x,y,this.isActivatable,this.wrapNavigation());break;case B.ArrowUp:case w:z=dr(x,y,this.isActivatable,this.wrapNavigation());break;case B.Home:z=nr(x,this.isActivatable);break;case B.End:z=sr(x,this.isActivatable);break;default:break}z&&y&&y.item!==z&&(y.item.tabIndex=-1)},this.onDeactivateItems=()=>{let m=this.items;for(let f of m)this.deactivateItem(f)},this.onRequestActivation=m=>{this.onDeactivateItems();let f=m.target;this.activateItem(f),f.focus()},this.onSlotchange=()=>{let m=this.items,f=!1;for(let y of m){if(!y.disabled&&y.tabIndex>-1&&!f){f=!0,y.tabIndex=0;continue}y.tabIndex=-1}if(f)return;let x=Ge(m,this.isActivatable);x&&(x.tabIndex=0)};let{isItem:t,getPossibleItems:r,isRtl:i,deactivateItem:n,activateItem:a,isNavigableKey:d,isActivatable:c,wrapNavigation:h}=e;this.isItem=t,this.getPossibleItems=r,this.isRtl=i,this.deactivateItem=n,this.activateItem=a,this.isNavigableKey=d,this.isActivatable=c,this.wrapNavigation=h??(()=>!0)}get items(){let e=this.getPossibleItems(),t=[];for(let r of e){if(this.isItem(r)){t.push(r);continue}let n=r.item;n&&this.isItem(n)&&t.push(n)}return t}activateNextItem(){let e=this.items,t=ve(e,this.isActivatable);return t&&(t.item.tabIndex=-1),lr(e,t,this.isActivatable,this.wrapNavigation())}activatePreviousItem(){let e=this.items,t=ve(e,this.isActivatable);return t&&(t.item.tabIndex=-1),dr(e,t,this.isActivatable,this.wrapNavigation())}};function ti(o,e){return new CustomEvent("close-menu",{bubbles:!0,composed:!0,detail:{initiator:o,reason:e,itemPath:[o]}})}var pr=ti;var cr={SPACE:"Space",ENTER:"Enter"},Et={CLICK_SELECTION:"click-selection",KEYDOWN:"keydown"},ri={ESCAPE:"Escape",SPACE:cr.SPACE,ENTER:cr.ENTER};function At(o){return Object.values(ri).some(e=>e===o)}function lo(o){return Object.values(cr).some(e=>e===o)}function Ye(o,e){let t=new Event("md-contains",{bubbles:!0,composed:!0}),r=[],i=a=>{r=a.composedPath()};return e.addEventListener("md-contains",i),o.dispatchEvent(t),e.removeEventListener("md-contains",i),r.length>0}var K={NONE:"none",LIST_ROOT:"list-root",FIRST_ITEM:"first-item",LAST_ITEM:"last-item"};var Xe={END_START:"end-start",END_END:"end-end",START_START:"start-start",START_END:"start-end"},Ct=class{constructor(e,t){this.host=e,this.getProperties=t,this.surfaceStylesInternal={display:"none"},this.lastValues={isOpen:!1},this.host.addController(this)}get surfaceStyles(){return this.surfaceStylesInternal}async position(){let{surfaceEl:e,anchorEl:t,anchorCorner:r,surfaceCorner:i,positioning:n,xOffset:a,yOffset:d,disableBlockFlip:c,disableInlineFlip:h,repositionStrategy:m}=this.getProperties(),f=r.toLowerCase().trim(),x=i.toLowerCase().trim();if(!e||!t)return;let y=window.innerWidth,I=window.innerHeight,w=document.createElement("div");w.style.opacity="0",w.style.position="fixed",w.style.display="block",w.style.inset="0",document.body.appendChild(w);let O=w.getBoundingClientRect();w.remove();let z=window.innerHeight-O.bottom,C=window.innerWidth-O.right;this.surfaceStylesInternal={display:"block",opacity:"0"},this.host.requestUpdate(),await this.host.updateComplete,e.popover&&e.isConnected&&e.showPopover();let V=e.getSurfacePositionClientRect?e.getSurfacePositionClientRect():e.getBoundingClientRect(),F=t.getSurfacePositionClientRect?t.getSurfacePositionClientRect():t.getBoundingClientRect(),[L,le]=x.split("-"),[de,be]=f.split("-"),Ze=getComputedStyle(e).direction==="ltr",{blockInset:Ce,blockOutOfBoundsCorrection:J,surfaceBlockProperty:Ar}=this.calculateBlock({surfaceRect:V,anchorRect:F,anchorBlock:de,surfaceBlock:L,yOffset:d,positioning:n,windowInnerHeight:I,blockScrollbarHeight:z});if(J&&!c){let Mt=L==="start"?"end":"start",Nt=de==="start"?"end":"start",te=this.calculateBlock({surfaceRect:V,anchorRect:F,anchorBlock:Nt,surfaceBlock:Mt,yOffset:d,positioning:n,windowInnerHeight:I,blockScrollbarHeight:z});J>te.blockOutOfBoundsCorrection&&(Ce=te.blockInset,J=te.blockOutOfBoundsCorrection,Ar=te.surfaceBlockProperty)}let{inlineInset:Je,inlineOutOfBoundsCorrection:$e,surfaceInlineProperty:Cr}=this.calculateInline({surfaceRect:V,anchorRect:F,anchorInline:be,surfaceInline:le,xOffset:a,positioning:n,isLTR:Ze,windowInnerWidth:y,inlineScrollbarWidth:C});if($e&&!h){let Mt=le==="start"?"end":"start",Nt=be==="start"?"end":"start",te=this.calculateInline({surfaceRect:V,anchorRect:F,anchorInline:Nt,surfaceInline:Mt,xOffset:a,positioning:n,isLTR:Ze,windowInnerWidth:y,inlineScrollbarWidth:C});Math.abs($e)>Math.abs(te.inlineOutOfBoundsCorrection)&&(Je=te.inlineInset,$e=te.inlineOutOfBoundsCorrection,Cr=te.surfaceInlineProperty)}m==="move"&&(Ce=Ce-J,Je=Je-$e),this.surfaceStylesInternal={display:"block",opacity:"1",[Ar]:`${Ce}px`,[Cr]:`${Je}px`},m==="resize"&&(J&&(this.surfaceStylesInternal.height=`${V.height-J}px`),$e&&(this.surfaceStylesInternal.width=`${V.width-$e}px`)),this.host.requestUpdate()}calculateBlock(e){let{surfaceRect:t,anchorRect:r,anchorBlock:i,surfaceBlock:n,yOffset:a,positioning:d,windowInnerHeight:c,blockScrollbarHeight:h}=e,m=d==="fixed"||d==="document"?1:0,f=d==="document"?1:0,x=n==="start"?1:0,y=n==="end"?1:0,w=(i!==n?1:0)*r.height+a,O=x*r.top+y*(c-r.bottom-h),z=x*window.scrollY-y*window.scrollY,C=Math.abs(Math.min(0,c-O-w-t.height));return{blockInset:m*O+f*z+w,blockOutOfBoundsCorrection:C,surfaceBlockProperty:n==="start"?"inset-block-start":"inset-block-end"}}calculateInline(e){let{isLTR:t,surfaceInline:r,anchorInline:i,anchorRect:n,surfaceRect:a,xOffset:d,positioning:c,windowInnerWidth:h,inlineScrollbarWidth:m}=e,f=c==="fixed"||c==="document"?1:0,x=c==="document"?1:0,y=t?1:0,I=t?0:1,w=r==="start"?1:0,O=r==="end"?1:0,C=(i!==r?1:0)*n.width+d,V=w*n.left+O*(h-n.right-m),F=w*(h-n.right-m)+O*n.left,L=y*V+I*F,le=w*window.scrollX-O*window.scrollX,de=O*window.scrollX-w*window.scrollX,be=y*le+I*de,Ze=Math.abs(Math.min(0,h-L-C-a.width)),Ce=f*L+C+x*be,J=r==="start"?"inset-inline-start":"inset-inline-end";return(c==="document"||c==="fixed")&&(r==="start"&&t||r==="end"&&!t?J="left":J="right"),{inlineInset:Ce,inlineOutOfBoundsCorrection:Ze,surfaceInlineProperty:J}}hostUpdate(){this.onUpdate()}hostUpdated(){this.onUpdate()}async onUpdate(){let e=this.getProperties(),t=!1;for(let[a,d]of Object.entries(e))if(t=t||d!==this.lastValues[a],t)break;let r=this.lastValues.isOpen!==e.isOpen,i=!!e.anchorEl,n=!!e.surfaceEl;t&&i&&n&&(this.lastValues.isOpen=e.isOpen,e.isOpen?(this.lastValues=e,await this.position(),e.onOpen()):r&&(await e.beforeClose(),this.close(),e.onClose()))}close(){this.surfaceStylesInternal={display:"none"},this.host.requestUpdate();let e=this.getProperties().surfaceEl;e?.popover&&e?.isConnected&&e.hidePopover()}};var Y={INDEX:0,ITEM:1,TEXT:2},$t=class{constructor(e){this.getProperties=e,this.typeaheadRecords=[],this.typaheadBuffer="",this.cancelTypeaheadTimeout=0,this.isTypingAhead=!1,this.lastActiveRecord=null,this.onKeydown=t=>{this.isTypingAhead?this.typeahead(t):this.beginTypeahead(t)},this.endTypeahead=()=>{this.isTypingAhead=!1,this.typaheadBuffer="",this.typeaheadRecords=[]}}get items(){return this.getProperties().getItems()}get active(){return this.getProperties().active}beginTypeahead(e){this.active&&(e.code==="Space"||e.code==="Enter"||e.code.startsWith("Arrow")||e.code==="Escape"||(this.isTypingAhead=!0,this.typeaheadRecords=this.items.map((t,r)=>[r,t,t.typeaheadText.trim().toLowerCase()]),this.lastActiveRecord=this.typeaheadRecords.find(t=>t[Y.ITEM].tabIndex===0)??null,this.lastActiveRecord&&(this.lastActiveRecord[Y.ITEM].tabIndex=-1),this.typeahead(e)))}typeahead(e){if(e.defaultPrevented)return;if(clearTimeout(this.cancelTypeaheadTimeout),e.code==="Enter"||e.code.startsWith("Arrow")||e.code==="Escape"){this.endTypeahead(),this.lastActiveRecord&&(this.lastActiveRecord[Y.ITEM].tabIndex=-1);return}e.code==="Space"&&e.preventDefault(),this.cancelTypeaheadTimeout=setTimeout(this.endTypeahead,this.getProperties().typeaheadBufferTime),this.typaheadBuffer+=e.key.toLowerCase();let t=this.lastActiveRecord?this.lastActiveRecord[Y.INDEX]:-1,r=this.typeaheadRecords.length,i=c=>(c[Y.INDEX]+r-t)%r,n=this.typeaheadRecords.filter(c=>!c[Y.ITEM].disabled&&c[Y.TEXT].startsWith(this.typaheadBuffer)).sort((c,h)=>i(c)-i(h));if(n.length===0){clearTimeout(this.cancelTypeaheadTimeout),this.lastActiveRecord&&(this.lastActiveRecord[Y.ITEM].tabIndex=-1),this.endTypeahead();return}let a=this.typaheadBuffer.length===1,d;this.lastActiveRecord===n[0]&&a?d=n[1]??n[0]:d=n[0],this.lastActiveRecord&&(this.lastActiveRecord[Y.ITEM].tabIndex=-1),this.lastActiveRecord=d,d[Y.ITEM].tabIndex=0,d[Y.ITEM].focus()}};var ur=200,co=new Set([B.ArrowDown,B.ArrowUp,B.Home,B.End]),oi=new Set([B.ArrowLeft,B.ArrowRight,...co]);function ii(o=document){let e=o.activeElement;for(;e&&e?.shadowRoot?.activeElement;)e=e.shadowRoot.activeElement;return e}var T=class extends b{get openDirection(){return this.menuCorner.split("-")[0]==="start"?"DOWN":"UP"}get anchorElement(){return this.anchor?this.getRootNode().querySelector(`#${this.anchor}`):this.currentAnchorElement}set anchorElement(e){this.currentAnchorElement=e,this.requestUpdate("anchorElement")}constructor(){super(),this.anchor="",this.positioning="absolute",this.quick=!1,this.hasOverflow=!1,this.open=!1,this.xOffset=0,this.yOffset=0,this.noHorizontalFlip=!1,this.noVerticalFlip=!1,this.typeaheadDelay=ur,this.anchorCorner=Xe.END_START,this.menuCorner=Xe.START_START,this.stayOpenOnOutsideClick=!1,this.stayOpenOnFocusout=!1,this.skipRestoreFocus=!1,this.defaultFocus=K.FIRST_ITEM,this.noNavigationWrap=!1,this.typeaheadActive=!0,this.isSubmenu=!1,this.pointerPath=[],this.isRepositioning=!1,this.openCloseAnimationSignal=Wr(),this.listController=new wt({isItem:e=>e.hasAttribute("md-menu-item"),getPossibleItems:()=>this.slotItems,isRtl:()=>getComputedStyle(this).direction==="rtl",deactivateItem:e=>{e.selected=!1,e.tabIndex=-1},activateItem:e=>{e.selected=!0,e.tabIndex=0},isNavigableKey:e=>{if(!this.isSubmenu)return oi.has(e);let r=getComputedStyle(this).direction==="rtl"?B.ArrowLeft:B.ArrowRight;return e===r?!0:co.has(e)},wrapNavigation:()=>!this.noNavigationWrap}),this.lastFocusedElement=null,this.typeaheadController=new $t(()=>({getItems:()=>this.items,typeaheadBufferTime:this.typeaheadDelay,active:this.typeaheadActive})),this.currentAnchorElement=null,this.internals=this.attachInternals(),this.menuPositionController=new Ct(this,()=>({anchorCorner:this.anchorCorner,surfaceCorner:this.menuCorner,surfaceEl:this.surfaceEl,anchorEl:this.anchorElement,positioning:this.positioning==="popover"?"document":this.positioning,isOpen:this.open,xOffset:this.xOffset,yOffset:this.yOffset,disableBlockFlip:this.noVerticalFlip,disableInlineFlip:this.noHorizontalFlip,onOpen:this.onOpened,beforeClose:this.beforeClose,onClose:this.onClosed,repositionStrategy:this.hasOverflow&&this.positioning!=="popover"?"move":"resize"})),this.onWindowResize=()=>{this.isRepositioning||this.positioning!=="document"&&this.positioning!=="fixed"&&this.positioning!=="popover"||(this.isRepositioning=!0,this.reposition(),this.isRepositioning=!1)},this.handleFocusout=async e=>{let t=this.anchorElement;if(this.stayOpenOnFocusout||!this.open||this.pointerPath.includes(t))return;if(e.relatedTarget){if(Ye(e.relatedTarget,this)||this.pointerPath.length!==0&&Ye(e.relatedTarget,t))return}else if(this.pointerPath.includes(this))return;let r=this.skipRestoreFocus;this.skipRestoreFocus=!0,this.close(),await this.updateComplete,this.skipRestoreFocus=r},this.onOpened=async()=>{this.lastFocusedElement=ii();let e=this.items,t=ve(e);t&&this.defaultFocus!==K.NONE&&(t.item.tabIndex=-1);let r=!this.quick;switch(this.quick?this.dispatchEvent(new Event("opening")):r=!!await this.animateOpen(),this.defaultFocus){case K.FIRST_ITEM:let i=Ge(e);i&&(i.tabIndex=0,i.focus(),await i.updateComplete);break;case K.LAST_ITEM:let n=ar(e);n&&(n.tabIndex=0,n.focus(),await n.updateComplete);break;case K.LIST_ROOT:this.focus();break;default:case K.NONE:break}r||this.dispatchEvent(new Event("opened"))},this.beforeClose=async()=>{this.open=!1,this.skipRestoreFocus||this.lastFocusedElement?.focus?.(),this.quick||await this.animateClose()},this.onClosed=()=>{this.quick&&(this.dispatchEvent(new Event("closing")),this.dispatchEvent(new Event("closed")))},this.onWindowPointerdown=e=>{this.pointerPath=e.composedPath()},this.onDocumentClick=e=>{if(!this.open)return;let t=e.composedPath();!this.stayOpenOnOutsideClick&&!t.includes(this)&&!t.includes(this.anchorElement)&&(this.open=!1)},this.internals.role="menu",this.addEventListener("keydown",this.handleKeydown),this.addEventListener("keydown",this.captureKeydown,{capture:!0}),this.addEventListener("focusout",this.handleFocusout)}get items(){return this.listController.items}willUpdate(e){if(e.has("open")){if(this.open){this.removeAttribute("aria-hidden");return}this.setAttribute("aria-hidden","true")}}update(e){e.has("open")&&(this.open?this.setUpGlobalEventListeners():this.cleanUpGlobalEventListeners()),e.has("positioning")&&this.positioning==="popover"&&!this.showPopover&&(this.positioning="fixed"),super.update(e)}connectedCallback(){super.connectedCallback(),this.open&&this.setUpGlobalEventListeners()}disconnectedCallback(){super.disconnectedCallback(),this.cleanUpGlobalEventListeners()}getBoundingClientRect(){return this.surfaceEl?this.surfaceEl.getBoundingClientRect():super.getBoundingClientRect()}getClientRects(){return this.surfaceEl?this.surfaceEl.getClientRects():super.getClientRects()}render(){return this.renderSurface()}renderSurface(){return u`
+
+ `}renderMenuItems(){return u` `}renderElevation(){return u` `}getSurfaceClasses(){return{open:this.open,fixed:this.positioning==="fixed","has-overflow":this.hasOverflow}}captureKeydown(e){e.target===this&&!e.defaultPrevented&&At(e.code)&&(e.preventDefault(),this.close()),this.typeaheadController.onKeydown(e)}async animateOpen(){let e=this.surfaceEl,t=this.slotEl;if(!e||!t)return!0;let r=this.openDirection;this.dispatchEvent(new Event("opening")),e.classList.toggle("animating",!0);let i=this.openCloseAnimationSignal.start(),n=e.offsetHeight,a=r==="UP",d=this.items,c=500,h=50,m=250,f=(c-m)/d.length,x=e.animate([{height:"0px"},{height:`${n}px`}],{duration:c,easing:ie.EMPHASIZED}),y=t.animate([{transform:a?`translateY(-${n}px)`:""},{transform:""}],{duration:c,easing:ie.EMPHASIZED}),I=e.animate([{opacity:0},{opacity:1}],h),w=[];for(let C=0;C{F.classList.toggle("md-menu-hidden",!1)}),w.push([F,L])}let O=C=>{},z=new Promise(C=>{O=C});return i.addEventListener("abort",()=>{x.cancel(),y.cancel(),I.cancel(),w.forEach(([C,V])=>{C.classList.toggle("md-menu-hidden",!1),V.cancel()}),O(!0)}),x.addEventListener("finish",()=>{e.classList.toggle("animating",!1),this.openCloseAnimationSignal.finish(),O(!1)}),await z}animateClose(){let e,t=new Promise(L=>{e=L}),r=this.surfaceEl,i=this.slotEl;if(!r||!i)return e(!1),t;let a=this.openDirection==="UP";this.dispatchEvent(new Event("closing")),r.classList.toggle("animating",!0);let d=this.openCloseAnimationSignal.start(),c=r.offsetHeight,h=this.items,m=150,f=50,x=m-f,y=50,I=50,w=.35,O=(m-I-y)/h.length,z=r.animate([{height:`${c}px`},{height:`${c*w}px`}],{duration:m,easing:ie.EMPHASIZED_ACCELERATE}),C=i.animate([{transform:""},{transform:a?`translateY(-${c*(1-w)}px)`:""}],{duration:m,easing:ie.EMPHASIZED_ACCELERATE}),V=r.animate([{opacity:1},{opacity:0}],{duration:f,delay:x}),F=[];for(let L=0;L{de.classList.toggle("md-menu-hidden",!0)}),F.push([de,be])}return d.addEventListener("abort",()=>{z.cancel(),C.cancel(),V.cancel(),F.forEach(([L,le])=>{le.cancel(),L.classList.toggle("md-menu-hidden",!1)}),e(!1)}),z.addEventListener("finish",()=>{r.classList.toggle("animating",!1),F.forEach(([L])=>{L.classList.toggle("md-menu-hidden",!1)}),this.openCloseAnimationSignal.finish(),this.dispatchEvent(new Event("closed")),e(!0)}),t}handleKeydown(e){this.pointerPath=[],this.listController.handleKeydown(e)}setUpGlobalEventListeners(){document.addEventListener("click",this.onDocumentClick,{capture:!0}),window.addEventListener("pointerdown",this.onWindowPointerdown),document.addEventListener("resize",this.onWindowResize,{passive:!0}),window.addEventListener("resize",this.onWindowResize,{passive:!0})}cleanUpGlobalEventListeners(){document.removeEventListener("click",this.onDocumentClick,{capture:!0}),window.removeEventListener("pointerdown",this.onWindowPointerdown),document.removeEventListener("resize",this.onWindowResize),window.removeEventListener("resize",this.onWindowResize)}onCloseMenu(){this.close()}onDeactivateItems(e){e.stopPropagation(),this.listController.onDeactivateItems()}onRequestActivation(e){e.stopPropagation(),this.listController.onRequestActivation(e)}handleDeactivateTypeahead(e){e.stopPropagation(),this.typeaheadActive=!1}handleActivateTypeahead(e){e.stopPropagation(),this.typeaheadActive=!0}handleStayOpenOnFocusout(e){e.stopPropagation(),this.stayOpenOnFocusout=!0}handleCloseOnFocusout(e){e.stopPropagation(),this.stayOpenOnFocusout=!1}close(){this.open=!1,this.slotItems.forEach(t=>{t.close?.()})}show(){this.open=!0}activateNextItem(){return this.listController.activateNextItem()??null}activatePreviousItem(){return this.listController.activatePreviousItem()??null}reposition(){this.open&&this.menuPositionController.position()}};s([S(".menu")],T.prototype,"surfaceEl",void 0);s([S("slot")],T.prototype,"slotEl",void 0);s([l()],T.prototype,"anchor",void 0);s([l()],T.prototype,"positioning",void 0);s([l({type:Boolean})],T.prototype,"quick",void 0);s([l({type:Boolean,attribute:"has-overflow"})],T.prototype,"hasOverflow",void 0);s([l({type:Boolean,reflect:!0})],T.prototype,"open",void 0);s([l({type:Number,attribute:"x-offset"})],T.prototype,"xOffset",void 0);s([l({type:Number,attribute:"y-offset"})],T.prototype,"yOffset",void 0);s([l({type:Boolean,attribute:"no-horizontal-flip"})],T.prototype,"noHorizontalFlip",void 0);s([l({type:Boolean,attribute:"no-vertical-flip"})],T.prototype,"noVerticalFlip",void 0);s([l({type:Number,attribute:"typeahead-delay"})],T.prototype,"typeaheadDelay",void 0);s([l({attribute:"anchor-corner"})],T.prototype,"anchorCorner",void 0);s([l({attribute:"menu-corner"})],T.prototype,"menuCorner",void 0);s([l({type:Boolean,attribute:"stay-open-on-outside-click"})],T.prototype,"stayOpenOnOutsideClick",void 0);s([l({type:Boolean,attribute:"stay-open-on-focusout"})],T.prototype,"stayOpenOnFocusout",void 0);s([l({type:Boolean,attribute:"skip-restore-focus"})],T.prototype,"skipRestoreFocus",void 0);s([l({attribute:"default-focus"})],T.prototype,"defaultFocus",void 0);s([l({type:Boolean,attribute:"no-navigation-wrap"})],T.prototype,"noNavigationWrap",void 0);s([N({flatten:!0})],T.prototype,"slotItems",void 0);s([k()],T.prototype,"typeaheadActive",void 0);var po=g`:host{--md-elevation-level: var(--md-menu-container-elevation, 2);--md-elevation-shadow-color: var(--md-menu-container-shadow-color, var(--md-sys-color-shadow, #000));min-width:112px;color:unset;display:contents}md-focus-ring{--md-focus-ring-shape: var(--md-menu-container-shape, var(--md-sys-shape-corner-extra-small, 4px))}.menu{border-radius:var(--md-menu-container-shape, var(--md-sys-shape-corner-extra-small, 4px));display:none;inset:auto;border:none;padding:0px;overflow:visible;background-color:rgba(0,0,0,0);color:inherit;opacity:0;z-index:20;position:absolute;user-select:none;max-height:inherit;height:inherit;min-width:inherit;max-width:inherit;scrollbar-width:inherit}.menu::backdrop{display:none}.fixed{position:fixed}.items{display:block;list-style-type:none;margin:0;outline:none;box-sizing:border-box;background-color:var(--md-menu-container-color, var(--md-sys-color-surface-container, #f3edf7));height:inherit;max-height:inherit;overflow:auto;min-width:inherit;max-width:inherit;border-radius:inherit;scrollbar-width:inherit}.item-padding{padding-block:var(--md-menu-top-space, 8px) var(--md-menu-bottom-space, 8px)}.has-overflow:not([popover]) .items{overflow:visible}.has-overflow.animating .items,.animating .items{overflow:hidden}.has-overflow.animating .items{pointer-events:none}.animating ::slotted(.md-menu-hidden){opacity:0}slot{display:block;height:inherit;max-height:inherit}::slotted(:is(md-divider,[role=separator])){margin:8px 0}@media(forced-colors: active){.menu{border-style:solid;border-color:CanvasText;border-width:1px}}
+`;var hr=class extends T{};hr.styles=[po];hr=s([E("md-menu")],hr);var Tt=class extends me{computeValidity(e){return this.selectControl||(this.selectControl=document.createElement("select")),Se(u` `,this.selectControl),this.selectControl.value=e.value,this.selectControl.required=e.required,{validity:this.selectControl.validity,validationMessage:this.selectControl.validationMessage}}equals(e,t){return e.value===t.value&&e.required===t.required}copy({value:e,required:t}){return{value:e,required:t}}};function uo(o){let e=[];for(let t=0;te)}get hasError(){return this.error||this.nativeError}constructor(){super(),this.quick=!1,this.required=!1,this.errorText="",this.label="",this.noAsterisk=!1,this.supportingText="",this.error=!1,this.menuPositioning="popover",this.clampMenuWidth=!1,this.typeaheadDelay=ur,this.hasLeadingIcon=!1,this.displayText="",this.menuAlign="start",this[ho]="",this.lastUserSetValue=null,this.lastUserSetSelectedIndex=null,this.lastSelectedOption=null,this.lastSelectedOptionRecords=[],this.nativeError=!1,this.nativeErrorText="",this.focused=!1,this.open=!1,this.defaultFocus=K.NONE,this.prevOpen=this.open,this.selectWidth=0,!!1&&(this.addEventListener("focus",this.handleFocus.bind(this)),this.addEventListener("blur",this.handleBlur.bind(this)))}select(e){let t=this.options.find(r=>r.value===e);t&&this.selectItem(t)}selectIndex(e){let t=this.options[e];t&&this.selectItem(t)}reset(){for(let e of this.options)e.selected=e.hasAttribute("selected");this.updateValueAndDisplayText(),this.nativeError=!1,this.nativeErrorText=""}showPicker(){this.open=!0}[(ho=St,Pe)](e){e?.preventDefault();let t=this.getErrorText();this.nativeError=!!e,this.nativeErrorText=this.validationMessage,t===this.getErrorText()&&this.field?.reannounceError()}update(e){if(this.hasUpdated||this.initUserSelection(),this.prevOpen!==this.open&&this.open){let t=this.getBoundingClientRect();this.selectWidth=t.width}this.prevOpen=this.open,super.update(e)}render(){return u`
+
+ ${this.renderField()} ${this.renderMenu()}
+
+ `}async firstUpdated(e){await this.menu?.updateComplete,this.lastSelectedOptionRecords.length||this.initUserSelection(),!this.lastSelectedOptionRecords.length&&!!1&&!this.options.length&&setTimeout(()=>{this.updateValueAndDisplayText()}),super.firstUpdated(e)}getRenderClasses(){return{disabled:this.disabled,error:this.error,open:this.open}}renderField(){let e=this.ariaLabel||this.label;return ue`
+ <${this.fieldTag}
+ aria-haspopup="listbox"
+ role="combobox"
+ part="field"
+ id="field"
+ tabindex=${this.disabled?"-1":"0"}
+ aria-label=${e||p}
+ aria-describedby="description"
+ aria-expanded=${this.open?"true":"false"}
+ aria-controls="listbox"
+ class="field"
+ label=${this.label}
+ ?no-asterisk=${this.noAsterisk}
+ .focused=${this.focused||this.open}
+ .populated=${!!this.displayText}
+ .disabled=${this.disabled}
+ .required=${this.required}
+ .error=${this.hasError}
+ ?has-start=${this.hasLeadingIcon}
+ has-end
+ supporting-text=${this.supportingText}
+ error-text=${this.getErrorText()}
+ @keydown=${this.handleKeydown}
+ @click=${this.handleClick}>
+ ${this.renderFieldContent()}
+
+ ${this.fieldTag}>`}renderFieldContent(){return[this.renderLeadingIcon(),this.renderLabel(),this.renderTrailingIcon()]}renderLeadingIcon(){return u`
+
+
+
+ `}renderTrailingIcon(){return u`
+
+
+
+
+
+
+
+
+ `}renderLabel(){return u`${this.displayText||u` `}
`}renderMenu(){let e=this.label||this.ariaLabel;return u``}renderMenuContent(){return u` `}handleKeydown(e){if(this.open||this.disabled||!this.menu)return;let t=this.menu.typeaheadController,r=e.code==="Space"||e.code==="ArrowDown"||e.code==="ArrowUp"||e.code==="End"||e.code==="Home"||e.code==="Enter";if(!t.isTypingAhead&&r){switch(e.preventDefault(),this.open=!0,e.code){case"Space":case"ArrowDown":case"Enter":this.defaultFocus=K.NONE;break;case"End":this.defaultFocus=K.LAST_ITEM;break;case"ArrowUp":case"Home":this.defaultFocus=K.FIRST_ITEM;break;default:break}return}if(e.key.length===1){t.onKeydown(e),e.preventDefault();let{lastActiveRecord:n}=t;if(!n)return;this.labelEl?.setAttribute?.("aria-live","polite"),this.selectItem(n[Y.ITEM])&&this.dispatchInteractionEvents()}}handleClick(){this.open=!this.open}handleFocus(){this.focused=!0}handleBlur(){this.focused=!1}handleFocusout(e){e.relatedTarget&&Ye(e.relatedTarget,this)||(this.open=!1)}getSelectedOptions(){if(!this.menu)return this.lastSelectedOptionRecords=[],null;let e=this.menu.items;return this.lastSelectedOptionRecords=uo(e),this.lastSelectedOptionRecords}async getUpdateComplete(){return await this.menu?.updateComplete,super.getUpdateComplete()}updateValueAndDisplayText(){let e=this.getSelectedOptions()??[],t=!1;if(e.length){let[r]=e[0];t=this.lastSelectedOption!==r,this.lastSelectedOption=r,this[St]=r.value,this.displayText=r.displayText}else t=this.lastSelectedOption!==null,this.lastSelectedOption=null,this[St]="",this.displayText="";return t}async handleOpening(e){if(this.labelEl?.removeAttribute?.("aria-live"),this.redispatchEvent(e),this.defaultFocus!==K.NONE)return;let t=this.menu.items,r=ve(t)?.item,[i]=this.lastSelectedOptionRecords[0]??[null];r&&r!==i&&(r.tabIndex=-1),i=i??t[0],i&&(i.tabIndex=0,i.focus())}redispatchEvent(e){ke(this,e)}handleClosed(e){this.open=!1,this.redispatchEvent(e)}handleCloseMenu(e){let t=e.detail.reason,r=e.detail.itemPath[0];this.open=!1;let i=!1;t.kind==="click-selection"?i=this.selectItem(r):t.kind==="keydown"&&lo(t.key)?i=this.selectItem(r):(r.tabIndex=-1,r.blur()),i&&this.dispatchInteractionEvents()}selectItem(e){return(this.getSelectedOptions()??[]).forEach(([r])=>{e!==r&&(r.selected=!1)}),e.selected=!0,this.updateValueAndDisplayText()}handleRequestSelection(e){let t=e.target;this.lastSelectedOptionRecords.some(([r])=>r===t)||this.selectItem(t)}handleRequestDeselection(e){let t=e.target;this.lastSelectedOptionRecords.some(([r])=>r===t)&&this.updateValueAndDisplayText()}initUserSelection(){this.lastUserSetValue&&!this.lastSelectedOptionRecords.length?this.select(this.lastUserSetValue):this.lastUserSetSelectedIndex!==null&&!this.lastSelectedOptionRecords.length?this.selectIndex(this.lastUserSetSelectedIndex):this.updateValueAndDisplayText()}handleIconChange(){this.hasLeadingIcon=this.leadingIcons.length>0}dispatchInteractionEvents(){this.dispatchEvent(new Event("input",{bubbles:!0,composed:!0})),this.dispatchEvent(new Event("change",{bubbles:!0}))}getErrorText(){return this.error?this.errorText:this.nativeErrorText}[se](){return this.value}formResetCallback(){this.reset()}formStateRestoreCallback(e){this.value=e}click(){this.field?.click()}[he](){return new Tt(()=>this)}[fe](){return this.field}};_.shadowRootOptions={...b.shadowRootOptions,delegatesFocus:!0};s([l({type:Boolean})],_.prototype,"quick",void 0);s([l({type:Boolean})],_.prototype,"required",void 0);s([l({type:String,attribute:"error-text"})],_.prototype,"errorText",void 0);s([l()],_.prototype,"label",void 0);s([l({type:Boolean,attribute:"no-asterisk"})],_.prototype,"noAsterisk",void 0);s([l({type:String,attribute:"supporting-text"})],_.prototype,"supportingText",void 0);s([l({type:Boolean,reflect:!0})],_.prototype,"error",void 0);s([l({attribute:"menu-positioning"})],_.prototype,"menuPositioning",void 0);s([l({type:Boolean,attribute:"clamp-menu-width"})],_.prototype,"clampMenuWidth",void 0);s([l({type:Number,attribute:"typeahead-delay"})],_.prototype,"typeaheadDelay",void 0);s([l({type:Boolean,attribute:"has-leading-icon"})],_.prototype,"hasLeadingIcon",void 0);s([l({attribute:"display-text"})],_.prototype,"displayText",void 0);s([l({attribute:"menu-align"})],_.prototype,"menuAlign",void 0);s([l()],_.prototype,"value",null);s([l({type:Number,attribute:"selected-index"})],_.prototype,"selectedIndex",null);s([k()],_.prototype,"nativeError",void 0);s([k()],_.prototype,"nativeErrorText",void 0);s([k()],_.prototype,"focused",void 0);s([k()],_.prototype,"open",void 0);s([k()],_.prototype,"defaultFocus",void 0);s([S(".field")],_.prototype,"field",void 0);s([S("md-menu")],_.prototype,"menu",void 0);s([S("#label")],_.prototype,"labelEl",void 0);s([N({slot:"leading-icon",flatten:!0})],_.prototype,"leadingIcons",void 0);var It=class extends _{constructor(){super(...arguments),this.fieldTag=G`md-outlined-field`}};var fo=g`:host{--_text-field-disabled-input-text-color: var(--md-outlined-select-text-field-disabled-input-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-disabled-input-text-opacity: var(--md-outlined-select-text-field-disabled-input-text-opacity, 0.38);--_text-field-disabled-label-text-color: var(--md-outlined-select-text-field-disabled-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-disabled-label-text-opacity: var(--md-outlined-select-text-field-disabled-label-text-opacity, 0.38);--_text-field-disabled-leading-icon-color: var(--md-outlined-select-text-field-disabled-leading-icon-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-disabled-leading-icon-opacity: var(--md-outlined-select-text-field-disabled-leading-icon-opacity, 0.38);--_text-field-disabled-outline-color: var(--md-outlined-select-text-field-disabled-outline-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-disabled-outline-opacity: var(--md-outlined-select-text-field-disabled-outline-opacity, 0.12);--_text-field-disabled-outline-width: var(--md-outlined-select-text-field-disabled-outline-width, 1px);--_text-field-disabled-supporting-text-color: var(--md-outlined-select-text-field-disabled-supporting-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-disabled-supporting-text-opacity: var(--md-outlined-select-text-field-disabled-supporting-text-opacity, 0.38);--_text-field-disabled-trailing-icon-color: var(--md-outlined-select-text-field-disabled-trailing-icon-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-disabled-trailing-icon-opacity: var(--md-outlined-select-text-field-disabled-trailing-icon-opacity, 0.38);--_text-field-error-focus-input-text-color: var(--md-outlined-select-text-field-error-focus-input-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-error-focus-label-text-color: var(--md-outlined-select-text-field-error-focus-label-text-color, var(--md-sys-color-error, #b3261e));--_text-field-error-focus-leading-icon-color: var(--md-outlined-select-text-field-error-focus-leading-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-error-focus-outline-color: var(--md-outlined-select-text-field-error-focus-outline-color, var(--md-sys-color-error, #b3261e));--_text-field-error-focus-supporting-text-color: var(--md-outlined-select-text-field-error-focus-supporting-text-color, var(--md-sys-color-error, #b3261e));--_text-field-error-focus-trailing-icon-color: var(--md-outlined-select-text-field-error-focus-trailing-icon-color, var(--md-sys-color-error, #b3261e));--_text-field-error-hover-input-text-color: var(--md-outlined-select-text-field-error-hover-input-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-error-hover-label-text-color: var(--md-outlined-select-text-field-error-hover-label-text-color, var(--md-sys-color-on-error-container, #410e0b));--_text-field-error-hover-leading-icon-color: var(--md-outlined-select-text-field-error-hover-leading-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-error-hover-outline-color: var(--md-outlined-select-text-field-error-hover-outline-color, var(--md-sys-color-on-error-container, #410e0b));--_text-field-error-hover-supporting-text-color: var(--md-outlined-select-text-field-error-hover-supporting-text-color, var(--md-sys-color-error, #b3261e));--_text-field-error-hover-trailing-icon-color: var(--md-outlined-select-text-field-error-hover-trailing-icon-color, var(--md-sys-color-on-error-container, #410e0b));--_text-field-error-input-text-color: var(--md-outlined-select-text-field-error-input-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-error-label-text-color: var(--md-outlined-select-text-field-error-label-text-color, var(--md-sys-color-error, #b3261e));--_text-field-error-leading-icon-color: var(--md-outlined-select-text-field-error-leading-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-error-outline-color: var(--md-outlined-select-text-field-error-outline-color, var(--md-sys-color-error, #b3261e));--_text-field-error-supporting-text-color: var(--md-outlined-select-text-field-error-supporting-text-color, var(--md-sys-color-error, #b3261e));--_text-field-error-trailing-icon-color: var(--md-outlined-select-text-field-error-trailing-icon-color, var(--md-sys-color-error, #b3261e));--_text-field-focus-input-text-color: var(--md-outlined-select-text-field-focus-input-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-focus-label-text-color: var(--md-outlined-select-text-field-focus-label-text-color, var(--md-sys-color-primary, #6750a4));--_text-field-focus-leading-icon-color: var(--md-outlined-select-text-field-focus-leading-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-focus-outline-color: var(--md-outlined-select-text-field-focus-outline-color, var(--md-sys-color-primary, #6750a4));--_text-field-focus-outline-width: var(--md-outlined-select-text-field-focus-outline-width, 3px);--_text-field-focus-supporting-text-color: var(--md-outlined-select-text-field-focus-supporting-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-focus-trailing-icon-color: var(--md-outlined-select-text-field-focus-trailing-icon-color, var(--md-sys-color-primary, #6750a4));--_text-field-hover-input-text-color: var(--md-outlined-select-text-field-hover-input-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-hover-label-text-color: var(--md-outlined-select-text-field-hover-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-hover-leading-icon-color: var(--md-outlined-select-text-field-hover-leading-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-hover-outline-color: var(--md-outlined-select-text-field-hover-outline-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-hover-outline-width: var(--md-outlined-select-text-field-hover-outline-width, 1px);--_text-field-hover-supporting-text-color: var(--md-outlined-select-text-field-hover-supporting-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-hover-trailing-icon-color: var(--md-outlined-select-text-field-hover-trailing-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-input-text-color: var(--md-outlined-select-text-field-input-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-input-text-font: var(--md-outlined-select-text-field-input-text-font, var(--md-sys-typescale-body-large-font, var(--md-ref-typeface-plain, Roboto)));--_text-field-input-text-line-height: var(--md-outlined-select-text-field-input-text-line-height, var(--md-sys-typescale-body-large-line-height, 1.5rem));--_text-field-input-text-size: var(--md-outlined-select-text-field-input-text-size, var(--md-sys-typescale-body-large-size, 1rem));--_text-field-input-text-weight: var(--md-outlined-select-text-field-input-text-weight, var(--md-sys-typescale-body-large-weight, var(--md-ref-typeface-weight-regular, 400)));--_text-field-label-text-color: var(--md-outlined-select-text-field-label-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-label-text-font: var(--md-outlined-select-text-field-label-text-font, var(--md-sys-typescale-body-large-font, var(--md-ref-typeface-plain, Roboto)));--_text-field-label-text-line-height: var(--md-outlined-select-text-field-label-text-line-height, var(--md-sys-typescale-body-large-line-height, 1.5rem));--_text-field-label-text-populated-line-height: var(--md-outlined-select-text-field-label-text-populated-line-height, var(--md-sys-typescale-body-small-line-height, 1rem));--_text-field-label-text-populated-size: var(--md-outlined-select-text-field-label-text-populated-size, var(--md-sys-typescale-body-small-size, 0.75rem));--_text-field-label-text-size: var(--md-outlined-select-text-field-label-text-size, var(--md-sys-typescale-body-large-size, 1rem));--_text-field-label-text-weight: var(--md-outlined-select-text-field-label-text-weight, var(--md-sys-typescale-body-large-weight, var(--md-ref-typeface-weight-regular, 400)));--_text-field-leading-icon-color: var(--md-outlined-select-text-field-leading-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-leading-icon-size: var(--md-outlined-select-text-field-leading-icon-size, 24px);--_text-field-outline-color: var(--md-outlined-select-text-field-outline-color, var(--md-sys-color-outline, #79747e));--_text-field-outline-width: var(--md-outlined-select-text-field-outline-width, 1px);--_text-field-supporting-text-color: var(--md-outlined-select-text-field-supporting-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-supporting-text-font: var(--md-outlined-select-text-field-supporting-text-font, var(--md-sys-typescale-body-small-font, var(--md-ref-typeface-plain, Roboto)));--_text-field-supporting-text-line-height: var(--md-outlined-select-text-field-supporting-text-line-height, var(--md-sys-typescale-body-small-line-height, 1rem));--_text-field-supporting-text-size: var(--md-outlined-select-text-field-supporting-text-size, var(--md-sys-typescale-body-small-size, 0.75rem));--_text-field-supporting-text-weight: var(--md-outlined-select-text-field-supporting-text-weight, var(--md-sys-typescale-body-small-weight, var(--md-ref-typeface-weight-regular, 400)));--_text-field-trailing-icon-color: var(--md-outlined-select-text-field-trailing-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-trailing-icon-size: var(--md-outlined-select-text-field-trailing-icon-size, 24px);--_text-field-container-shape-start-start: var(--md-outlined-select-text-field-container-shape-start-start, var(--md-outlined-select-text-field-container-shape, var(--md-sys-shape-corner-extra-small, 4px)));--_text-field-container-shape-start-end: var(--md-outlined-select-text-field-container-shape-start-end, var(--md-outlined-select-text-field-container-shape, var(--md-sys-shape-corner-extra-small, 4px)));--_text-field-container-shape-end-end: var(--md-outlined-select-text-field-container-shape-end-end, var(--md-outlined-select-text-field-container-shape, var(--md-sys-shape-corner-extra-small, 4px)));--_text-field-container-shape-end-start: var(--md-outlined-select-text-field-container-shape-end-start, var(--md-outlined-select-text-field-container-shape, var(--md-sys-shape-corner-extra-small, 4px)));--md-outlined-field-container-shape-end-end: var(--_text-field-container-shape-end-end);--md-outlined-field-container-shape-end-start: var(--_text-field-container-shape-end-start);--md-outlined-field-container-shape-start-end: var(--_text-field-container-shape-start-end);--md-outlined-field-container-shape-start-start: var(--_text-field-container-shape-start-start);--md-outlined-field-content-color: var(--_text-field-input-text-color);--md-outlined-field-content-font: var(--_text-field-input-text-font);--md-outlined-field-content-line-height: var(--_text-field-input-text-line-height);--md-outlined-field-content-size: var(--_text-field-input-text-size);--md-outlined-field-content-weight: var(--_text-field-input-text-weight);--md-outlined-field-disabled-content-color: var(--_text-field-disabled-input-text-color);--md-outlined-field-disabled-content-opacity: var(--_text-field-disabled-input-text-opacity);--md-outlined-field-disabled-label-text-color: var(--_text-field-disabled-label-text-color);--md-outlined-field-disabled-label-text-opacity: var(--_text-field-disabled-label-text-opacity);--md-outlined-field-disabled-leading-content-color: var(--_text-field-disabled-leading-icon-color);--md-outlined-field-disabled-leading-content-opacity: var(--_text-field-disabled-leading-icon-opacity);--md-outlined-field-disabled-outline-color: var(--_text-field-disabled-outline-color);--md-outlined-field-disabled-outline-opacity: var(--_text-field-disabled-outline-opacity);--md-outlined-field-disabled-outline-width: var(--_text-field-disabled-outline-width);--md-outlined-field-disabled-supporting-text-color: var(--_text-field-disabled-supporting-text-color);--md-outlined-field-disabled-supporting-text-opacity: var(--_text-field-disabled-supporting-text-opacity);--md-outlined-field-disabled-trailing-content-color: var(--_text-field-disabled-trailing-icon-color);--md-outlined-field-disabled-trailing-content-opacity: var(--_text-field-disabled-trailing-icon-opacity);--md-outlined-field-error-content-color: var(--_text-field-error-input-text-color);--md-outlined-field-error-focus-content-color: var(--_text-field-error-focus-input-text-color);--md-outlined-field-error-focus-label-text-color: var(--_text-field-error-focus-label-text-color);--md-outlined-field-error-focus-leading-content-color: var(--_text-field-error-focus-leading-icon-color);--md-outlined-field-error-focus-outline-color: var(--_text-field-error-focus-outline-color);--md-outlined-field-error-focus-supporting-text-color: var(--_text-field-error-focus-supporting-text-color);--md-outlined-field-error-focus-trailing-content-color: var(--_text-field-error-focus-trailing-icon-color);--md-outlined-field-error-hover-content-color: var(--_text-field-error-hover-input-text-color);--md-outlined-field-error-hover-label-text-color: var(--_text-field-error-hover-label-text-color);--md-outlined-field-error-hover-leading-content-color: var(--_text-field-error-hover-leading-icon-color);--md-outlined-field-error-hover-outline-color: var(--_text-field-error-hover-outline-color);--md-outlined-field-error-hover-supporting-text-color: var(--_text-field-error-hover-supporting-text-color);--md-outlined-field-error-hover-trailing-content-color: var(--_text-field-error-hover-trailing-icon-color);--md-outlined-field-error-label-text-color: var(--_text-field-error-label-text-color);--md-outlined-field-error-leading-content-color: var(--_text-field-error-leading-icon-color);--md-outlined-field-error-outline-color: var(--_text-field-error-outline-color);--md-outlined-field-error-supporting-text-color: var(--_text-field-error-supporting-text-color);--md-outlined-field-error-trailing-content-color: var(--_text-field-error-trailing-icon-color);--md-outlined-field-focus-content-color: var(--_text-field-focus-input-text-color);--md-outlined-field-focus-label-text-color: var(--_text-field-focus-label-text-color);--md-outlined-field-focus-leading-content-color: var(--_text-field-focus-leading-icon-color);--md-outlined-field-focus-outline-color: var(--_text-field-focus-outline-color);--md-outlined-field-focus-outline-width: var(--_text-field-focus-outline-width);--md-outlined-field-focus-supporting-text-color: var(--_text-field-focus-supporting-text-color);--md-outlined-field-focus-trailing-content-color: var(--_text-field-focus-trailing-icon-color);--md-outlined-field-hover-content-color: var(--_text-field-hover-input-text-color);--md-outlined-field-hover-label-text-color: var(--_text-field-hover-label-text-color);--md-outlined-field-hover-leading-content-color: var(--_text-field-hover-leading-icon-color);--md-outlined-field-hover-outline-color: var(--_text-field-hover-outline-color);--md-outlined-field-hover-outline-width: var(--_text-field-hover-outline-width);--md-outlined-field-hover-supporting-text-color: var(--_text-field-hover-supporting-text-color);--md-outlined-field-hover-trailing-content-color: var(--_text-field-hover-trailing-icon-color);--md-outlined-field-label-text-color: var(--_text-field-label-text-color);--md-outlined-field-label-text-font: var(--_text-field-label-text-font);--md-outlined-field-label-text-line-height: var(--_text-field-label-text-line-height);--md-outlined-field-label-text-populated-line-height: var(--_text-field-label-text-populated-line-height);--md-outlined-field-label-text-populated-size: var(--_text-field-label-text-populated-size);--md-outlined-field-label-text-size: var(--_text-field-label-text-size);--md-outlined-field-label-text-weight: var(--_text-field-label-text-weight);--md-outlined-field-leading-content-color: var(--_text-field-leading-icon-color);--md-outlined-field-outline-color: var(--_text-field-outline-color);--md-outlined-field-outline-width: var(--_text-field-outline-width);--md-outlined-field-supporting-text-color: var(--_text-field-supporting-text-color);--md-outlined-field-supporting-text-font: var(--_text-field-supporting-text-font);--md-outlined-field-supporting-text-line-height: var(--_text-field-supporting-text-line-height);--md-outlined-field-supporting-text-size: var(--_text-field-supporting-text-size);--md-outlined-field-supporting-text-weight: var(--_text-field-supporting-text-weight);--md-outlined-field-trailing-content-color: var(--_text-field-trailing-icon-color)}[has-start] .icon.leading{font-size:var(--_text-field-leading-icon-size);height:var(--_text-field-leading-icon-size);width:var(--_text-field-leading-icon-size)}.icon.trailing{font-size:var(--_text-field-trailing-icon-size);height:var(--_text-field-trailing-icon-size);width:var(--_text-field-trailing-icon-size)}
+`;var mo=g`:host{color:unset;min-width:210px;display:flex}.field{cursor:default;outline:none}.select{position:relative;flex-direction:column}.icon.trailing svg,.icon ::slotted(*){fill:currentColor}.icon ::slotted(*){width:inherit;height:inherit;font-size:inherit}.icon slot{display:flex;height:100%;width:100%;align-items:center;justify-content:center}.icon.trailing :is(.up,.down){opacity:0;transition:opacity 75ms linear 75ms}.select:not(.open) .down,.select.open .up{opacity:1}.field,.select,md-menu{min-width:inherit;width:inherit;max-width:inherit;display:flex}md-menu{min-width:var(--__menu-min-width);max-width:var(--__menu-max-width, inherit)}.menu-wrapper{width:0px;height:0px;max-width:inherit}md-menu ::slotted(:not[disabled]){cursor:pointer}.field,.select{width:100%}:host{display:inline-flex}:host([disabled]){pointer-events:none}
+`;var fr=class extends It{};fr.styles=[mo,fo];fr=s([E("md-outlined-select")],fr);var kt=g`:host{display:flex;--md-ripple-hover-color: var(--md-menu-item-hover-state-layer-color, var(--md-sys-color-on-surface, #1d1b20));--md-ripple-hover-opacity: var(--md-menu-item-hover-state-layer-opacity, 0.08);--md-ripple-pressed-color: var(--md-menu-item-pressed-state-layer-color, var(--md-sys-color-on-surface, #1d1b20));--md-ripple-pressed-opacity: var(--md-menu-item-pressed-state-layer-opacity, 0.12)}:host([disabled]){opacity:var(--md-menu-item-disabled-opacity, 0.3);pointer-events:none}md-focus-ring{z-index:1;--md-focus-ring-shape: 8px}a,button,li{background:none;border:none;padding:0;margin:0;text-align:unset;text-decoration:none}.list-item{border-radius:inherit;display:flex;flex:1;max-width:inherit;min-width:inherit;outline:none;-webkit-tap-highlight-color:rgba(0,0,0,0)}.list-item:not(.disabled){cursor:pointer}[slot=container]{pointer-events:none}md-ripple{border-radius:inherit}md-item{border-radius:inherit;flex:1;color:var(--md-menu-item-label-text-color, var(--md-sys-color-on-surface, #1d1b20));font-family:var(--md-menu-item-label-text-font, var(--md-sys-typescale-body-large-font, var(--md-ref-typeface-plain, Roboto)));font-size:var(--md-menu-item-label-text-size, var(--md-sys-typescale-body-large-size, 1rem));line-height:var(--md-menu-item-label-text-line-height, var(--md-sys-typescale-body-large-line-height, 1.5rem));font-weight:var(--md-menu-item-label-text-weight, var(--md-sys-typescale-body-large-weight, var(--md-ref-typeface-weight-regular, 400)));min-height:var(--md-menu-item-one-line-container-height, 56px);padding-top:var(--md-menu-item-top-space, 12px);padding-bottom:var(--md-menu-item-bottom-space, 12px);padding-inline-start:var(--md-menu-item-leading-space, 16px);padding-inline-end:var(--md-menu-item-trailing-space, 16px)}md-item[multiline]{min-height:var(--md-menu-item-two-line-container-height, 72px)}[slot=supporting-text]{color:var(--md-menu-item-supporting-text-color, var(--md-sys-color-on-surface-variant, #49454f));font-family:var(--md-menu-item-supporting-text-font, var(--md-sys-typescale-body-medium-font, var(--md-ref-typeface-plain, Roboto)));font-size:var(--md-menu-item-supporting-text-size, var(--md-sys-typescale-body-medium-size, 0.875rem));line-height:var(--md-menu-item-supporting-text-line-height, var(--md-sys-typescale-body-medium-line-height, 1.25rem));font-weight:var(--md-menu-item-supporting-text-weight, var(--md-sys-typescale-body-medium-weight, var(--md-ref-typeface-weight-regular, 400)))}[slot=trailing-supporting-text]{color:var(--md-menu-item-trailing-supporting-text-color, var(--md-sys-color-on-surface-variant, #49454f));font-family:var(--md-menu-item-trailing-supporting-text-font, var(--md-sys-typescale-label-small-font, var(--md-ref-typeface-plain, Roboto)));font-size:var(--md-menu-item-trailing-supporting-text-size, var(--md-sys-typescale-label-small-size, 0.6875rem));line-height:var(--md-menu-item-trailing-supporting-text-line-height, var(--md-sys-typescale-label-small-line-height, 1rem));font-weight:var(--md-menu-item-trailing-supporting-text-weight, var(--md-sys-typescale-label-small-weight, var(--md-ref-typeface-weight-medium, 500)))}:is([slot=start],[slot=end])::slotted(*){fill:currentColor}[slot=start]{color:var(--md-menu-item-leading-icon-color, var(--md-sys-color-on-surface-variant, #49454f))}[slot=end]{color:var(--md-menu-item-trailing-icon-color, var(--md-sys-color-on-surface-variant, #49454f))}.list-item{background-color:var(--md-menu-item-container-color, transparent)}.list-item.selected{background-color:var(--md-menu-item-selected-container-color, var(--md-sys-color-secondary-container, #e8def8))}.selected:not(.disabled) ::slotted(*){color:var(--md-menu-item-selected-label-text-color, var(--md-sys-color-on-secondary-container, #1d192b))}@media(forced-colors: active){:host([disabled]),:host([disabled]) slot{color:GrayText;opacity:1}.list-item{position:relative}.list-item.selected::before{content:"";position:absolute;inset:0;box-sizing:border-box;border-radius:inherit;pointer-events:none;border:3px double CanvasText}}
+`;var ze=class extends b{constructor(){super(...arguments),this.multiline=!1}render(){return u`
+
+
+
+
+
+
+
+
+
+
+ `}handleTextSlotChange(){let e=!1,t=0;for(let r of this.textSlots)if(si(r)&&(t+=1),t>1){e=!0;break}this.multiline=e}};s([l({type:Boolean,reflect:!0})],ze.prototype,"multiline",void 0);s([Or(".text slot")],ze.prototype,"textSlots",void 0);function si(o){for(let e of o.assignedNodes({flatten:!0})){let t=e.nodeType===Node.ELEMENT_NODE,r=e.nodeType===Node.TEXT_NODE&&e.textContent?.match(/\S/);if(t||r)return!0}return!1}var vo=g`:host{color:var(--md-sys-color-on-surface, #1d1b20);font-family:var(--md-sys-typescale-body-large-font, var(--md-ref-typeface-plain, Roboto));font-size:var(--md-sys-typescale-body-large-size, 1rem);font-weight:var(--md-sys-typescale-body-large-weight, var(--md-ref-typeface-weight-regular, 400));line-height:var(--md-sys-typescale-body-large-line-height, 1.5rem);align-items:center;box-sizing:border-box;display:flex;gap:16px;min-height:56px;overflow:hidden;padding:12px 16px;position:relative;text-overflow:ellipsis}:host([multiline]){min-height:72px}[name=overline]{color:var(--md-sys-color-on-surface-variant, #49454f);font-family:var(--md-sys-typescale-label-small-font, var(--md-ref-typeface-plain, Roboto));font-size:var(--md-sys-typescale-label-small-size, 0.6875rem);font-weight:var(--md-sys-typescale-label-small-weight, var(--md-ref-typeface-weight-medium, 500));line-height:var(--md-sys-typescale-label-small-line-height, 1rem)}[name=supporting-text]{color:var(--md-sys-color-on-surface-variant, #49454f);font-family:var(--md-sys-typescale-body-medium-font, var(--md-ref-typeface-plain, Roboto));font-size:var(--md-sys-typescale-body-medium-size, 0.875rem);font-weight:var(--md-sys-typescale-body-medium-weight, var(--md-ref-typeface-weight-regular, 400));line-height:var(--md-sys-typescale-body-medium-line-height, 1.25rem)}[name=trailing-supporting-text]{color:var(--md-sys-color-on-surface-variant, #49454f);font-family:var(--md-sys-typescale-label-small-font, var(--md-ref-typeface-plain, Roboto));font-size:var(--md-sys-typescale-label-small-size, 0.6875rem);font-weight:var(--md-sys-typescale-label-small-weight, var(--md-ref-typeface-weight-medium, 500));line-height:var(--md-sys-typescale-label-small-line-height, 1rem)}[name=container]::slotted(*){inset:0;position:absolute}.default-slot{display:inline}.default-slot,.text ::slotted(*){overflow:hidden;text-overflow:ellipsis}.text{display:flex;flex:1;flex-direction:column;overflow:hidden}
+`;var mr=class extends ze{};mr.styles=[vo];mr=s([E("md-item")],mr);var ai=450,go=225,li=.2,di=10,ci=75,pi=.35,ui="::after",hi="forwards",H;(function(o){o[o.INACTIVE=0]="INACTIVE",o[o.TOUCH_DELAY=1]="TOUCH_DELAY",o[o.HOLDING=2]="HOLDING",o[o.WAITING_FOR_CLICK=3]="WAITING_FOR_CLICK"})(H||(H={}));var fi=["click","contextmenu","pointercancel","pointerdown","pointerenter","pointerleave","pointerup"],mi=150,vi=window.matchMedia("(forced-colors: active)"),ge=class extends b{constructor(){super(...arguments),this.disabled=!1,this.hovered=!1,this.pressed=!1,this.rippleSize="",this.rippleScale="",this.initialSize=0,this.state=H.INACTIVE,this.attachableController=new Le(this,this.onControlChange.bind(this))}get htmlFor(){return this.attachableController.htmlFor}set htmlFor(e){this.attachableController.htmlFor=e}get control(){return this.attachableController.control}set control(e){this.attachableController.control=e}attach(e){this.attachableController.attach(e)}detach(){this.attachableController.detach()}connectedCallback(){super.connectedCallback(),this.setAttribute("aria-hidden","true")}render(){let e={hovered:this.hovered,pressed:this.pressed};return u`
`}update(e){e.has("disabled")&&this.disabled&&(this.hovered=!1,this.pressed=!1),super.update(e)}handlePointerenter(e){this.shouldReactToEvent(e)&&(this.hovered=!0)}handlePointerleave(e){this.shouldReactToEvent(e)&&(this.hovered=!1,this.state!==H.INACTIVE&&this.endPressAnimation())}handlePointerup(e){if(this.shouldReactToEvent(e)){if(this.state===H.HOLDING){this.state=H.WAITING_FOR_CLICK;return}if(this.state===H.TOUCH_DELAY){this.state=H.WAITING_FOR_CLICK,this.startPressAnimation(this.rippleStartEvent);return}}}async handlePointerdown(e){if(this.shouldReactToEvent(e)){if(this.rippleStartEvent=e,!this.isTouch(e)){this.state=H.WAITING_FOR_CLICK,this.startPressAnimation(e);return}this.state=H.TOUCH_DELAY,await new Promise(t=>{setTimeout(t,mi)}),this.state===H.TOUCH_DELAY&&(this.state=H.HOLDING,this.startPressAnimation(e))}}handleClick(){if(!this.disabled){if(this.state===H.WAITING_FOR_CLICK){this.endPressAnimation();return}this.state===H.INACTIVE&&(this.startPressAnimation(),this.endPressAnimation())}}handlePointercancel(e){this.shouldReactToEvent(e)&&this.endPressAnimation()}handleContextmenu(){this.disabled||this.endPressAnimation()}determineRippleSize(){let{height:e,width:t}=this.getBoundingClientRect(),r=Math.max(e,t),i=Math.max(pi*r,ci),n=this.currentCSSZoom??1,a=Math.floor(r*li/n),c=Math.sqrt(t**2+e**2)+di;this.initialSize=a;let h=(c+i)/a;this.rippleScale=`${h/n}`,this.rippleSize=`${a}px`}getNormalizedPointerEventCoords(e){let{scrollX:t,scrollY:r}=window,{left:i,top:n}=this.getBoundingClientRect(),a=t+i,d=r+n,{pageX:c,pageY:h}=e,m=this.currentCSSZoom??1;return{x:(c-a)/m,y:(h-d)/m}}getTranslationCoordinates(e){let{height:t,width:r}=this.getBoundingClientRect(),i=this.currentCSSZoom??1,n={x:(r/i-this.initialSize)/2,y:(t/i-this.initialSize)/2},a;return e instanceof PointerEvent?a=this.getNormalizedPointerEventCoords(e):a={x:r/i/2,y:t/i/2},a={x:a.x-this.initialSize/2,y:a.y-this.initialSize/2},{startPoint:a,endPoint:n}}startPressAnimation(e){if(!this.mdRoot)return;this.pressed=!0,this.growAnimation?.cancel(),this.determineRippleSize();let{startPoint:t,endPoint:r}=this.getTranslationCoordinates(e),i=`${t.x}px, ${t.y}px`,n=`${r.x}px, ${r.y}px`;this.growAnimation=this.mdRoot.animate({top:[0,0],left:[0,0],height:[this.rippleSize,this.rippleSize],width:[this.rippleSize,this.rippleSize],transform:[`translate(${i}) scale(1)`,`translate(${n}) scale(${this.rippleScale})`]},{pseudoElement:ui,duration:ai,easing:ie.STANDARD,fill:hi})}async endPressAnimation(){this.rippleStartEvent=void 0,this.state=H.INACTIVE;let e=this.growAnimation,t=1/0;if(typeof e?.currentTime=="number"?t=e.currentTime:e?.currentTime&&(t=e.currentTime.to("ms").value),t>=go){this.pressed=!1;return}await new Promise(r=>{setTimeout(r,go-t)}),this.growAnimation===e&&(this.pressed=!1)}shouldReactToEvent(e){if(this.disabled||!e.isPrimary||this.rippleStartEvent&&this.rippleStartEvent.pointerId!==e.pointerId)return!1;if(e.type==="pointerenter"||e.type==="pointerleave")return!this.isTouch(e);let t=e.buttons===1;return this.isTouch(e)||t}isTouch({pointerType:e}){return e==="touch"}async handleEvent(e){if(!vi?.matches)switch(e.type){case"click":this.handleClick();break;case"contextmenu":this.handleContextmenu();break;case"pointercancel":this.handlePointercancel(e);break;case"pointerdown":await this.handlePointerdown(e);break;case"pointerenter":this.handlePointerenter(e);break;case"pointerleave":this.handlePointerleave(e);break;case"pointerup":this.handlePointerup(e);break;default:break}}onControlChange(e,t){if(!!1)for(let r of fi)e?.removeEventListener(r,this),t?.addEventListener(r,this)}};s([l({type:Boolean,reflect:!0})],ge.prototype,"disabled",void 0);s([k()],ge.prototype,"hovered",void 0);s([k()],ge.prototype,"pressed",void 0);s([S(".surface")],ge.prototype,"mdRoot",void 0);var bo=g`:host{display:flex;margin:auto;pointer-events:none}:host([disabled]){display:none}@media(forced-colors: active){:host{display:none}}:host,.surface{border-radius:inherit;position:absolute;inset:0;overflow:hidden}.surface{-webkit-tap-highlight-color:rgba(0,0,0,0)}.surface::before,.surface::after{content:"";opacity:0;position:absolute}.surface::before{background-color:var(--md-ripple-hover-color, var(--md-sys-color-on-surface, #1d1b20));inset:0;transition:opacity 15ms linear,background-color 15ms linear}.surface::after{background:radial-gradient(closest-side, var(--md-ripple-pressed-color, var(--md-sys-color-on-surface, #1d1b20)) max(100% - 70px, 65%), transparent 100%);transform-origin:center center;transition:opacity 375ms linear}.hovered::before{background-color:var(--md-ripple-hover-color, var(--md-sys-color-on-surface, #1d1b20));opacity:var(--md-ripple-hover-opacity, 0.08)}.pressed::after{opacity:var(--md-ripple-pressed-opacity, 0.12);transition-duration:105ms}
+`;var vr=class extends ge{};vr.styles=[bo];vr=s([E("md-ripple")],vr);var Me=class{constructor(e,t){this.host=e,this.internalTypeaheadText=null,this.onClick=()=>{this.host.keepOpen||this.host.dispatchEvent(pr(this.host,{kind:Et.CLICK_SELECTION}))},this.onKeydown=r=>{if(this.host.href&&r.code==="Enter"){let n=this.getInteractiveElement();n instanceof HTMLAnchorElement&&n.click()}if(r.defaultPrevented)return;let i=r.code;this.host.keepOpen&&i!=="Escape"||At(i)&&(r.preventDefault(),this.host.dispatchEvent(pr(this.host,{kind:Et.KEYDOWN,key:i})))},this.getHeadlineElements=t.getHeadlineElements,this.getSupportingTextElements=t.getSupportingTextElements,this.getDefaultElements=t.getDefaultElements,this.getInteractiveElement=t.getInteractiveElement,this.host.addController(this)}get typeaheadText(){if(this.internalTypeaheadText!==null)return this.internalTypeaheadText;let e=this.getHeadlineElements(),t=[];return e.forEach(r=>{r.textContent&&r.textContent.trim()&&t.push(r.textContent.trim())}),t.length===0&&this.getDefaultElements().forEach(r=>{r.textContent&&r.textContent.trim()&&t.push(r.textContent.trim())}),t.length===0&&this.getSupportingTextElements().forEach(r=>{r.textContent&&r.textContent.trim()&&t.push(r.textContent.trim())}),t.join(" ")}get tagName(){switch(this.host.type){case"link":return"a";case"button":return"button";default:case"menuitem":case"option":return"li"}}get role(){return this.host.type==="option"?"option":"menuitem"}hostConnected(){this.host.toggleAttribute("md-menu-item",!0)}hostUpdate(){this.host.href&&(this.host.type="link")}setTypeaheadText(e){this.internalTypeaheadText=e}};function gi(){return new Event("request-selection",{bubbles:!0,composed:!0})}function bi(){return new Event("request-deselection",{bubbles:!0,composed:!0})}var Ot=class{get role(){return this.menuItemController.role}get typeaheadText(){return this.menuItemController.typeaheadText}setTypeaheadText(e){this.menuItemController.setTypeaheadText(e)}get displayText(){return this.internalDisplayText!==null?this.internalDisplayText:this.menuItemController.typeaheadText}setDisplayText(e){this.internalDisplayText=e}constructor(e,t){this.host=e,this.internalDisplayText=null,this.firstUpdate=!0,this.onClick=()=>{this.menuItemController.onClick()},this.onKeydown=r=>{this.menuItemController.onKeydown(r)},this.lastSelected=this.host.selected,this.menuItemController=new Me(e,t),e.addController(this)}hostUpdate(){this.lastSelected!==this.host.selected&&(this.host.ariaSelected=this.host.selected?"true":"false")}hostUpdated(){this.lastSelected!==this.host.selected&&!this.firstUpdate&&(this.host.selected?this.host.dispatchEvent(gi()):this.host.dispatchEvent(bi())),this.lastSelected=this.host.selected,this.firstUpdate=!1}};var yi=W(b),j=class extends yi{constructor(){super(...arguments),this.disabled=!1,this.isMenuItem=!0,this.selected=!1,this.value="",this.type="option",this.selectOptionController=new Ot(this,{getHeadlineElements:()=>this.headlineElements,getSupportingTextElements:()=>this.supportingTextElements,getDefaultElements:()=>this.defaultElements,getInteractiveElement:()=>this.listItemRoot})}get typeaheadText(){return this.selectOptionController.typeaheadText}set typeaheadText(e){this.selectOptionController.setTypeaheadText(e)}get displayText(){return this.selectOptionController.displayText}set displayText(e){this.selectOptionController.setDisplayText(e)}render(){return this.renderListItem(u`
+
+
+ ${this.renderRipple()} ${this.renderFocusRing()}
+
+
+
+ ${this.renderBody()}
+
+ `)}renderListItem(e){return u`
+ ${e}
+ `}renderRipple(){return u` `}renderFocusRing(){return u` `}getRenderClasses(){return{disabled:this.disabled,selected:this.selected}}renderBody(){return u`
+
+
+
+
+
+ `}focus(){this.listItemRoot?.focus()}};j.shadowRootOptions={...b.shadowRootOptions,delegatesFocus:!0};s([l({type:Boolean,reflect:!0})],j.prototype,"disabled",void 0);s([l({type:Boolean,attribute:"md-menu-item",reflect:!0})],j.prototype,"isMenuItem",void 0);s([l({type:Boolean})],j.prototype,"selected",void 0);s([l()],j.prototype,"value",void 0);s([S(".list-item")],j.prototype,"listItemRoot",void 0);s([N({slot:"headline"})],j.prototype,"headlineElements",void 0);s([N({slot:"supporting-text"})],j.prototype,"supportingTextElements",void 0);s([rt({slot:""})],j.prototype,"defaultElements",void 0);s([l({attribute:"typeahead-text"})],j.prototype,"typeaheadText",null);s([l({attribute:"display-text"})],j.prototype,"displayText",null);var gr=class extends j{};gr.styles=[kt];gr=s([E("md-select-option")],gr);var xi=W(b),q=class extends xi{constructor(){super(...arguments),this.disabled=!1,this.type="menuitem",this.href="",this.target="",this.keepOpen=!1,this.selected=!1,this.menuItemController=new Me(this,{getHeadlineElements:()=>this.headlineElements,getSupportingTextElements:()=>this.supportingTextElements,getDefaultElements:()=>this.defaultElements,getInteractiveElement:()=>this.listItemRoot})}get typeaheadText(){return this.menuItemController.typeaheadText}set typeaheadText(e){this.menuItemController.setTypeaheadText(e)}render(){return this.renderListItem(u`
+
+
+ ${this.renderRipple()} ${this.renderFocusRing()}
+
+
+
+ ${this.renderBody()}
+
+ `)}renderListItem(e){let t=this.type==="link",r;switch(this.menuItemController.tagName){case"a":r=G`a`;break;case"button":r=G`button`;break;default:case"li":r=G`li`;break}let i=t&&this.target?this.target:p;return ue`
+ <${r}
+ id="item"
+ tabindex=${this.disabled&&!t?-1:0}
+ role=${this.menuItemController.role}
+ aria-label=${this.ariaLabel||p}
+ aria-selected=${this.ariaSelected||p}
+ aria-checked=${this.ariaChecked||p}
+ aria-expanded=${this.ariaExpanded||p}
+ aria-haspopup=${this.ariaHasPopup||p}
+ class="list-item ${R(this.getRenderClasses())}"
+ href=${this.href||p}
+ target=${i}
+ @click=${this.menuItemController.onClick}
+ @keydown=${this.menuItemController.onKeydown}
+ >${e}${r}>
+ `}renderRipple(){return u` `}renderFocusRing(){return u` `}getRenderClasses(){return{disabled:this.disabled,selected:this.selected}}renderBody(){return u`
+
+
+
+
+
+ `}focus(){this.listItemRoot?.focus()}};q.shadowRootOptions={...b.shadowRootOptions,delegatesFocus:!0};s([l({type:Boolean,reflect:!0})],q.prototype,"disabled",void 0);s([l()],q.prototype,"type",void 0);s([l()],q.prototype,"href",void 0);s([l()],q.prototype,"target",void 0);s([l({type:Boolean,attribute:"keep-open"})],q.prototype,"keepOpen",void 0);s([l({type:Boolean})],q.prototype,"selected",void 0);s([S(".list-item")],q.prototype,"listItemRoot",void 0);s([N({slot:"headline"})],q.prototype,"headlineElements",void 0);s([N({slot:"supporting-text"})],q.prototype,"supportingTextElements",void 0);s([rt({slot:""})],q.prototype,"defaultElements",void 0);s([l({attribute:"typeahead-text"})],q.prototype,"typeaheadText",null);var br=class extends q{};br.styles=[kt];br=s([E("md-menu-item")],br);function Rt(o){o.addInitializer(e=>{let t=e;t.addEventListener("click",async r=>{let{type:i,[P]:n}=t,{form:a}=n;if(!(!a||i==="button")&&(await new Promise(d=>{setTimeout(d)}),!r.defaultPrevented)){if(i==="reset"){a.reset();return}a.addEventListener("submit",d=>{Object.defineProperty(d,"submitter",{configurable:!0,enumerable:!0,get:()=>t})},{capture:!0,once:!0}),n.setFormValue(t.value),a.requestSubmit()}})})}function yr(o,e=!0){return e&&getComputedStyle(o).getPropertyValue("direction").trim()==="rtl"}var _i=W(ee(b)),D=class extends _i{get name(){return this.getAttribute("name")??""}set name(e){this.setAttribute("name",e)}get form(){return this[P].form}get labels(){return this[P].labels}constructor(){super(),this.disabled=!1,this.softDisabled=!1,this.flipIconInRtl=!1,this.href="",this.download="",this.target="",this.ariaLabelSelected="",this.toggle=!1,this.selected=!1,this.type="submit",this.value="",this.flipIcon=yr(this,this.flipIconInRtl),this.addEventListener("click",this.handleClick.bind(this))}willUpdate(){this.href&&(this.disabled=!1,this.softDisabled=!1)}render(){let e=this.href?G`div`:G`button`,{ariaLabel:t,ariaHasPopup:r,ariaExpanded:i}=this,n=t&&this.ariaLabelSelected,a=this.toggle?this.selected:p,d=p;return this.href||(d=n&&this.selected?this.ariaLabelSelected:t),ue`<${e}
+ class="icon-button ${R(this.getRenderClasses())}"
+ id="button"
+ aria-label="${d||p}"
+ aria-haspopup="${!this.href&&r||p}"
+ aria-expanded="${!this.href&&i||p}"
+ aria-pressed="${a}"
+ aria-disabled=${!this.href&&this.softDisabled||p}
+ ?disabled="${!this.href&&this.disabled}"
+ @click="${this.handleClickOnChild}">
+ ${this.renderFocusRing()}
+ ${this.renderRipple()}
+ ${this.selected?p:this.renderIcon()}
+ ${this.selected?this.renderSelectedIcon():p}
+ ${this.href?this.renderLink():this.renderTouchTarget()}
+ ${e}>`}renderLink(){let{ariaLabel:e}=this;return u`
+
+ ${this.renderTouchTarget()}
+
+ `}getRenderClasses(){return{"flip-icon":this.flipIcon,selected:this.toggle&&this.selected}}renderIcon(){return u` `}renderSelectedIcon(){return u` `}renderTouchTarget(){return u` `}renderFocusRing(){return u` `}renderRipple(){let e=!this.href&&(this.disabled||this.softDisabled);return u` `}connectedCallback(){this.flipIcon=yr(this,this.flipIconInRtl),super.connectedCallback()}handleClick(e){if(!this.href&&this.softDisabled){e.stopImmediatePropagation(),e.preventDefault();return}}async handleClickOnChild(e){await 0,!(!this.toggle||this.disabled||this.softDisabled||e.defaultPrevented)&&(this.selected=!this.selected,this.dispatchEvent(new InputEvent("input",{bubbles:!0,composed:!0})),this.dispatchEvent(new Event("change",{bubbles:!0})))}};Rt(D);D.formAssociated=!0;D.shadowRootOptions={mode:"open",delegatesFocus:!0};s([l({type:Boolean,reflect:!0})],D.prototype,"disabled",void 0);s([l({type:Boolean,attribute:"soft-disabled",reflect:!0})],D.prototype,"softDisabled",void 0);s([l({type:Boolean,attribute:"flip-icon-in-rtl"})],D.prototype,"flipIconInRtl",void 0);s([l()],D.prototype,"href",void 0);s([l()],D.prototype,"download",void 0);s([l()],D.prototype,"target",void 0);s([l({attribute:"aria-label-selected"})],D.prototype,"ariaLabelSelected",void 0);s([l({type:Boolean})],D.prototype,"toggle",void 0);s([l({type:Boolean,reflect:!0})],D.prototype,"selected",void 0);s([l()],D.prototype,"type",void 0);s([l({reflect:!0})],D.prototype,"value",void 0);s([k()],D.prototype,"flipIcon",void 0);var yo=g`:host{display:inline-flex;outline:none;-webkit-tap-highlight-color:rgba(0,0,0,0);height:var(--_container-height);width:var(--_container-width);justify-content:center}:host([touch-target=wrapper]){margin:max(0px,(48px - var(--_container-height))/2) max(0px,(48px - var(--_container-width))/2)}md-focus-ring{--md-focus-ring-shape-start-start: var(--_container-shape-start-start);--md-focus-ring-shape-start-end: var(--_container-shape-start-end);--md-focus-ring-shape-end-end: var(--_container-shape-end-end);--md-focus-ring-shape-end-start: var(--_container-shape-end-start)}:host(:is([disabled],[soft-disabled])){pointer-events:none}.icon-button{place-items:center;background:none;border:none;box-sizing:border-box;cursor:pointer;display:flex;place-content:center;outline:none;padding:0;position:relative;text-decoration:none;user-select:none;z-index:0;flex:1;border-start-start-radius:var(--_container-shape-start-start);border-start-end-radius:var(--_container-shape-start-end);border-end-start-radius:var(--_container-shape-end-start);border-end-end-radius:var(--_container-shape-end-end)}.icon ::slotted(*){font-size:var(--_icon-size);height:var(--_icon-size);width:var(--_icon-size);font-weight:inherit}md-ripple{z-index:-1;border-start-start-radius:var(--_container-shape-start-start);border-start-end-radius:var(--_container-shape-start-end);border-end-start-radius:var(--_container-shape-end-start);border-end-end-radius:var(--_container-shape-end-end)}.flip-icon .icon{transform:scaleX(-1)}.icon{display:inline-flex}.link{display:grid;height:100%;outline:none;place-items:center;position:absolute;width:100%}.touch{position:absolute;height:max(48px,100%);width:max(48px,100%)}:host([touch-target=none]) .touch{display:none}@media(forced-colors: active){:host(:is([disabled],[soft-disabled])){--_disabled-icon-color: GrayText;--_disabled-icon-opacity: 1}}
+`;var xo=g`:host{--_disabled-icon-color: var(--md-icon-button-disabled-icon-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-icon-opacity: var(--md-icon-button-disabled-icon-opacity, 0.38);--_icon-size: var(--md-icon-button-icon-size, 24px);--_selected-focus-icon-color: var(--md-icon-button-selected-focus-icon-color, var(--md-sys-color-primary, #6750a4));--_selected-hover-icon-color: var(--md-icon-button-selected-hover-icon-color, var(--md-sys-color-primary, #6750a4));--_selected-hover-state-layer-color: var(--md-icon-button-selected-hover-state-layer-color, var(--md-sys-color-primary, #6750a4));--_selected-hover-state-layer-opacity: var(--md-icon-button-selected-hover-state-layer-opacity, 0.08);--_selected-icon-color: var(--md-icon-button-selected-icon-color, var(--md-sys-color-primary, #6750a4));--_selected-pressed-icon-color: var(--md-icon-button-selected-pressed-icon-color, var(--md-sys-color-primary, #6750a4));--_selected-pressed-state-layer-color: var(--md-icon-button-selected-pressed-state-layer-color, var(--md-sys-color-primary, #6750a4));--_selected-pressed-state-layer-opacity: var(--md-icon-button-selected-pressed-state-layer-opacity, 0.12);--_state-layer-height: var(--md-icon-button-state-layer-height, 40px);--_state-layer-shape: var(--md-icon-button-state-layer-shape, var(--md-sys-shape-corner-full, 9999px));--_state-layer-width: var(--md-icon-button-state-layer-width, 40px);--_focus-icon-color: var(--md-icon-button-focus-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_hover-icon-color: var(--md-icon-button-hover-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_hover-state-layer-color: var(--md-icon-button-hover-state-layer-color, var(--md-sys-color-on-surface-variant, #49454f));--_hover-state-layer-opacity: var(--md-icon-button-hover-state-layer-opacity, 0.08);--_icon-color: var(--md-icon-button-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_pressed-icon-color: var(--md-icon-button-pressed-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_pressed-state-layer-color: var(--md-icon-button-pressed-state-layer-color, var(--md-sys-color-on-surface-variant, #49454f));--_pressed-state-layer-opacity: var(--md-icon-button-pressed-state-layer-opacity, 0.12);--_container-shape-start-start: 0;--_container-shape-start-end: 0;--_container-shape-end-end: 0;--_container-shape-end-start: 0;--_container-height: 0;--_container-width: 0;height:var(--_state-layer-height);width:var(--_state-layer-width)}:host([touch-target=wrapper]){margin:max(0px,(48px - var(--_state-layer-height))/2) max(0px,(48px - var(--_state-layer-width))/2)}md-focus-ring{--md-focus-ring-shape-start-start: var(--_state-layer-shape);--md-focus-ring-shape-start-end: var(--_state-layer-shape);--md-focus-ring-shape-end-end: var(--_state-layer-shape);--md-focus-ring-shape-end-start: var(--_state-layer-shape)}.standard{background-color:rgba(0,0,0,0);color:var(--_icon-color);--md-ripple-hover-color: var(--_hover-state-layer-color);--md-ripple-hover-opacity: var(--_hover-state-layer-opacity);--md-ripple-pressed-color: var(--_pressed-state-layer-color);--md-ripple-pressed-opacity: var(--_pressed-state-layer-opacity)}.standard:hover{color:var(--_hover-icon-color)}.standard:focus{color:var(--_focus-icon-color)}.standard:active{color:var(--_pressed-icon-color)}.standard:is(:disabled,[aria-disabled=true]){color:var(--_disabled-icon-color)}md-ripple{border-radius:var(--_state-layer-shape)}.standard:is(:disabled,[aria-disabled=true]){opacity:var(--_disabled-icon-opacity)}.selected:not(:disabled,[aria-disabled=true]){color:var(--_selected-icon-color)}.selected:not(:disabled,[aria-disabled=true]):hover{color:var(--_selected-hover-icon-color)}.selected:not(:disabled,[aria-disabled=true]):focus{color:var(--_selected-focus-icon-color)}.selected:not(:disabled,[aria-disabled=true]):active{color:var(--_selected-pressed-icon-color)}.selected{--md-ripple-hover-color: var(--_selected-hover-state-layer-color);--md-ripple-hover-opacity: var(--_selected-hover-state-layer-opacity);--md-ripple-pressed-color: var(--_selected-pressed-state-layer-color);--md-ripple-pressed-opacity: var(--_selected-pressed-state-layer-opacity)}
+`;var xr=class extends D{getRenderClasses(){return{...super.getRenderClasses(),standard:!0}}};xr.styles=[yo,xo];xr=s([E("md-icon-button")],xr);function Pt(o){let e=new MouseEvent("click",{bubbles:!0});return o.dispatchEvent(e),e}function Lt(o){return o.currentTarget!==o.target||o.composedPath()[0]!==o.target||o.target.disabled?!1:!wi(o)}function wi(o){let e=_r;return e&&(o.preventDefault(),o.stopImmediatePropagation()),Ei(),e}var _r=!1;async function Ei(){_r=!0,await null,_r=!1}var Ai=W(ee(b)),M=class extends Ai{get name(){return this.getAttribute("name")??""}set name(e){this.setAttribute("name",e)}get form(){return this[P].form}constructor(){super(),this.disabled=!1,this.softDisabled=!1,this.href="",this.download="",this.target="",this.trailingIcon=!1,this.hasIcon=!1,this.type="submit",this.value="",this.addEventListener("click",this.handleClick.bind(this))}focus(){this.buttonElement?.focus()}blur(){this.buttonElement?.blur()}render(){let e=this.disabled||this.softDisabled,t=this.href?this.renderLink():this.renderButton(),r=this.href?"link":"button";return u`
+ ${this.renderElevationOrOutline?.()}
+
+
+
+ ${t}
+ `}renderButton(){let{ariaLabel:e,ariaHasPopup:t,ariaExpanded:r}=this;return u`
+ ${this.renderContent()}
+ `}renderLink(){let{ariaLabel:e,ariaHasPopup:t,ariaExpanded:r}=this;return u`${this.renderContent()}
+ `}renderContent(){let e=u` `;return u`
+
+ ${this.trailingIcon?p:e}
+
+ ${this.trailingIcon?e:p}
+ `}handleClick(e){if(this.softDisabled||this.disabled&&this.href){e.stopImmediatePropagation(),e.preventDefault();return}!Lt(e)||!this.buttonElement||(this.focus(),Pt(this.buttonElement))}handleSlotChange(){this.hasIcon=this.assignedIcons.length>0}};Rt(M);M.formAssociated=!0;M.shadowRootOptions={mode:"open",delegatesFocus:!0};s([l({type:Boolean,reflect:!0})],M.prototype,"disabled",void 0);s([l({type:Boolean,attribute:"soft-disabled",reflect:!0})],M.prototype,"softDisabled",void 0);s([l()],M.prototype,"href",void 0);s([l()],M.prototype,"download",void 0);s([l()],M.prototype,"target",void 0);s([l({type:Boolean,attribute:"trailing-icon",reflect:!0})],M.prototype,"trailingIcon",void 0);s([l({type:Boolean,attribute:"has-icon",reflect:!0})],M.prototype,"hasIcon",void 0);s([l()],M.prototype,"type",void 0);s([l({reflect:!0})],M.prototype,"value",void 0);s([S(".button")],M.prototype,"buttonElement",void 0);s([N({slot:"icon",flatten:!0})],M.prototype,"assignedIcons",void 0);var Dt=class extends M{renderElevationOrOutline(){return u` `}};var _o=g`:host{--_container-color: var(--md-filled-button-container-color, var(--md-sys-color-primary, #6750a4));--_container-elevation: var(--md-filled-button-container-elevation, 0);--_container-height: var(--md-filled-button-container-height, 40px);--_container-shadow-color: var(--md-filled-button-container-shadow-color, var(--md-sys-color-shadow, #000));--_disabled-container-color: var(--md-filled-button-disabled-container-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-container-elevation: var(--md-filled-button-disabled-container-elevation, 0);--_disabled-container-opacity: var(--md-filled-button-disabled-container-opacity, 0.12);--_disabled-label-text-color: var(--md-filled-button-disabled-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-label-text-opacity: var(--md-filled-button-disabled-label-text-opacity, 0.38);--_focus-container-elevation: var(--md-filled-button-focus-container-elevation, 0);--_focus-label-text-color: var(--md-filled-button-focus-label-text-color, var(--md-sys-color-on-primary, #fff));--_hover-container-elevation: var(--md-filled-button-hover-container-elevation, 1);--_hover-label-text-color: var(--md-filled-button-hover-label-text-color, var(--md-sys-color-on-primary, #fff));--_hover-state-layer-color: var(--md-filled-button-hover-state-layer-color, var(--md-sys-color-on-primary, #fff));--_hover-state-layer-opacity: var(--md-filled-button-hover-state-layer-opacity, 0.08);--_label-text-color: var(--md-filled-button-label-text-color, var(--md-sys-color-on-primary, #fff));--_label-text-font: var(--md-filled-button-label-text-font, var(--md-sys-typescale-label-large-font, var(--md-ref-typeface-plain, Roboto)));--_label-text-line-height: var(--md-filled-button-label-text-line-height, var(--md-sys-typescale-label-large-line-height, 1.25rem));--_label-text-size: var(--md-filled-button-label-text-size, var(--md-sys-typescale-label-large-size, 0.875rem));--_label-text-weight: var(--md-filled-button-label-text-weight, var(--md-sys-typescale-label-large-weight, var(--md-ref-typeface-weight-medium, 500)));--_pressed-container-elevation: var(--md-filled-button-pressed-container-elevation, 0);--_pressed-label-text-color: var(--md-filled-button-pressed-label-text-color, var(--md-sys-color-on-primary, #fff));--_pressed-state-layer-color: var(--md-filled-button-pressed-state-layer-color, var(--md-sys-color-on-primary, #fff));--_pressed-state-layer-opacity: var(--md-filled-button-pressed-state-layer-opacity, 0.12);--_disabled-icon-color: var(--md-filled-button-disabled-icon-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-icon-opacity: var(--md-filled-button-disabled-icon-opacity, 0.38);--_focus-icon-color: var(--md-filled-button-focus-icon-color, var(--md-sys-color-on-primary, #fff));--_hover-icon-color: var(--md-filled-button-hover-icon-color, var(--md-sys-color-on-primary, #fff));--_icon-color: var(--md-filled-button-icon-color, var(--md-sys-color-on-primary, #fff));--_icon-size: var(--md-filled-button-icon-size, 18px);--_pressed-icon-color: var(--md-filled-button-pressed-icon-color, var(--md-sys-color-on-primary, #fff));--_container-shape-start-start: var(--md-filled-button-container-shape-start-start, var(--md-filled-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-start-end: var(--md-filled-button-container-shape-start-end, var(--md-filled-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-end-end: var(--md-filled-button-container-shape-end-end, var(--md-filled-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-end-start: var(--md-filled-button-container-shape-end-start, var(--md-filled-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_leading-space: var(--md-filled-button-leading-space, 24px);--_trailing-space: var(--md-filled-button-trailing-space, 24px);--_with-leading-icon-leading-space: var(--md-filled-button-with-leading-icon-leading-space, 16px);--_with-leading-icon-trailing-space: var(--md-filled-button-with-leading-icon-trailing-space, 24px);--_with-trailing-icon-leading-space: var(--md-filled-button-with-trailing-icon-leading-space, 24px);--_with-trailing-icon-trailing-space: var(--md-filled-button-with-trailing-icon-trailing-space, 16px)}
+`;var wo=g`md-elevation{transition-duration:280ms}:host(:is([disabled],[soft-disabled])) md-elevation{transition:none}md-elevation{--md-elevation-level: var(--_container-elevation);--md-elevation-shadow-color: var(--_container-shadow-color)}:host(:focus-within) md-elevation{--md-elevation-level: var(--_focus-container-elevation)}:host(:hover) md-elevation{--md-elevation-level: var(--_hover-container-elevation)}:host(:active) md-elevation{--md-elevation-level: var(--_pressed-container-elevation)}:host(:is([disabled],[soft-disabled])) md-elevation{--md-elevation-level: var(--_disabled-container-elevation)}
+`;var Eo=g`:host{border-start-start-radius:var(--_container-shape-start-start);border-start-end-radius:var(--_container-shape-start-end);border-end-start-radius:var(--_container-shape-end-start);border-end-end-radius:var(--_container-shape-end-end);box-sizing:border-box;cursor:pointer;display:inline-flex;gap:8px;min-height:var(--_container-height);outline:none;padding-block:calc((var(--_container-height) - max(var(--_label-text-line-height),var(--_icon-size)))/2);padding-inline-start:var(--_leading-space);padding-inline-end:var(--_trailing-space);place-content:center;place-items:center;position:relative;font-family:var(--_label-text-font);font-size:var(--_label-text-size);line-height:var(--_label-text-line-height);font-weight:var(--_label-text-weight);text-overflow:ellipsis;text-wrap:nowrap;user-select:none;-webkit-tap-highlight-color:rgba(0,0,0,0);vertical-align:top;--md-ripple-hover-color: var(--_hover-state-layer-color);--md-ripple-pressed-color: var(--_pressed-state-layer-color);--md-ripple-hover-opacity: var(--_hover-state-layer-opacity);--md-ripple-pressed-opacity: var(--_pressed-state-layer-opacity)}md-focus-ring{--md-focus-ring-shape-start-start: var(--_container-shape-start-start);--md-focus-ring-shape-start-end: var(--_container-shape-start-end);--md-focus-ring-shape-end-end: var(--_container-shape-end-end);--md-focus-ring-shape-end-start: var(--_container-shape-end-start)}:host(:is([disabled],[soft-disabled])){cursor:default;pointer-events:none}.button{border-radius:inherit;cursor:inherit;display:inline-flex;align-items:center;justify-content:center;border:none;outline:none;-webkit-appearance:none;vertical-align:middle;background:rgba(0,0,0,0);text-decoration:none;min-width:calc(64px - var(--_leading-space) - var(--_trailing-space));width:100%;z-index:0;height:100%;font:inherit;color:var(--_label-text-color);padding:0;gap:inherit;text-transform:inherit}.button::-moz-focus-inner{padding:0;border:0}:host(:hover) .button{color:var(--_hover-label-text-color)}:host(:focus-within) .button{color:var(--_focus-label-text-color)}:host(:active) .button{color:var(--_pressed-label-text-color)}.background{background:var(--_container-color);border-radius:inherit;inset:0;position:absolute}.label{overflow:hidden}:is(.button,.label,.label slot),.label ::slotted(*){text-overflow:inherit}:host(:is([disabled],[soft-disabled])) .label{color:var(--_disabled-label-text-color);opacity:var(--_disabled-label-text-opacity)}:host(:is([disabled],[soft-disabled])) .background{background:var(--_disabled-container-color);opacity:var(--_disabled-container-opacity)}@media(forced-colors: active){.background{border:1px solid CanvasText}:host(:is([disabled],[soft-disabled])){--_disabled-icon-color: GrayText;--_disabled-icon-opacity: 1;--_disabled-container-opacity: 1;--_disabled-label-text-color: GrayText;--_disabled-label-text-opacity: 1}}:host([has-icon]:not([trailing-icon])){padding-inline-start:var(--_with-leading-icon-leading-space);padding-inline-end:var(--_with-leading-icon-trailing-space)}:host([has-icon][trailing-icon]){padding-inline-start:var(--_with-trailing-icon-leading-space);padding-inline-end:var(--_with-trailing-icon-trailing-space)}::slotted([slot=icon]){display:inline-flex;position:relative;writing-mode:horizontal-tb;fill:currentColor;flex-shrink:0;color:var(--_icon-color);font-size:var(--_icon-size);inline-size:var(--_icon-size);block-size:var(--_icon-size)}:host(:hover) ::slotted([slot=icon]){color:var(--_hover-icon-color)}:host(:focus-within) ::slotted([slot=icon]){color:var(--_focus-icon-color)}:host(:active) ::slotted([slot=icon]){color:var(--_pressed-icon-color)}:host(:is([disabled],[soft-disabled])) ::slotted([slot=icon]){color:var(--_disabled-icon-color);opacity:var(--_disabled-icon-opacity)}.touch{position:absolute;top:50%;height:48px;left:0;right:0;transform:translateY(-50%)}:host([touch-target=wrapper]){margin:max(0px,(48px - var(--_container-height))/2) 0}:host([touch-target=none]) .touch{display:none}
+`;var wr=class extends Dt{};wr.styles=[Eo,wo,_o];wr=s([E("md-filled-button")],wr);var Co=Symbol("dispatchHooks");function $o(o,e){let t=o[Co];if(!t)throw new Error(`'${o.type}' event needs setupDispatchHooks().`);t.addEventListener("after",e)}var Ao=new WeakMap;function To(o,...e){let t=Ao.get(o);t||(t=new Set,Ao.set(o,t));for(let r of e){if(t.has(r))continue;let i=!1;o.addEventListener(r,n=>{if(i)return;n.stopImmediatePropagation();let a=Reflect.construct(n.constructor,[n.type,n]),d=new EventTarget;a[Co]=d,i=!0;let c=o.dispatchEvent(a);i=!1,c||n.preventDefault(),d.dispatchEvent(new Event("after"))},{capture:!0}),t.add(r)}}var zt=class extends me{computeValidity(e){return this.checkboxControl||(this.checkboxControl=document.createElement("input"),this.checkboxControl.type="checkbox"),this.checkboxControl.checked=e.checked,this.checkboxControl.required=e.required,{validity:this.checkboxControl.validity,validationMessage:this.checkboxControl.validationMessage}}equals(e,t){return e.checked===t.checked&&e.required===t.required}copy({checked:e,required:t}){return{checked:e,required:t}}};var Ci=W(Oe(Re(ee(b)))),Z=class extends Ci{constructor(){super(),this.selected=!1,this.icons=!1,this.showOnlySelectedIcon=!1,this.required=!1,this.value="on",!!1&&(this.addEventListener("click",e=>{!Lt(e)||!this.input||(this.focus(),Pt(this.input))}),To(this,"keydown"),this.addEventListener("keydown",e=>{$o(e,()=>{e.defaultPrevented||e.key!=="Enter"||this.disabled||!this.input||this.input.click()})}))}render(){return u`
+
+
+
+
+ ${this.renderHandle()}
+
+ `}getRenderClasses(){return{selected:this.selected,unselected:!this.selected,disabled:this.disabled}}renderHandle(){let e={"with-icon":this.showOnlySelectedIcon?this.selected:this.icons};return u`
+ ${this.renderTouchTarget()}
+
+
+
+ ${this.shouldShowIcons()?this.renderIcons():u``}
+
+
+ `}renderIcons(){return u`
+
+ ${this.renderOnIcon()}
+ ${this.showOnlySelectedIcon?u``:this.renderOffIcon()}
+
+ `}renderOnIcon(){return u`
+
+
+
+
+
+ `}renderOffIcon(){return u`
+
+
+
+
+
+ `}renderTouchTarget(){return u` `}shouldShowIcons(){return this.icons||this.showOnlySelectedIcon}handleInput(e){let t=e.target;this.selected=t.checked}handleChange(e){ke(this,e)}[se](){return this.selected?this.value:null}[ht](){return String(this.selected)}formResetCallback(){this.selected=this.hasAttribute("selected")}formStateRestoreCallback(e){this.selected=e==="true"}[he](){return new zt(()=>({checked:this.selected,required:this.required}))}[fe](){return this.input}};Z.shadowRootOptions={mode:"open",delegatesFocus:!0};s([l({type:Boolean})],Z.prototype,"selected",void 0);s([l({type:Boolean})],Z.prototype,"icons",void 0);s([l({type:Boolean,attribute:"show-only-selected-icon"})],Z.prototype,"showOnlySelectedIcon",void 0);s([l({type:Boolean})],Z.prototype,"required",void 0);s([l()],Z.prototype,"value",void 0);s([S("input")],Z.prototype,"input",void 0);var So=g`@layer styles, hcm;@layer styles{:host{display:inline-flex;outline:none;vertical-align:top;-webkit-tap-highlight-color:rgba(0,0,0,0);cursor:pointer}:host([disabled]){cursor:default}:host([touch-target=wrapper]){margin:max(0px,(48px - var(--md-switch-track-height, 32px))/2) 0px}md-focus-ring{--md-focus-ring-shape-start-start: var(--md-switch-track-shape-start-start, var(--md-switch-track-shape, var(--md-sys-shape-corner-full, 9999px)));--md-focus-ring-shape-start-end: var(--md-switch-track-shape-start-end, var(--md-switch-track-shape, var(--md-sys-shape-corner-full, 9999px)));--md-focus-ring-shape-end-end: var(--md-switch-track-shape-end-end, var(--md-switch-track-shape, var(--md-sys-shape-corner-full, 9999px)));--md-focus-ring-shape-end-start: var(--md-switch-track-shape-end-start, var(--md-switch-track-shape, var(--md-sys-shape-corner-full, 9999px)))}.switch{align-items:center;display:inline-flex;flex-shrink:0;position:relative;width:var(--md-switch-track-width, 52px);height:var(--md-switch-track-height, 32px);border-start-start-radius:var(--md-switch-track-shape-start-start, var(--md-switch-track-shape, var(--md-sys-shape-corner-full, 9999px)));border-start-end-radius:var(--md-switch-track-shape-start-end, var(--md-switch-track-shape, var(--md-sys-shape-corner-full, 9999px)));border-end-end-radius:var(--md-switch-track-shape-end-end, var(--md-switch-track-shape, var(--md-sys-shape-corner-full, 9999px)));border-end-start-radius:var(--md-switch-track-shape-end-start, var(--md-switch-track-shape, var(--md-sys-shape-corner-full, 9999px)))}input{appearance:none;height:max(100%,var(--md-switch-touch-target-size, 48px));outline:none;margin:0;position:absolute;width:max(100%,var(--md-switch-touch-target-size, 48px));z-index:1;cursor:inherit;top:50%;left:50%;transform:translate(-50%, -50%)}:host([touch-target=none]) input{display:none}}@layer styles{.track{position:absolute;width:100%;height:100%;box-sizing:border-box;border-radius:inherit;display:flex;justify-content:center;align-items:center}.track::before{content:"";display:flex;position:absolute;height:100%;width:100%;border-radius:inherit;box-sizing:border-box;transition-property:opacity,background-color;transition-timing-function:linear;transition-duration:67ms}.disabled .track{background-color:rgba(0,0,0,0);border-color:rgba(0,0,0,0)}.disabled .track::before,.disabled .track::after{transition:none;opacity:var(--md-switch-disabled-track-opacity, 0.12)}.disabled .track::before{background-clip:content-box}.selected .track::before{background-color:var(--md-switch-selected-track-color, var(--md-sys-color-primary, #6750a4))}.selected:hover .track::before{background-color:var(--md-switch-selected-hover-track-color, var(--md-sys-color-primary, #6750a4))}.selected:focus-within .track::before{background-color:var(--md-switch-selected-focus-track-color, var(--md-sys-color-primary, #6750a4))}.selected:active .track::before{background-color:var(--md-switch-selected-pressed-track-color, var(--md-sys-color-primary, #6750a4))}.selected.disabled .track{background-clip:border-box}.selected.disabled .track::before{background-color:var(--md-switch-disabled-selected-track-color, var(--md-sys-color-on-surface, #1d1b20))}.unselected .track::before{background-color:var(--md-switch-track-color, var(--md-sys-color-surface-container-highest, #e6e0e9));border-color:var(--md-switch-track-outline-color, var(--md-sys-color-outline, #79747e));border-style:solid;border-width:var(--md-switch-track-outline-width, 2px)}.unselected:hover .track::before{background-color:var(--md-switch-hover-track-color, var(--md-sys-color-surface-container-highest, #e6e0e9));border-color:var(--md-switch-hover-track-outline-color, var(--md-sys-color-outline, #79747e))}.unselected:focus-visible .track::before{background-color:var(--md-switch-focus-track-color, var(--md-sys-color-surface-container-highest, #e6e0e9));border-color:var(--md-switch-focus-track-outline-color, var(--md-sys-color-outline, #79747e))}.unselected:active .track::before{background-color:var(--md-switch-pressed-track-color, var(--md-sys-color-surface-container-highest, #e6e0e9));border-color:var(--md-switch-pressed-track-outline-color, var(--md-sys-color-outline, #79747e))}.unselected.disabled .track::before{background-color:var(--md-switch-disabled-track-color, var(--md-sys-color-surface-container-highest, #e6e0e9));border-color:var(--md-switch-disabled-track-outline-color, var(--md-sys-color-on-surface, #1d1b20))}}@layer hcm{@media(forced-colors: active){.selected .track::before{background:ButtonText;border-color:ButtonText}.disabled .track::before{border-color:GrayText;opacity:1}.disabled.selected .track::before{background:GrayText}}}@layer styles{.handle-container{display:flex;place-content:center;place-items:center;position:relative;transition:margin 300ms cubic-bezier(0.175, 0.885, 0.32, 1.275)}.selected .handle-container{margin-inline-start:calc(var(--md-switch-track-width, 52px) - var(--md-switch-track-height, 32px))}.unselected .handle-container{margin-inline-end:calc(var(--md-switch-track-width, 52px) - var(--md-switch-track-height, 32px))}.disabled .handle-container{transition:none}.handle{border-start-start-radius:var(--md-switch-handle-shape-start-start, var(--md-switch-handle-shape, var(--md-sys-shape-corner-full, 9999px)));border-start-end-radius:var(--md-switch-handle-shape-start-end, var(--md-switch-handle-shape, var(--md-sys-shape-corner-full, 9999px)));border-end-end-radius:var(--md-switch-handle-shape-end-end, var(--md-switch-handle-shape, var(--md-sys-shape-corner-full, 9999px)));border-end-start-radius:var(--md-switch-handle-shape-end-start, var(--md-switch-handle-shape, var(--md-sys-shape-corner-full, 9999px)));height:var(--md-switch-handle-height, 16px);width:var(--md-switch-handle-width, 16px);transform-origin:center;transition-property:height,width;transition-duration:250ms,250ms;transition-timing-function:cubic-bezier(0.2, 0, 0, 1),cubic-bezier(0.2, 0, 0, 1);z-index:0}.handle::before{content:"";display:flex;inset:0;position:absolute;border-radius:inherit;box-sizing:border-box;transition:background-color 67ms linear}.disabled .handle,.disabled .handle::before{transition:none}.selected .handle{height:var(--md-switch-selected-handle-height, 24px);width:var(--md-switch-selected-handle-width, 24px)}.handle.with-icon{height:var(--md-switch-with-icon-handle-height, 24px);width:var(--md-switch-with-icon-handle-width, 24px)}.selected:not(.disabled):active .handle,.unselected:not(.disabled):active .handle{height:var(--md-switch-pressed-handle-height, 28px);width:var(--md-switch-pressed-handle-width, 28px);transition-timing-function:linear;transition-duration:100ms}.selected .handle::before{background-color:var(--md-switch-selected-handle-color, var(--md-sys-color-on-primary, #fff))}.selected:hover .handle::before{background-color:var(--md-switch-selected-hover-handle-color, var(--md-sys-color-primary-container, #eaddff))}.selected:focus-within .handle::before{background-color:var(--md-switch-selected-focus-handle-color, var(--md-sys-color-primary-container, #eaddff))}.selected:active .handle::before{background-color:var(--md-switch-selected-pressed-handle-color, var(--md-sys-color-primary-container, #eaddff))}.selected.disabled .handle::before{background-color:var(--md-switch-disabled-selected-handle-color, var(--md-sys-color-surface, #fef7ff));opacity:var(--md-switch-disabled-selected-handle-opacity, 1)}.unselected .handle::before{background-color:var(--md-switch-handle-color, var(--md-sys-color-outline, #79747e))}.unselected:hover .handle::before{background-color:var(--md-switch-hover-handle-color, var(--md-sys-color-on-surface-variant, #49454f))}.unselected:focus-within .handle::before{background-color:var(--md-switch-focus-handle-color, var(--md-sys-color-on-surface-variant, #49454f))}.unselected:active .handle::before{background-color:var(--md-switch-pressed-handle-color, var(--md-sys-color-on-surface-variant, #49454f))}.unselected.disabled .handle::before{background-color:var(--md-switch-disabled-handle-color, var(--md-sys-color-on-surface, #1d1b20));opacity:var(--md-switch-disabled-handle-opacity, 0.38)}md-ripple{border-radius:var(--md-switch-state-layer-shape, var(--md-sys-shape-corner-full, 9999px));height:var(--md-switch-state-layer-size, 40px);inset:unset;width:var(--md-switch-state-layer-size, 40px)}.selected md-ripple{--md-ripple-hover-color: var(--md-switch-selected-hover-state-layer-color, var(--md-sys-color-primary, #6750a4));--md-ripple-pressed-color: var(--md-switch-selected-pressed-state-layer-color, var(--md-sys-color-primary, #6750a4));--md-ripple-hover-opacity: var(--md-switch-selected-hover-state-layer-opacity, 0.08);--md-ripple-pressed-opacity: var(--md-switch-selected-pressed-state-layer-opacity, 0.12)}.unselected md-ripple{--md-ripple-hover-color: var(--md-switch-hover-state-layer-color, var(--md-sys-color-on-surface, #1d1b20));--md-ripple-pressed-color: var(--md-switch-pressed-state-layer-color, var(--md-sys-color-on-surface, #1d1b20));--md-ripple-hover-opacity: var(--md-switch-hover-state-layer-opacity, 0.08);--md-ripple-pressed-opacity: var(--md-switch-pressed-state-layer-opacity, 0.12)}}@layer hcm{@media(forced-colors: active){.unselected .handle::before{background:ButtonText}.disabled .handle::before{opacity:1}.disabled.unselected .handle::before{background:GrayText}}}@layer styles{.icons{position:relative;height:100%;width:100%}.icon{position:absolute;inset:0;margin:auto;display:flex;align-items:center;justify-content:center;fill:currentColor;transition:fill 67ms linear,opacity 33ms linear,transform 167ms cubic-bezier(0.2, 0, 0, 1);opacity:0}.disabled .icon{transition:none}.selected .icon--on,.unselected .icon--off{opacity:1}.unselected .handle:not(.with-icon) .icon--on{transform:rotate(-45deg)}.icon--off{width:var(--md-switch-icon-size, 16px);height:var(--md-switch-icon-size, 16px);color:var(--md-switch-icon-color, var(--md-sys-color-surface-container-highest, #e6e0e9))}.unselected:hover .icon--off{color:var(--md-switch-hover-icon-color, var(--md-sys-color-surface-container-highest, #e6e0e9))}.unselected:focus-within .icon--off{color:var(--md-switch-focus-icon-color, var(--md-sys-color-surface-container-highest, #e6e0e9))}.unselected:active .icon--off{color:var(--md-switch-pressed-icon-color, var(--md-sys-color-surface-container-highest, #e6e0e9))}.unselected.disabled .icon--off{color:var(--md-switch-disabled-icon-color, var(--md-sys-color-surface-container-highest, #e6e0e9));opacity:var(--md-switch-disabled-icon-opacity, 0.38)}.icon--on{width:var(--md-switch-selected-icon-size, 16px);height:var(--md-switch-selected-icon-size, 16px);color:var(--md-switch-selected-icon-color, var(--md-sys-color-on-primary-container, #21005d))}.selected:hover .icon--on{color:var(--md-switch-selected-hover-icon-color, var(--md-sys-color-on-primary-container, #21005d))}.selected:focus-within .icon--on{color:var(--md-switch-selected-focus-icon-color, var(--md-sys-color-on-primary-container, #21005d))}.selected:active .icon--on{color:var(--md-switch-selected-pressed-icon-color, var(--md-sys-color-on-primary-container, #21005d))}.selected.disabled .icon--on{color:var(--md-switch-disabled-selected-icon-color, var(--md-sys-color-on-surface, #1d1b20));opacity:var(--md-switch-disabled-selected-icon-opacity, 0.38)}}@layer hcm{@media(forced-colors: active){.icon--off{fill:Canvas}.icon--on{fill:ButtonText}.disabled.unselected .icon--off,.disabled.selected .icon--on{opacity:1}.disabled .icon--on{fill:GrayText}}}
+`;var Er=class extends Z{};Er.styles=[So];Er=s([E("md-switch")],Er);})();
diff --git a/mikuproject-src.html b/mikuproject-src.html
new file mode 100644
index 0000000..82735ea
--- /dev/null
+++ b/mikuproject-src.html
@@ -0,0 +1,373 @@
+
+
+
+
+
+ mikuproject
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 📋
+ mikuproject
+
+ MS Project XML を読み込み、内部モデル・XLSX・生成AI向け JSON を行き来しながら、プロジェクト計画を確認・編集するためのアプリです。
+
+
+
+
+
+ GitHub
+
+
+
+
+
+
+
+ 1
+ Input
+
+
+ 2
+ Overview
+
+
+ 3
+ Output
+
+
+
+ 未実行
+
+
+
+
+
+
+
+
+
+
+ MS Project XML
+ XLSX
+ JSON
+ CSV
+
+
+ WBS XLSX
+ WBS Markdown
+ Mermaid
+ SVG
+
+
+
+
+ 生成AI連携
+
+
+
+ full bundle
+
+ 既存 project を生成AIへ渡すための projection JSON をここから生成します。
+
+ project_overview_view
+ phase_detail_view full
+
+
+ phase_detail_view scoped
+
+ phase の一部だけを生成AIへ渡したい時に使います。
+
+
+
+
+
+
+
+ phase_detail_view scoped
+
+
+
+
+
+
+
+
+ 設定
+
+
+ WBS XLSX の祝日指定
+
+ `WBS XLSX Export` では、`ProjectModel` から補完した既定祝日を WBS 日付帯へ反映します。
+
+
+ 既定祝日は、現在の `ProjectModel` に含まれる `Calendar.Exceptions` の非稼働日例外から補完します。画面では `Calendars / Exceptions` を直接編集せず、必要な変更は `MS Project XML` または `XLSX Import` 側で扱います。表示期間を空欄にすると全期間、数値を入れると `BaseDate` 前後の営業日で切り出します。進捗帯も営業日基準で計算します。
+
+
+
+
+
+
+
+
+
+
+
+
+ デバッグ情報
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mikuproject.html b/mikuproject.html
new file mode 100644
index 0000000..851e995
--- /dev/null
+++ b/mikuproject.html
@@ -0,0 +1,14329 @@
+
+
+
+
+
+ mikuproject
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 📋
+ mikuproject
+
+ MS Project XML を読み込み、内部モデル・XLSX・生成AI向け JSON を行き来しながら、プロジェクト計画を確認・編集するためのアプリです。
+
+
+
+
+
+ GitHub
+
+
+
+
+
+
+
+ 1
+ Input
+
+
+ 2
+ Overview
+
+
+ 3
+ Output
+
+
+
+ 未実行
+
+
+
+
+
+
+
+
+
+
+ MS Project XML
+ XLSX
+ JSON
+ CSV
+
+
+ WBS XLSX
+ WBS Markdown
+ Mermaid
+ SVG
+
+
+
+
+ 生成AI連携
+
+
+
+ full bundle
+
+ 既存 project を生成AIへ渡すための projection JSON をここから生成します。
+
+ project_overview_view
+ phase_detail_view full
+
+
+ phase_detail_view scoped
+
+ phase の一部だけを生成AIへ渡したい時に使います。
+
+
+
+
+
+
+
+ phase_detail_view scoped
+
+
+
+
+
+
+
+
+ 設定
+
+
+ WBS XLSX の祝日指定
+
+ `WBS XLSX Export` では、`ProjectModel` から補完した既定祝日を WBS 日付帯へ反映します。
+
+
+ 既定祝日は、現在の `ProjectModel` に含まれる `Calendar.Exceptions` の非稼働日例外から補完します。画面では `Calendars / Exceptions` を直接編集せず、必要な変更は `MS Project XML` または `XLSX Import` 側で扱います。表示期間を空欄にすると全期間、数値を入れると `BaseDate` 前後の営業日で切り出します。進捗帯も営業日基準で計算します。
+
+
+
+
+
+
+
+
+
+
+
+
+ デバッグ情報
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..541bb11
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2107 @@
+{
+ "name": "mikuproject",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "mikuproject",
+ "version": "0.1.0",
+ "license": "Apache-2.0",
+ "devDependencies": {
+ "jsdom": "^26.1.0",
+ "typescript": "^5.8.2",
+ "vitest": "^3.2.4"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
+ "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
+ "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
+ "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
+ "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
+ "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
+ "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
+ "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
+ "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
+ "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
+ "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
+ "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
+ "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
+ "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
+ "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
+ "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
+ "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
+ "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
+ "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
+ "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
+ "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
+ "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
+ "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
+ "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
+ "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
+ "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
+ "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
+ "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
+ "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
+ "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
+ "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
+ "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
+ "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
+ "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
+ "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
+ "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
+ "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
+ "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
+ "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
+ "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
+ "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
+ "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
+ "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
+ "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
+ "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
+ "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
+ "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
+ "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
+ "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
+ "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
+ "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
+ "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitest/expect": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.4",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.4",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "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/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
+ "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.4",
+ "@esbuild/android-arm": "0.27.4",
+ "@esbuild/android-arm64": "0.27.4",
+ "@esbuild/android-x64": "0.27.4",
+ "@esbuild/darwin-arm64": "0.27.4",
+ "@esbuild/darwin-x64": "0.27.4",
+ "@esbuild/freebsd-arm64": "0.27.4",
+ "@esbuild/freebsd-x64": "0.27.4",
+ "@esbuild/linux-arm": "0.27.4",
+ "@esbuild/linux-arm64": "0.27.4",
+ "@esbuild/linux-ia32": "0.27.4",
+ "@esbuild/linux-loong64": "0.27.4",
+ "@esbuild/linux-mips64el": "0.27.4",
+ "@esbuild/linux-ppc64": "0.27.4",
+ "@esbuild/linux-riscv64": "0.27.4",
+ "@esbuild/linux-s390x": "0.27.4",
+ "@esbuild/linux-x64": "0.27.4",
+ "@esbuild/netbsd-arm64": "0.27.4",
+ "@esbuild/netbsd-x64": "0.27.4",
+ "@esbuild/openbsd-arm64": "0.27.4",
+ "@esbuild/openbsd-x64": "0.27.4",
+ "@esbuild/openharmony-arm64": "0.27.4",
+ "@esbuild/sunos-x64": "0.27.4",
+ "@esbuild/win32-arm64": "0.27.4",
+ "@esbuild/win32-ia32": "0.27.4",
+ "@esbuild/win32-x64": "0.27.4"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "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/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/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsdom": {
+ "version": "26.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
+ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssstyle": "^4.2.1",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.5.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.16",
+ "parse5": "^7.2.1",
+ "rrweb-cssom": "^0.8.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.1.1",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.1.1",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/nwsapi": {
+ "version": "2.2.23",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
+ "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "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",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
+ "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.0",
+ "@rollup/rollup-android-arm64": "4.60.0",
+ "@rollup/rollup-darwin-arm64": "4.60.0",
+ "@rollup/rollup-darwin-x64": "4.60.0",
+ "@rollup/rollup-freebsd-arm64": "4.60.0",
+ "@rollup/rollup-freebsd-x64": "4.60.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.0",
+ "@rollup/rollup-linux-arm64-musl": "4.60.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.0",
+ "@rollup/rollup-linux-loong64-musl": "4.60.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.0",
+ "@rollup/rollup-linux-x64-gnu": "4.60.0",
+ "@rollup/rollup-linux-x64-musl": "4.60.0",
+ "@rollup/rollup-openbsd-x64": "4.60.0",
+ "@rollup/rollup-openharmony-arm64": "4.60.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.0",
+ "@rollup/rollup-win32-x64-gnu": "4.60.0",
+ "@rollup/rollup-win32-x64-msvc": "4.60.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "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/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.4",
+ "@vitest/mocker": "3.2.4",
+ "@vitest/pretty-format": "^3.2.4",
+ "@vitest/runner": "3.2.4",
+ "@vitest/snapshot": "3.2.4",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.4",
+ "@vitest/ui": "3.2.4",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "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": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..4c38786
--- /dev/null
+++ b/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "mikuproject",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Local HTML tool for MS Project XML round-trip experiments.",
+ "license": "Apache-2.0",
+ "type": "module",
+ "scripts": {
+ "build": "npm run build:app && npm test && npm run build:xlsx-sample",
+ "build:js": "node scripts/build-project.mjs --js-only",
+ "build:html": "node scripts/build-project.mjs --html-only",
+ "build:app": "node scripts/build-project.mjs",
+ "build:xlsx-sample": "node scripts/build-project-xlsx-sample.mjs",
+ "test": "vitest run"
+ },
+ "devDependencies": {
+ "jsdom": "^26.1.0",
+ "typescript": "^5.8.2",
+ "vitest": "^3.2.4"
+ }
+}
diff --git a/scripts/build-project-xlsx-sample.mjs b/scripts/build-project-xlsx-sample.mjs
new file mode 100644
index 0000000..e459ffb
--- /dev/null
+++ b/scripts/build-project-xlsx-sample.mjs
@@ -0,0 +1,149 @@
+import fs from "node:fs";
+import path from "node:path";
+import { JSDOM } from "jsdom";
+
+const ROOT = process.cwd();
+const typesCode = fs.readFileSync(path.resolve(ROOT, "src/js/types.js"), "utf8");
+const markdownEscapeCode = fs.readFileSync(path.resolve(ROOT, "src/js/markdown-escape.js"), "utf8");
+const excelIoCode = fs.readFileSync(path.resolve(ROOT, "src/js/excel-io.js"), "utf8");
+const msProjectXmlCode = fs.readFileSync(path.resolve(ROOT, "src/js/msproject-xml.js"), "utf8");
+const projectWorkbookSchemaCode = fs.readFileSync(path.resolve(ROOT, "src/js/project-workbook-schema.js"), "utf8");
+const projectXlsxCode = fs.readFileSync(path.resolve(ROOT, "src/js/project-xlsx.js"), "utf8");
+const wbsXlsxCode = fs.readFileSync(path.resolve(ROOT, "src/js/wbs-xlsx.js"), "utf8");
+const wbsMarkdownCode = fs.readFileSync(path.resolve(ROOT, "src/js/wbs-markdown.js"), "utf8");
+
+const dom = new JSDOM("");
+globalThis.window = dom.window;
+globalThis.document = dom.window.document;
+globalThis.DOMParser = dom.window.DOMParser;
+globalThis.XMLSerializer = dom.window.XMLSerializer;
+globalThis.Node = dom.window.Node;
+
+globalThis.eval(`${typesCode}\n${markdownEscapeCode}\n${excelIoCode}\n${msProjectXmlCode}\n${projectWorkbookSchemaCode}\n${projectXlsxCode}\n${wbsXlsxCode}\n${wbsMarkdownCode}`);
+
+const excelIo = globalThis.__mikuprojectExcelIo;
+const xml = globalThis.__mikuprojectXml;
+const projectXlsx = globalThis.__mikuprojectProjectXlsx;
+const wbsXlsx = globalThis.__mikuprojectWbsXlsx;
+const wbsMarkdown = globalThis.__mikuprojectWbsMarkdown;
+if (!excelIo?.XlsxWorkbookCodec) {
+ throw new Error("mikuproject excel io module is not loaded");
+}
+if (!xml?.SAMPLE_XML || typeof xml.importMsProjectXml !== "function") {
+ throw new Error("mikuproject xml module is not loaded");
+}
+if (typeof projectXlsx?.exportProjectWorkbook !== "function") {
+ throw new Error("mikuproject project xlsx module is not loaded");
+}
+if (typeof wbsXlsx?.exportWbsWorkbook !== "function") {
+ throw new Error("mikuproject wbs xlsx module is not loaded");
+}
+if (typeof wbsMarkdown?.exportWbsMarkdown !== "function") {
+ throw new Error("mikuproject wbs markdown module is not loaded");
+}
+
+const codec = new excelIo.XlsxWorkbookCodec();
+const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+const workbook = projectXlsx.exportProjectWorkbook(model);
+const holidayDates = wbsXlsx.collectWbsHolidayDates(model);
+const wbsWorkbook = wbsXlsx.exportWbsWorkbook(model, { holidayDates });
+const wbsMarkdownText = wbsMarkdown.exportWbsMarkdown(model, {
+ holidayDates,
+ useBusinessDaysForDisplayRange: true,
+ useBusinessDaysForProgressBand: true
+});
+const richMarkdownModel = buildRichWbsMarkdownSampleModel(model);
+const richHolidayDates = wbsXlsx.collectWbsHolidayDates(richMarkdownModel);
+const richWbsMarkdownText = wbsMarkdown.exportWbsMarkdown(richMarkdownModel, {
+ holidayDates: richHolidayDates,
+ useBusinessDaysForDisplayRange: true,
+ useBusinessDaysForProgressBand: true
+});
+
+const bytes = codec.exportWorkbook(workbook);
+const wbsBytes = codec.exportWorkbook(wbsWorkbook);
+const outputPath = path.resolve(ROOT, "local-data/mikuproject-sample.xlsx");
+const wbsOutputPath = path.resolve(ROOT, "local-data/mikuproject-wbs-sample.xlsx");
+const wbsMarkdownOutputPath = path.resolve(ROOT, "local-data/mikuproject-wbs-sample.md");
+const richWbsMarkdownOutputPath = path.resolve(ROOT, "local-data/mikuproject-wbs-sample-rich.md");
+fs.mkdirSync(path.dirname(outputPath), { recursive: true });
+fs.writeFileSync(outputPath, Buffer.from(bytes));
+fs.writeFileSync(wbsOutputPath, Buffer.from(wbsBytes));
+fs.writeFileSync(wbsMarkdownOutputPath, wbsMarkdownText, "utf8");
+fs.writeFileSync(richWbsMarkdownOutputPath, richWbsMarkdownText, "utf8");
+console.log(`[build:project:xlsx-sample] generated ${path.relative(ROOT, outputPath)}`);
+console.log(`[build:project:xlsx-sample] generated ${path.relative(ROOT, wbsOutputPath)}`);
+console.log(`[build:project:xlsx-sample] generated ${path.relative(ROOT, wbsMarkdownOutputPath)}`);
+console.log(`[build:project:xlsx-sample] generated ${path.relative(ROOT, richWbsMarkdownOutputPath)}`);
+
+function buildRichWbsMarkdownSampleModel(model) {
+ const cloned = JSON.parse(JSON.stringify(model));
+ const longNameTarget = cloned.tasks.find((task) => task.outlineNumber === "1.2");
+ if (longNameTarget) {
+ longNameTarget.name = "初期実装(MS Project XML 調査・基軸フォーマット選定・内部モデルの概要確定)";
+ longNameTarget.notes = "前提と制約を整理する\n関係者レビューを実施する";
+ }
+ const insertIndex = cloned.tasks.findIndex((task) => task.outlineNumber === "1.2");
+ if (insertIndex >= 0) {
+ const templateTask = cloned.tasks[insertIndex];
+ cloned.tasks.splice(
+ insertIndex + 1,
+ 0,
+ createDerivedTask(templateTask, {
+ uid: "901",
+ id: "901",
+ name: "内部 JSON 形式への写像方針を確認する",
+ outlineLevel: 3,
+ outlineNumber: "1.2.1",
+ wbs: "1.2.1",
+ start: "2026-03-17T09:00:00",
+ finish: "2026-03-18T18:00:00",
+ duration: "PT16H0M0S",
+ percentComplete: 40,
+ notes: "説明責務と round-trip 観点を切り分ける"
+ }),
+ createDerivedTask(templateTask, {
+ uid: "902",
+ id: "902",
+ name: "フィールド差分の洗い出し結果を整理する",
+ outlineLevel: 4,
+ outlineNumber: "1.2.1.1",
+ wbs: "1.2.1.1",
+ start: "2026-03-18T09:00:00",
+ finish: "2026-03-18T18:00:00",
+ duration: "PT8H0M0S",
+ percentComplete: 10,
+ notes: "XML と内部モデルの差分を箇条書きで残す"
+ }),
+ createDerivedTask(templateTask, {
+ uid: "903",
+ id: "903",
+ name: "長い説明文の折り返しを確認するための task",
+ outlineLevel: 5,
+ outlineNumber: "1.2.1.1.1",
+ wbs: "1.2.1.1.1",
+ start: "2026-03-18T09:00:00",
+ finish: "2026-03-19T18:00:00",
+ duration: "PT16H0M0S",
+ percentComplete: 0,
+ notes: "かなり長い補足説明をここへ入れて、Markdown tree の見え方と table の見え方を同時に確認する"
+ })
+ );
+ }
+ return cloned;
+}
+
+function createDerivedTask(baseTask, overrides) {
+ return {
+ ...baseTask,
+ ...overrides,
+ milestone: false,
+ summary: false,
+ critical: false,
+ percentWorkComplete: overrides.percentComplete,
+ predecessors: [],
+ extendedAttributes: [],
+ baselines: [],
+ timephasedData: []
+ };
+}
diff --git a/scripts/build-project.mjs b/scripts/build-project.mjs
new file mode 100644
index 0000000..786bdbf
--- /dev/null
+++ b/scripts/build-project.mjs
@@ -0,0 +1,131 @@
+import fs from "node:fs";
+import path from "node:path";
+import { buildSingleHtmlFromSource } from "./lib/single-html.mjs";
+
+const ROOT = process.cwd();
+const args = new Set(process.argv.slice(2));
+const buildJs = !args.has("--html-only");
+const buildHtml = !args.has("--js-only");
+
+const TARGETS = [
+ {
+ id: "index",
+ srcHtml: "index-src.html",
+ outHtml: "index.html"
+ },
+ {
+ id: "mikuproject",
+ srcHtml: "mikuproject-src.html",
+ outHtml: "mikuproject.html",
+ tsOrder: [
+ "src/ts/types.ts",
+ "src/ts/markdown-escape.ts",
+ "src/ts/excel-io.ts",
+ "src/ts/msproject-xml.ts",
+ "src/ts/project-workbook-schema.ts",
+ "src/ts/project-xlsx.ts",
+ "src/ts/project-workbook-json.ts",
+ "src/ts/wbs-xlsx.ts",
+ "src/ts/wbs-markdown.ts",
+ "src/ts/native-svg.ts",
+ "src/ts/main.ts"
+ ]
+ }
+];
+
+const tsModule = await loadTypeScriptModule();
+
+if (buildJs) {
+ for (const target of TARGETS) {
+ transpileTypeScript(target, tsModule);
+ }
+}
+
+if (buildHtml) {
+ for (const target of TARGETS) {
+ const srcPath = path.resolve(ROOT, target.srcHtml);
+ const outPath = path.resolve(ROOT, target.outHtml);
+ const source = fs.readFileSync(srcPath, "utf8");
+ const output = buildSingleHtmlFromSource(applyTemplateValues(source), srcPath, ROOT);
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
+ fs.writeFileSync(outPath, output, "utf8");
+ console.log(`[build:project] generated ${target.outHtml}`);
+ }
+}
+
+async function loadTypeScriptModule() {
+ try {
+ const module = await import("typescript");
+ return module.default || module;
+ } catch (_error) {
+ return null;
+ }
+}
+
+function transpileTypeScript(target, tsModule) {
+ for (const relTsPath of target.tsOrder || []) {
+ const tsPath = path.resolve(ROOT, relTsPath);
+ const jsPath = path.resolve(
+ ROOT,
+ relTsPath.replace("/ts/", "/js/").replace(/\.ts$/, ".js")
+ );
+
+ const source = fs.readFileSync(tsPath, "utf8");
+ let outputText = source;
+ if (tsModule) {
+ const result = tsModule.transpileModule(source, {
+ compilerOptions: {
+ target: tsModule.ScriptTarget.ES2019,
+ module: tsModule.ModuleKind.None,
+ lib: ["ES2020", "DOM"],
+ strict: false,
+ skipLibCheck: true
+ },
+ reportDiagnostics: true,
+ fileName: tsPath
+ });
+
+ if (result.diagnostics && result.diagnostics.length > 0) {
+ const errors = result.diagnostics
+ .filter((diagnostic) => diagnostic.category === tsModule.DiagnosticCategory.Error)
+ .map((diagnostic) => tsModule.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
+ if (errors.length > 0) {
+ throw new Error(`TypeScript transpile error in ${relTsPath}:\n${errors.join("\n")}`);
+ }
+ }
+ outputText = result.outputText;
+ } else {
+ console.warn(
+ `[build:project] typescript not found. copied ${relTsPath} -> ${relTsPath.replace("/ts/", "/js/").replace(/\.ts$/, ".js")}`
+ );
+ }
+
+ fs.mkdirSync(path.dirname(jsPath), { recursive: true });
+ fs.writeFileSync(jsPath, outputText, "utf8");
+ }
+}
+
+function applyTemplateValues(source) {
+ return source
+ .replaceAll("{{BUILD_DATE}}", formatBuildDate(new Date()))
+ .replaceAll("{{AI_PROMPT_TEXT}}", escapeHtml(loadAiPromptText()));
+}
+
+function formatBuildDate(date) {
+ 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}`;
+}
+
+function loadAiPromptText() {
+ const promptPath = path.resolve(ROOT, "docs/mikuproject-ai-json-spec.md");
+ return fs.readFileSync(promptPath, "utf8");
+}
+
+function escapeHtml(text) {
+ return text
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">");
+}
diff --git a/scripts/lib/single-html.mjs b/scripts/lib/single-html.mjs
new file mode 100644
index 0000000..49e93b3
--- /dev/null
+++ b/scripts/lib/single-html.mjs
@@ -0,0 +1,74 @@
+import fs from "node:fs";
+import path from "node:path";
+
+function getAttr(tagText, attrName) {
+ const escaped = attrName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const attrRegex = new RegExp(`${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\\\`]+))`, "i");
+ const match = tagText.match(attrRegex);
+ if (!match) return "";
+ return match[1] ?? match[2] ?? match[3] ?? "";
+}
+
+function isLocalAssetRef(ref) {
+ if (!ref) return false;
+ return !/^(?:[a-z]+:)?\/\//i.test(ref) && !/^data:/i.test(ref);
+}
+
+function escapeScriptForInlineHtml(scriptText) {
+ return scriptText.replace(/<\/script/gi, "<\\/script");
+}
+
+function injectBeforeLastClosingTag(html, tagName, injectText) {
+ const closeTag = `${tagName.toLowerCase()}>`;
+ const lowerHtml = html.toLowerCase();
+ const idx = lowerHtml.lastIndexOf(closeTag);
+ if (idx < 0) return html;
+ return html.slice(0, idx) + injectText + html.slice(idx);
+}
+
+export function buildSingleHtmlFromSource(sourceHtml, srcHtmlPath) {
+ const srcDir = path.dirname(srcHtmlPath);
+
+ const cssBlocks = [];
+ const jsBlocks = [];
+
+ let output = sourceHtml;
+
+ output = output.replace(/ ]*>/gi, (tag) => {
+ const rel = getAttr(tag, "rel").toLowerCase();
+ if (rel !== "stylesheet") return tag;
+ const href = getAttr(tag, "href");
+ if (!isLocalAssetRef(href)) return tag;
+ const assetPath = path.resolve(srcDir, href);
+ const cssText = fs.readFileSync(assetPath, "utf8").trimEnd();
+ cssBlocks.push(cssText);
+ return "";
+ });
+
+ output = output.replace(/" inside JS strings (e.g. HTML template builders), which breaks
+ // the final single-file HTML parser. Escape those safely.
+ output = output.replace(/`;
+ });
+
+ if (cssBlocks.length > 0) {
+ output = injectBeforeLastClosingTag(output, "head", ` \n`);
+ }
+
+ if (jsBlocks.length > 0) {
+ const scriptTags = jsBlocks.map((text) => ` `).join("\n\n");
+ output = injectBeforeLastClosingTag(output, "body", `${scriptTags}\n`);
+ }
+
+ return output;
+}
diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs
new file mode 100644
index 0000000..06891de
--- /dev/null
+++ b/scripts/run-tests.mjs
@@ -0,0 +1,27 @@
+import { spawnSync } from "node:child_process";
+
+const testGroups = [
+ [
+ "tests/mikuproject-excel-io.test.js",
+ "tests/mikuproject-project-xlsx.test.js",
+ "tests/mikuproject-wbs-xlsx.test.js",
+ "lht-cmn/components.test.js"
+ ],
+ [
+ "tests/mikuproject-main.test.js"
+ ]
+];
+
+for (const files of testGroups) {
+ const result = spawnSync(
+ process.execPath,
+ ["./node_modules/vitest/vitest.mjs", "run", "--testTimeout=15000", "--hookTimeout=15000", ...files],
+ {
+ stdio: "inherit",
+ cwd: process.cwd()
+ }
+ );
+ if (result.status !== 0) {
+ process.exit(result.status ?? 1);
+ }
+}
diff --git a/src/css/app.css b/src/css/app.css
new file mode 100644
index 0000000..1eb219b
--- /dev/null
+++ b/src/css/app.css
@@ -0,0 +1,1074 @@
+:root {
+ color-scheme: light;
+ --md-project-accent: #6750a4;
+ --md-project-accent-soft: #f3edff;
+ --md-project-border: rgba(148, 163, 184, 0.2);
+ --md-project-surface: rgba(255, 255, 255, 0.94);
+ --md-project-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
+ --ms-miku-surface: #cbf3f0;
+ --ms-miku-border: #a8e3e0;
+ --ms-miku-text: #1c1b1f;
+ --md-sys-color-primary: #6750a4;
+ --md-sys-color-surface: #ffffff;
+ --md-sys-color-on-surface: #102a43;
+ --md-sys-color-on-surface-variant: #52606d;
+ --md-sys-color-outline: #bdb7c8;
+ --md-sys-color-state-layer: rgba(103, 80, 164, 0.08);
+ --md-sys-color-focus-ring: rgba(103, 80, 164, 0.22);
+}
+
+.md-page {
+ min-height: 100vh;
+ margin: 0;
+ padding: 24px;
+ background:
+ radial-gradient(circle at top left, rgba(9, 132, 227, 0.12), transparent 38%),
+ linear-gradient(180deg, #f7fbff 0%, #eef4f9 100%);
+ color: #1f2933;
+ box-sizing: border-box;
+}
+
+.md-shell {
+ max-width: 1180px;
+ margin: 0 auto;
+}
+
+.md-icons {
+ position: absolute;
+ width: 0;
+ height: 0;
+ overflow: hidden;
+}
+
+.md-surface-card.md-card {
+ background: var(--md-project-surface);
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ border-radius: 24px;
+ box-shadow: var(--md-project-shadow);
+ padding: 28px;
+}
+
+.ms-hero {
+ position: relative;
+ margin: -6px -6px 18px;
+ padding: 14px 16px 12px;
+ border-radius: 18px;
+ border: 1px solid var(--ms-miku-border);
+ background: var(--ms-miku-surface);
+ box-shadow: 0 8px 20px rgba(86, 58, 132, 0.12);
+}
+
+.ms-hero-title {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ margin: 0;
+ color: var(--ms-miku-text);
+ font-size: clamp(1.12rem, 4vw, 1.9rem);
+ font-weight: 800;
+}
+
+.ms-hero-subtitle {
+ margin: 0.75rem 0 0;
+ max-width: 72ch;
+ color: #243b53;
+ font-size: 0.95rem;
+ line-height: 1.6;
+}
+
+.ms-hero-icon {
+ width: 1.95rem;
+ height: 1.95rem;
+ border-radius: 999px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(103, 80, 164, 0.08);
+ color: #5b33a8;
+ border: 1px solid rgba(103, 80, 164, 0.38);
+ flex: 0 0 auto;
+}
+
+.ms-hero lht-help-tooltip:not([data-initialized="true"]) {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ inline-size: 1.35rem;
+ block-size: 1.35rem;
+ overflow: hidden;
+ flex: 0 0 1.35rem;
+ color: transparent;
+ white-space: nowrap;
+}
+
+.ms-hero-link {
+ margin-left: auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.3rem;
+ color: var(--ms-miku-text);
+ text-decoration: none;
+ border: none;
+ background: transparent;
+ border-radius: 0;
+ padding: 0;
+ font-size: 0.8rem;
+ font-weight: 700;
+ line-height: 1;
+ transition:
+ color 0.16s ease,
+ text-decoration-color 0.16s ease;
+}
+
+.ms-hero-link:hover,
+.ms-hero-link:focus-visible {
+ color: #2f1f52;
+ text-decoration: underline;
+}
+
+.ms-hero-link--text {
+ margin-left: 0;
+ padding-inline: 0.2rem;
+}
+
+.ms-hero-link .ms-btn-icon + span {
+ display: none;
+}
+
+.ms-btn-icon {
+ width: 1.05rem;
+ height: 1.05rem;
+ flex: 0 0 auto;
+}
+
+.md-icon-btn {
+ background: #f0edf5;
+ color: #3f3b46;
+}
+
+.md-icon-btn:hover {
+ background: #ebe6f3;
+}
+
+.md-menu-panel {
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ background: #ffffff;
+}
+
+.md-menu-link:hover {
+ background: #f2edf8;
+}
+
+.md-section {
+ margin-top: 24px;
+}
+
+.md-stack-md {
+ display: grid;
+ gap: 16px;
+}
+
+.md-flow-section {
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ border-radius: 18px;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(245, 248, 252, 0.94) 100%);
+ padding: 20px 22px;
+ display: grid;
+ gap: 18px;
+}
+
+.md-flow-section__header {
+ display: grid;
+ gap: 8px;
+}
+
+.md-flow-section__title {
+ margin: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.55rem;
+ flex-wrap: wrap;
+ color: #102a43;
+ font-size: 1.04rem;
+ font-weight: 800;
+}
+
+.md-flow-section__step {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.75rem;
+ height: 1.75rem;
+ border-radius: 999px;
+ background: rgba(103, 80, 164, 0.12);
+ color: var(--md-project-accent);
+ font-size: 0.82rem;
+ font-weight: 800;
+}
+
+.md-flow-section__text {
+ margin: 0;
+ color: #52606d;
+ font-size: 0.88rem;
+ line-height: 1.55;
+}
+
+.md-tab-panel[hidden] {
+ display: none !important;
+}
+
+.md-top-tabs,
+.ms-top-tabs {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.6rem;
+ align-items: center;
+ margin-bottom: 0.2rem;
+ padding: 0.35rem 0;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.md-top-tab,
+.ms-top-tab {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 0.5rem;
+ appearance: none;
+ min-height: 3.25rem;
+ border: 1px solid #ddd2f0;
+ background: #f7f3fc;
+ color: #4a4458;
+ border-radius: 14px;
+ padding: 0.45rem 0.85rem;
+ font: inherit;
+ font-size: 0.95rem;
+ font-weight: 700;
+ white-space: nowrap;
+ cursor: pointer;
+ transition: transform 150ms ease, box-shadow 150ms ease, background 150ms ease, border-color 150ms ease;
+}
+
+.md-top-tab::after,
+.ms-top-tab::after {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: calc(100% + 0.2rem);
+ width: 0.45rem;
+ height: 2px;
+ background: #d8cceb;
+ transform: translateY(-50%);
+}
+
+.md-top-tab:last-child::after,
+.ms-top-tab:last-child::after {
+ display: none;
+}
+
+.md-top-tab:hover,
+.ms-top-tab:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 6px 16px rgba(103, 80, 164, 0.12);
+}
+
+.md-top-tab.is-active,
+.ms-top-tab.is-active {
+ border-color: rgba(98, 0, 238, 0.5);
+ color: #4d2aa5;
+ background: rgba(98, 0, 238, 0.14);
+}
+
+.md-top-tab-no,
+.ms-top-tab-no {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.4rem;
+ height: 1.4rem;
+ border-radius: 999px;
+ background: #ece5f8;
+ color: #5a4b79;
+ font-size: 0.76rem;
+ font-weight: 800;
+}
+
+.md-top-tab.is-active .md-top-tab-no,
+.ms-top-tab.is-active .ms-top-tab-no {
+ background: rgba(98, 0, 238, 0.22);
+ color: #4d2aa5;
+}
+
+.md-top-tab-label,
+.ms-top-tab-label {
+ display: inline-block;
+ letter-spacing: 0.01em;
+}
+
+.md-panel-actions {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+.md-panel-actions--ai-draft {
+ margin-bottom: 20px;
+}
+
+.md-button--compact-link {
+ gap: 0.38rem;
+ min-height: 2.2rem;
+ padding: 0.28rem 0.88rem;
+ font-size: 0.82rem;
+ font-weight: 600;
+}
+
+.md-button__icon {
+ width: 0.82rem;
+ height: 0.82rem;
+ flex: 0 0 auto;
+}
+
+.md-output-actions {
+ display: grid;
+ gap: 12px;
+}
+
+.md-button-with-help {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.md-button-help-anchor {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+}
+
+.md-button-help-anchor lht-help-tooltip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.md-button-help-anchor .md-tooltip-group > .md-tooltip {
+ display: none;
+}
+
+.md-panel-actions + .md-form-grid {
+ margin-top: 24px;
+}
+
+.md-panel-actions + .md-note-card {
+ margin-top: 12px;
+}
+
+.md-form-grid + .md-panel-actions {
+ margin-top: 12px;
+}
+
+.md-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.44rem;
+ min-height: 2.55rem;
+ border-radius: 999px;
+ padding: 0.38rem 1.02rem;
+ border: 1px solid var(--md-project-border);
+ background: linear-gradient(180deg, #ffffff 0%, #f3f7fb 100%);
+ color: #102a43;
+ font: inherit;
+ font-weight: 600;
+ font-size: 0.9rem;
+ line-height: 1.2;
+ cursor: pointer;
+ text-decoration: none;
+ transition: background 150ms ease, box-shadow 150ms ease, transform 150ms ease;
+}
+
+.md-button:link,
+.md-button:visited,
+.md-button:hover,
+.md-button:focus-visible,
+.md-button:active {
+ color: #102a43;
+ text-decoration: none;
+}
+
+.md-button:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(103, 80, 164, 0.12);
+}
+
+.md-button--primary {
+ background: linear-gradient(135deg, #6750a4, #7b61c7);
+ border-color: rgba(103, 80, 164, 0.45);
+ color: #fff;
+ box-shadow: 0 8px 20px rgba(103, 80, 164, 0.22);
+}
+
+.md-button--tonal {
+ background: linear-gradient(180deg, #f2edf8 0%, #ebe6f3 100%);
+ border-color: rgba(124, 98, 171, 0.26);
+ color: #3f315f;
+}
+
+.md-button--surface {
+ background: #d9e2ec;
+}
+
+.md-status {
+ border: 1px solid var(--md-project-border);
+ border-radius: 12px;
+ background: rgba(230, 240, 255, 0.72);
+ padding: 11px 14px;
+ color: #102a43;
+ font-size: 0.94rem;
+ font-weight: 500;
+ line-height: 1.45;
+}
+
+.md-save-state {
+ display: inline-flex;
+ align-items: center;
+ width: fit-content;
+ border-radius: 999px;
+ padding: 7px 12px;
+ font-size: 0.8rem;
+ font-weight: 700;
+ border: 1px solid transparent;
+}
+
+.md-save-state--dirty {
+ color: #8a5a1c;
+ background: #fff8ef;
+ border-color: #efcfaa;
+}
+
+.md-save-state--clean {
+ color: #225b4d;
+ background: #eefaf5;
+ border-color: #c7e6d9;
+}
+
+.md-feedback-stack {
+ display: grid;
+ gap: 10px;
+ padding: 14px 16px;
+ border-radius: 18px;
+ background: linear-gradient(180deg, rgba(247, 250, 253, 0.96) 0%, rgba(255, 255, 255, 0.94) 100%);
+ border: 1px solid rgba(213, 222, 231, 0.82);
+}
+
+.md-feedback-stack.md-hidden {
+ display: none;
+}
+
+.md-feedback-stack__title {
+ font-size: 0.88rem;
+ font-weight: 700;
+ color: #28485a;
+}
+
+.md-feedback-stack__text {
+ color: #5d7482;
+ font-size: 0.82rem;
+ line-height: 1.5;
+}
+
+.md-feedback-stack__label {
+ margin-top: 2px;
+ font-size: 0.76rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #607787;
+}
+
+.md-issues {
+ border: 1px solid #efc9a6;
+ border-radius: 16px;
+ background: linear-gradient(180deg, #fffaf3 0%, #fff 100%);
+ padding: 14px 16px;
+ color: #7a4d13;
+}
+
+.md-issues.md-hidden {
+ display: none;
+}
+
+.md-issues__title {
+ font-weight: 700;
+ margin-bottom: 8px;
+}
+
+.md-issues__section + .md-issues__section {
+ margin-top: 10px;
+}
+
+.md-issues__section-title {
+ font-size: 0.85rem;
+ font-weight: 700;
+ color: #8a5a1c;
+ margin-bottom: 4px;
+}
+
+.md-issues__list {
+ margin: 0;
+ padding-left: 1.2rem;
+}
+
+.md-issues__item + .md-issues__item {
+ margin-top: 4px;
+}
+
+.md-note-card {
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.72);
+ padding: 16px 18px;
+}
+
+.md-note-card--accent {
+ border-color: rgba(157, 134, 205, 0.28);
+ background: linear-gradient(180deg, rgba(249, 245, 255, 0.96) 0%, rgba(244, 239, 252, 0.9) 100%);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
+}
+
+.md-note-card--subtle {
+ border-color: rgba(167, 185, 210, 0.24);
+ background: linear-gradient(180deg, rgba(250, 252, 255, 0.98) 0%, rgba(244, 247, 251, 0.96) 100%);
+}
+
+.md-note-card--feature {
+ border-color: rgba(168, 227, 224, 0.68);
+ background: linear-gradient(180deg, rgba(243, 254, 252, 0.98) 0%, rgba(233, 249, 246, 0.94) 100%);
+ box-shadow: 0 8px 18px rgba(74, 122, 118, 0.08);
+}
+
+.md-note-card__title-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.md-note-card__title {
+ margin: 0 0 8px;
+ font-size: 0.98rem;
+ font-weight: 700;
+ color: #102a43;
+}
+
+.md-note-card__title-row .md-note-card__title {
+ margin-bottom: 0;
+}
+
+.md-note-card__text {
+ margin: 0;
+ color: #52606d;
+ font-size: 0.88rem;
+ line-height: 1.6;
+}
+
+.md-inline-link {
+ color: #4f378b;
+ font-weight: 700;
+ text-decoration: none;
+}
+
+.md-inline-link:hover,
+.md-inline-link:focus-visible {
+ text-decoration: underline;
+}
+
+.md-inline-link--compact {
+ margin-left: auto;
+ font-size: 0.86rem;
+ font-weight: 700;
+}
+
+.md-pill-link {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.42rem 0.8rem;
+ border-radius: 999px;
+ border: 1px solid rgba(103, 80, 164, 0.22);
+ background: linear-gradient(180deg, #f6f1fc 0%, #eee7f8 100%);
+ color: #4f378b;
+ font-size: 0.82rem;
+ font-weight: 700;
+ line-height: 1.2;
+ text-decoration: none;
+ transition: background 0.16s ease, border-color 0.16s ease, color 0.16s ease;
+}
+
+.md-pill-link:hover,
+.md-pill-link:focus-visible {
+ background: linear-gradient(180deg, #efe7fa 0%, #e6dcf5 100%);
+ border-color: rgba(103, 80, 164, 0.32);
+ color: #3f315f;
+ text-decoration: none;
+}
+
+.md-pill-link--compact {
+ margin-left: auto;
+}
+
+md-outlined-text-field.md-outlined-field {
+ --md-outlined-text-field-outline-color: #bdb7c8;
+ --md-outlined-field-outline-color: #bdb7c8;
+ --md-outlined-text-field-hover-outline-color: #b1aac0;
+ --md-outlined-field-hover-outline-color: #b1aac0;
+ --md-outlined-text-field-focus-outline-color: #6c3eea;
+ --md-outlined-field-focus-outline-color: #6c3eea;
+ --md-outlined-text-field-focus-state-layer-color: #6c3eea;
+ --md-outlined-field-focus-state-layer-color: #6c3eea;
+ --md-outlined-text-field-caret-color: #6c3eea;
+ --md-outlined-field-caret-color: #6c3eea;
+}
+
+md-outlined-text-field.md-outlined-field:focus-within::part(outline) {
+ filter: drop-shadow(0 0 4px rgba(108, 62, 234, 0.26)) drop-shadow(0 0 12px rgba(108, 62, 234, 0.22));
+}
+
+md-outlined-text-field.md-outlined-field:focus-within::part(container) {
+ box-shadow: 0 0 0 2px rgba(108, 62, 234, 0.12), 0 0 14px rgba(108, 62, 234, 0.18);
+}
+
+md-outlined-select.md-outlined-field {
+ --md-outlined-select-text-field-outline-color: #bdb7c8;
+ --md-outlined-select-text-field-hover-outline-color: #b1aac0;
+ --md-outlined-select-text-field-focus-outline-color: #6c3eea;
+ --md-outlined-select-text-field-focus-state-layer-color: #6c3eea;
+ --md-outlined-select-text-field-caret-color: #6c3eea;
+}
+
+md-outlined-select.md-outlined-field:focus-within::part(outline) {
+ filter: drop-shadow(0 0 4px rgba(108, 62, 234, 0.26)) drop-shadow(0 0 12px rgba(108, 62, 234, 0.22));
+}
+
+md-outlined-select.md-outlined-field:focus-within::part(container) {
+ box-shadow: 0 0 0 2px rgba(108, 62, 234, 0.12), 0 0 14px rgba(108, 62, 234, 0.18);
+}
+
+.md-help-icon-button,
+.md-help-icon-button--fallback {
+ --md-focus-ring-color: rgba(103, 80, 164, 0.22);
+}
+
+.md-help-icon-button--fallback {
+ background: #f0edf5;
+ color: #3f3b46;
+}
+
+.md-note-card__text + .md-note-card__text,
+.md-note-list + .md-note-card__text {
+ margin-top: 8px;
+}
+
+.md-note-list {
+ margin: 8px 0 0;
+ padding-left: 1.2rem;
+ color: #345450;
+ font-size: 0.86rem;
+}
+
+.md-note-list li + li {
+ margin-top: 4px;
+}
+
+.md-tooltip-content .md-note-card__text,
+.md-tooltip-content .md-note-list {
+ color: #f3f7f6;
+}
+
+.md-tooltip-content .md-note-card__text strong {
+ color: #ffffff;
+}
+
+.md-tooltip-content .md-note-list {
+ padding-left: 1.1rem;
+}
+
+.md-tooltip-content .md-note-list li::marker {
+ color: #9fe0d5;
+}
+
+.md-debug-accordion {
+ margin-top: 16px;
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.72);
+ overflow: clip;
+}
+
+.md-debug-accordion__summary {
+ list-style: none;
+ cursor: pointer;
+ padding: 14px 18px;
+ font-size: 0.92rem;
+ font-weight: 700;
+ color: #102a43;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.md-debug-accordion__summary::-webkit-details-marker {
+ display: none;
+}
+
+.md-debug-accordion__summary::before {
+ content: "▸";
+ font-size: 0.9rem;
+ color: #52606d;
+ transition: transform 160ms ease;
+}
+
+.md-debug-accordion[open] .md-debug-accordion__summary::before {
+ transform: rotate(90deg);
+}
+
+.md-debug-accordion__body {
+ padding: 0 18px 18px;
+ border-top: 1px solid rgba(148, 163, 184, 0.18);
+}
+
+.md-debug-accordion__body .md-form-grid + .md-form-grid {
+ margin-top: 16px;
+}
+
+.md-note-accordion {
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.72);
+ overflow: clip;
+}
+
+.md-note-accordion__summary {
+ list-style: none;
+ cursor: pointer;
+ user-select: none;
+ padding: 14px 18px;
+ font-size: 0.94rem;
+ font-weight: 700;
+ color: #102a43;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.md-note-accordion__summary::-webkit-details-marker {
+ display: none;
+}
+
+.md-note-accordion__summary::before {
+ content: "▸";
+ font-size: 0.9rem;
+ color: #52606d;
+ transition: transform 160ms ease;
+}
+
+.md-note-accordion[open] .md-note-accordion__summary::before {
+ transform: rotate(90deg);
+}
+
+.md-note-accordion__body {
+ padding: 0 18px 18px;
+}
+
+.md-note-accordion__body > .md-note-card {
+ margin-top: 0;
+}
+
+.ms-settings-card {
+ margin-top: 16px;
+}
+
+.ms-settings-accordion {
+ border: 1px solid #d6d1e0;
+ border-radius: 16px;
+ background: #f6f2fb;
+ overflow: hidden;
+}
+
+.ms-settings-summary {
+ list-style: none;
+ cursor: pointer;
+ user-select: none;
+ font-size: 0.92rem;
+ font-weight: 700;
+ color: #4f495a;
+ padding: 14px 18px;
+ background: #eee8f6;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.ms-settings-summary::-webkit-details-marker {
+ display: none;
+}
+
+.ms-settings-summary::after {
+ content: "";
+ width: 0.52rem;
+ height: 0.52rem;
+ border-right: 1.5px solid #766f86;
+ border-bottom: 1.5px solid #766f86;
+ transform: rotate(45deg);
+ transition: transform 150ms ease;
+}
+
+.ms-settings-accordion[open] > .ms-settings-summary::after {
+ transform: rotate(225deg);
+}
+
+.ms-settings-accordion[open] > .ms-settings-summary {
+ border-bottom: 1px solid #d9d3e5;
+}
+
+.ms-settings-body {
+ padding: 16px 18px 18px;
+}
+
+.ms-settings-block {
+ display: grid;
+ gap: 4px;
+}
+
+.ms-settings-subtitle {
+ margin: 0 0 4px;
+ font-size: 1rem;
+ font-weight: 700;
+ color: #4e495d;
+}
+
+.md-xlsx-summary {
+ border: 1px solid #cde3f5;
+ border-radius: 16px;
+ background: linear-gradient(180deg, #f5faff 0%, #ffffff 100%);
+ padding: 14px 16px;
+ color: #325067;
+}
+
+.md-xlsx-summary.md-hidden {
+ display: none;
+}
+
+.md-xlsx-summary__title {
+ font-weight: 700;
+ margin-bottom: 8px;
+}
+
+.md-xlsx-summary__counts {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-bottom: 10px;
+}
+
+.md-xlsx-summary__count {
+ display: inline-flex;
+ align-items: center;
+ min-height: 1.8rem;
+ padding: 0 10px;
+ border-radius: 999px;
+ background: #e9f3fc;
+ color: #2c5570;
+ font-size: 0.82rem;
+ font-weight: 700;
+}
+
+.md-xlsx-summary__unchanged {
+ margin-bottom: 10px;
+ color: #5b7382;
+ font-size: 0.84rem;
+}
+
+.md-xlsx-summary__section + .md-xlsx-summary__section {
+ margin-top: 12px;
+}
+
+.md-xlsx-summary__section-title {
+ margin-bottom: 6px;
+ font-weight: 700;
+ color: #2b5169;
+}
+
+.md-xlsx-summary__list {
+ margin: 0;
+ padding-left: 1.2rem;
+}
+
+.md-xlsx-summary__item + .md-xlsx-summary__item {
+ margin-top: 4px;
+}
+
+.md-xlsx-summary__item-title {
+ font-weight: 700;
+ color: #244559;
+}
+
+.md-xlsx-summary__item-body {
+ margin-top: 3px;
+ color: #466172;
+}
+
+.md-xlsx-summary__hint {
+ margin-top: 12px;
+ color: #516b7b;
+ font-size: 0.84rem;
+}
+
+.md-summary-grid {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.md-summary-card {
+ border: 1px solid var(--md-project-border);
+ border-radius: 18px;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(245, 248, 252, 0.94) 100%);
+ padding: 16px;
+}
+
+.md-summary-label {
+ color: #52606d;
+ font-size: 0.84rem;
+ font-weight: 700;
+}
+
+.md-summary-value {
+ margin-top: 6px;
+ font-size: 1.1rem;
+ font-weight: 700;
+ color: #102a43;
+}
+
+.md-preview-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.md-preview-card {
+ border: 1px solid var(--md-project-border);
+ border-radius: 18px;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(245, 248, 252, 0.94) 100%);
+ padding: 16px;
+}
+
+.md-preview-card__title {
+ margin: 0 0 10px;
+ font-size: 0.98rem;
+ font-weight: 700;
+ color: #102a43;
+}
+
+.md-preview-list {
+ display: grid;
+ gap: 8px;
+}
+
+.md-preview-item {
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ border-radius: 14px;
+ background: #fff;
+ padding: 10px 12px;
+}
+
+.md-preview-item__title {
+ font-size: 0.9rem;
+ font-weight: 700;
+ color: #102a43;
+}
+
+.md-preview-item__meta {
+ margin-top: 4px;
+ font-size: 0.8rem;
+ color: #5b6f78;
+ white-space: pre-wrap;
+}
+
+.md-preview-empty {
+ color: #6d7b84;
+ font-size: 0.86rem;
+}
+
+.md-svg-preview-stage {
+ min-height: 16rem;
+ overflow: auto;
+ border-radius: 14px;
+ background: rgba(255, 255, 255, 0.8);
+ padding: 12px;
+}
+
+.md-svg-preview-stage svg {
+ display: block;
+ max-width: 100%;
+ height: auto;
+}
+
+@media (max-width: 900px) {
+ .md-top-tabs,
+ .ms-top-tabs {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .md-summary-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .md-preview-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+@media (max-width: 640px) {
+ .md-page {
+ padding: 16px;
+ }
+
+ .ms-hero {
+ margin: -2px -2px 14px;
+ padding: 12px 12px 10px;
+ }
+
+ .md-top-tab,
+ .ms-top-tab {
+ min-width: 0;
+ padding: 0.42rem 0.45rem;
+ justify-content: center;
+ width: 100%;
+ }
+
+ .md-top-tab::after,
+ .ms-top-tab::after {
+ display: none;
+ }
+
+ .md-top-tab-label,
+ .ms-top-tab-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .ms-hero-title {
+ flex-wrap: wrap;
+ }
+
+ .md-summary-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
diff --git a/src/js/excel-io.js b/src/js/excel-io.js
new file mode 100644
index 0000000..214d180
--- /dev/null
+++ b/src/js/excel-io.js
@@ -0,0 +1,1155 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ const TEXT_ENCODER = new TextEncoder();
+ const TEXT_DECODER = new TextDecoder();
+ const INVALID_SHEET_NAME_PATTERN = /[:\\/?*\[\]]/;
+ const CRC32_TABLE = buildCrc32Table();
+ const NUMBER_FORMATS = ["general", "integer", "decimal", "date", "datetime", "percent", "text"];
+ const HORIZONTAL_ALIGNS = ["left", "center", "right"];
+ const VERTICAL_ALIGNS = ["top", "center", "bottom"];
+ const BORDER_STYLES = ["thin"];
+ const STYLE_KEY_DELIMITER = "::";
+ const DEFAULT_STYLE = { numberFormat: "general" };
+ const DEFAULT_FILL_NONE = { patternType: "none", fillColor: undefined };
+ const DEFAULT_FILL_GRAY125 = { patternType: "gray125", fillColor: undefined };
+ class XlsxWorkbookCodec {
+ exportWorkbook(workbook) {
+ const normalizedWorkbook = normalizeWorkbook(workbook);
+ const entries = this.createWorkbookEntries(normalizedWorkbook);
+ return packZip(entries);
+ }
+ importWorkbook(bytes) {
+ const entries = this.unpackEntries(bytes);
+ return parseWorkbookEntries(entries);
+ }
+ async importWorkbookAsync(bytes) {
+ const entries = await this.unpackEntriesAsync(bytes);
+ return parseWorkbookEntries(entries);
+ }
+ listEntries(bytes) {
+ return Object.keys(this.unpackEntries(bytes)).sort();
+ }
+ async listEntriesAsync(bytes) {
+ const entries = await this.unpackEntriesAsync(bytes);
+ return Object.keys(entries).sort();
+ }
+ unpackEntries(bytes) {
+ return unpackZip(bytes);
+ }
+ async unpackEntriesAsync(bytes) {
+ return unpackZipAsync(bytes);
+ }
+ createWorkbookEntries(workbook) {
+ const styleBook = createStyleBook(workbook);
+ const worksheetRelationships = workbook.sheets.map((sheet, index) => ({
+ relationshipId: `rId${index + 1}`,
+ target: `worksheets/sheet${index + 1}.xml`,
+ name: sheet.name
+ }));
+ const worksheetEntries = workbook.sheets.map((sheet, index) => ({
+ name: `xl/worksheets/sheet${index + 1}.xml`,
+ data: encodeUtf8(buildWorksheetXml(sheet, styleBook))
+ }));
+ const entries = [
+ {
+ name: "[Content_Types].xml",
+ data: encodeUtf8(buildContentTypesXml(workbook.sheets.length, styleBook.styles.length > 1))
+ },
+ {
+ name: "_rels/.rels",
+ data: encodeUtf8(buildRootRelationshipsXml())
+ },
+ {
+ name: "xl/_rels/workbook.xml.rels",
+ data: encodeUtf8(buildWorkbookRelationshipsXml(worksheetRelationships, styleBook.styles.length > 1))
+ },
+ {
+ name: "xl/workbook.xml",
+ data: encodeUtf8(buildWorkbookXml(worksheetRelationships))
+ }
+ ];
+ if (styleBook.styles.length > 1) {
+ entries.push({
+ name: "xl/styles.xml",
+ data: encodeUtf8(buildStylesXml(styleBook.styles))
+ });
+ }
+ entries.push(...worksheetEntries);
+ return entries;
+ }
+ }
+ function normalizeWorkbook(workbook) {
+ if (!workbook || !Array.isArray(workbook.sheets) || workbook.sheets.length === 0) {
+ throw new Error("Workbook must contain at least one sheet");
+ }
+ const seenNames = new Set();
+ return {
+ sheets: workbook.sheets.map((sheet) => {
+ if (!sheet || typeof sheet.name !== "string") {
+ throw new Error("Each sheet must have a valid sheet name");
+ }
+ validateSheetName(sheet.name);
+ const canonicalName = sheet.name.toLocaleLowerCase();
+ if (seenNames.has(canonicalName)) {
+ throw new Error(`Duplicate sheet name is not allowed: ${sheet.name}`);
+ }
+ seenNames.add(canonicalName);
+ return {
+ name: sheet.name,
+ columns: Array.isArray(sheet.columns) ? sheet.columns.map((column) => normalizeColumn(column)) : undefined,
+ freezePane: normalizeFreezePane(sheet.freezePane),
+ mergedRanges: Array.isArray(sheet.mergedRanges) ? sheet.mergedRanges.map((range) => normalizeMergedRange(range)) : undefined,
+ rows: Array.isArray(sheet.rows)
+ ? sheet.rows.map((row) => ({
+ height: normalizeOptionalPositiveNumber(row === null || row === void 0 ? void 0 : row.height, "Row height"),
+ cells: Array.isArray(row === null || row === void 0 ? void 0 : row.cells)
+ ? row.cells.map((cell) => normalizeCell(cell))
+ : []
+ }))
+ : []
+ };
+ })
+ };
+ }
+ function normalizeColumn(column) {
+ if (!column) {
+ return {};
+ }
+ if (column.hidden !== undefined && typeof column.hidden !== "boolean") {
+ throw new Error("Column hidden must be boolean");
+ }
+ return {
+ width: normalizeOptionalPositiveNumber(column.width, "Column width"),
+ hidden: column.hidden === true ? true : undefined
+ };
+ }
+ function normalizeFreezePane(freezePane) {
+ if (!freezePane) {
+ return undefined;
+ }
+ const rowSplit = normalizeOptionalPositiveInteger(freezePane.rowSplit, "Freeze pane rowSplit");
+ const colSplit = normalizeOptionalPositiveInteger(freezePane.colSplit, "Freeze pane colSplit");
+ if (rowSplit === undefined && colSplit === undefined) {
+ return undefined;
+ }
+ return {
+ rowSplit,
+ colSplit
+ };
+ }
+ function normalizeCell(cell) {
+ if (!cell) {
+ return {};
+ }
+ if (cell.value !== undefined && typeof cell.value !== "string" && typeof cell.value !== "number" && typeof cell.value !== "boolean") {
+ throw new Error("Cell value must be string, number, or boolean");
+ }
+ if (cell.formula !== undefined && typeof cell.formula !== "string") {
+ throw new Error("Cell formula must be a string");
+ }
+ if (cell.numberFormat !== undefined && !NUMBER_FORMATS.includes(cell.numberFormat)) {
+ throw new Error(`Unsupported cell number format: ${cell.numberFormat}`);
+ }
+ if (cell.horizontalAlign !== undefined && !HORIZONTAL_ALIGNS.includes(cell.horizontalAlign)) {
+ throw new Error(`Unsupported cell horizontal align: ${cell.horizontalAlign}`);
+ }
+ if (cell.verticalAlign !== undefined && !VERTICAL_ALIGNS.includes(cell.verticalAlign)) {
+ throw new Error(`Unsupported cell vertical align: ${cell.verticalAlign}`);
+ }
+ if (cell.border !== undefined && !BORDER_STYLES.includes(cell.border)) {
+ throw new Error(`Unsupported cell border: ${cell.border}`);
+ }
+ if (cell.fontSize !== undefined) {
+ normalizeOptionalPositiveNumber(cell.fontSize, "Cell fontSize");
+ }
+ if (cell.fillColor !== undefined) {
+ assertColor(cell.fillColor);
+ }
+ return {
+ value: cell.value,
+ formula: cell.formula,
+ numberFormat: cell.numberFormat,
+ horizontalAlign: cell.horizontalAlign,
+ verticalAlign: cell.verticalAlign,
+ wrapText: cell.wrapText === true ? true : undefined,
+ bold: cell.bold === true ? true : undefined,
+ fontSize: cell.fontSize,
+ fillColor: cell.fillColor ? normalizeColor(cell.fillColor) : undefined,
+ border: cell.border
+ };
+ }
+ function normalizeMergedRange(range) {
+ if (typeof range !== "string") {
+ throw new Error("Merged range must be a string");
+ }
+ const trimmed = range.trim().toUpperCase();
+ if (!/^[A-Z]+\d+:[A-Z]+\d+$/.test(trimmed)) {
+ throw new Error(`Invalid merged range: ${range}`);
+ }
+ return trimmed;
+ }
+ function normalizeOptionalPositiveNumber(value, label) {
+ if (value === undefined) {
+ return undefined;
+ }
+ if (!Number.isFinite(value) || value <= 0) {
+ throw new Error(`${label} must be a finite positive number`);
+ }
+ return value;
+ }
+ function normalizeOptionalPositiveInteger(value, label) {
+ if (value === undefined) {
+ return undefined;
+ }
+ if (!Number.isInteger(value) || value <= 0) {
+ throw new Error(`${label} must be a positive integer`);
+ }
+ return value;
+ }
+ function validateSheetName(name) {
+ if (!name || !name.trim()) {
+ throw new Error("Sheet name must not be empty");
+ }
+ if (name.length > 31) {
+ throw new Error(`Sheet name is too long: ${name}`);
+ }
+ if (INVALID_SHEET_NAME_PATTERN.test(name)) {
+ throw new Error(`Sheet name contains invalid characters: ${name}`);
+ }
+ if (name.startsWith("'") || name.endsWith("'")) {
+ throw new Error(`Sheet name must not start or end with apostrophe: ${name}`);
+ }
+ }
+ function assertColor(color) {
+ if (!/^#?[0-9a-fA-F]{6}$/.test(color)) {
+ throw new Error(`Unsupported color format: ${color}`);
+ }
+ }
+ function normalizeColor(color) {
+ const hex = color.startsWith("#") ? color.slice(1) : color;
+ return `FF${hex.toUpperCase()}`;
+ }
+ function denormalizeColor(color) {
+ if (!color) {
+ return undefined;
+ }
+ const normalized = color.toUpperCase();
+ if (/^[0-9A-F]{8}$/.test(normalized)) {
+ return `#${normalized.slice(2)}`;
+ }
+ if (/^[0-9A-F]{6}$/.test(normalized)) {
+ return `#${normalized}`;
+ }
+ return undefined;
+ }
+ function buildContentTypesXml(sheetCount, includeStyles) {
+ const worksheetOverrides = Array.from({ length: sheetCount }, (_unused, index) => (` `)).join("");
+ const stylesOverride = includeStyles
+ ? ` `
+ : "";
+ return `
+
+
+
+
+ ${worksheetOverrides}
+ ${stylesOverride}
+ `;
+ }
+ function buildRootRelationshipsXml() {
+ return `
+
+
+ `;
+ }
+ function buildWorkbookRelationshipsXml(relationships, includeStyles) {
+ const worksheetNodes = relationships.map((relationship) => (` `)).join("");
+ const stylesNode = includeStyles
+ ? ` `
+ : "";
+ return `
+
+ ${worksheetNodes}
+ ${stylesNode}
+ `;
+ }
+ function buildWorkbookXml(relationships) {
+ const sheets = relationships.map((relationship, index) => (` `)).join("");
+ return `
+
+ ${sheets}
+ `;
+ }
+ function buildWorksheetXml(sheet, styleBook) {
+ const sheetViewsXml = buildSheetViewsXml(sheet.freezePane);
+ const colsXml = buildColumnsXml(sheet.columns);
+ const mergeCellsXml = buildMergeCellsXml(sheet.mergedRanges);
+ const rows = sheet.rows.map((row, rowIndex) => buildWorksheetRowXml(row, rowIndex, styleBook)).filter(Boolean).join("");
+ return `
+
+ ${sheetViewsXml}
+ ${colsXml}
+ ${rows}
+ ${mergeCellsXml}
+ `;
+ }
+ function buildSheetViewsXml(freezePane) {
+ if (!freezePane || (!freezePane.rowSplit && !freezePane.colSplit)) {
+ return "";
+ }
+ const xSplit = freezePane.colSplit ? ` xSplit="${freezePane.colSplit}"` : "";
+ const ySplit = freezePane.rowSplit ? ` ySplit="${freezePane.rowSplit}"` : "";
+ const topLeftCell = encodeCellReference(freezePane.rowSplit || 0, freezePane.colSplit || 0);
+ const topLeftCellAttribute = topLeftCell ? ` topLeftCell="${topLeftCell}"` : "";
+ const activePane = resolveActivePane(freezePane);
+ return ` `;
+ }
+ function buildColumnsXml(columns) {
+ if (!columns || columns.length === 0 || columns.every((column) => column.width === undefined && column.hidden !== true)) {
+ return "";
+ }
+ const cols = columns.map((column, index) => ((column.width !== undefined || column.hidden === true)
+ ? ` `
+ : "")).filter(Boolean).join("");
+ return cols ? `${cols} ` : "";
+ }
+ function buildMergeCellsXml(mergedRanges) {
+ if (!mergedRanges || mergedRanges.length === 0) {
+ return "";
+ }
+ const mergeCells = mergedRanges
+ .map((range) => ` `)
+ .join("");
+ return `${mergeCells} `;
+ }
+ function buildWorksheetRowXml(row, rowIndex, styleBook) {
+ const cells = row.cells
+ .map((cell, cellIndex) => buildWorksheetCellXml(cell, rowIndex, cellIndex, styleBook))
+ .filter(Boolean)
+ .join("");
+ if (!cells) {
+ return "";
+ }
+ const heightAttributes = row.height !== undefined
+ ? ` ht="${formatNumber(row.height)}" customHeight="1"`
+ : "";
+ return `${cells}
`;
+ }
+ function buildWorksheetCellXml(cell, rowIndex, cellIndex, styleBook) {
+ const reference = `${encodeColumnName(cellIndex)}${rowIndex + 1}`;
+ const styleIndex = resolveStyleIndex(cell, styleBook);
+ const styleAttribute = styleIndex > 0 ? ` s="${styleIndex}"` : "";
+ const resolvedNumberFormat = resolveCellNumberFormat(cell);
+ if (cell.formula !== undefined) {
+ const formulaXml = `${escapeXml(cell.formula)} `;
+ const valueXml = buildFormulaValueXml(cell.value);
+ const typeAttribute = getCellTypeAttribute(cell.value, true);
+ return `${formulaXml}${valueXml} `;
+ }
+ if (cell.value === undefined) {
+ return styleIndex > 0 ? ` ` : "";
+ }
+ if (resolvedNumberFormat === "text") {
+ return `${buildInlineStringTextXml(String(cell.value))} `;
+ }
+ if (typeof cell.value === "string") {
+ return `${buildInlineStringTextXml(cell.value)} `;
+ }
+ if (typeof cell.value === "number") {
+ return `${formatNumber(cell.value)} `;
+ }
+ return `${cell.value ? "1" : "0"} `;
+ }
+ function buildFormulaValueXml(value) {
+ if (value === undefined) {
+ return "";
+ }
+ if (typeof value === "string") {
+ return `${escapeXml(value)} `;
+ }
+ if (typeof value === "number") {
+ return `${formatNumber(value)} `;
+ }
+ return `${value ? "1" : "0"} `;
+ }
+ function getCellTypeAttribute(value, hasFormula) {
+ if (!hasFormula) {
+ return "";
+ }
+ if (typeof value === "string") {
+ return ` t="str"`;
+ }
+ if (typeof value === "boolean") {
+ return ` t="b"`;
+ }
+ return "";
+ }
+ function formatNumber(value) {
+ if (!Number.isFinite(value)) {
+ throw new Error(`Cell number must be finite: ${value}`);
+ }
+ return String(value);
+ }
+ function createStyleBook(workbook) {
+ const styles = [DEFAULT_STYLE];
+ const styleIndexByKey = new Map([[styleKey(DEFAULT_STYLE), 0]]);
+ for (const sheet of workbook.sheets) {
+ for (const row of sheet.rows) {
+ for (const cell of row.cells) {
+ const descriptor = getStyleDescriptor(cell);
+ if (!descriptor) {
+ continue;
+ }
+ const key = styleKey(descriptor);
+ if (!styleIndexByKey.has(key)) {
+ styleIndexByKey.set(key, styles.length);
+ styles.push(descriptor);
+ }
+ }
+ }
+ }
+ return { styles, styleIndexByKey };
+ }
+ function getStyleDescriptor(cell) {
+ const numberFormat = resolveCellNumberFormat(cell);
+ if (numberFormat === "general" && !cell.horizontalAlign && !cell.verticalAlign && !cell.wrapText && !cell.bold && !cell.fontSize && !cell.fillColor && !cell.border) {
+ return null;
+ }
+ return {
+ numberFormat,
+ horizontalAlign: cell.horizontalAlign,
+ verticalAlign: cell.verticalAlign,
+ wrapText: cell.wrapText === true ? true : undefined,
+ bold: cell.bold === true ? true : undefined,
+ fontSize: cell.fontSize,
+ fillColor: cell.fillColor,
+ border: cell.border
+ };
+ }
+ function styleKey(style) {
+ return [
+ style.numberFormat,
+ style.horizontalAlign || "",
+ style.verticalAlign || "",
+ style.wrapText ? "wrap" : "",
+ style.bold ? "bold" : "",
+ style.fontSize !== undefined ? String(style.fontSize) : "",
+ style.fillColor || "",
+ style.border || ""
+ ].join(STYLE_KEY_DELIMITER);
+ }
+ function resolveStyleIndex(cell, styleBook) {
+ const descriptor = getStyleDescriptor(cell);
+ if (!descriptor) {
+ return 0;
+ }
+ return styleBook.styleIndexByKey.get(styleKey(descriptor)) || 0;
+ }
+ function buildStylesXml(styles) {
+ const fonts = dedupeDescriptors(styles.map((style) => ({ bold: style.bold, fontSize: style.fontSize })), fontKey, { bold: undefined, fontSize: undefined });
+ const fills = dedupeFillDescriptors(styles.map((style) => ({ patternType: style.fillColor ? "solid" : "none", fillColor: style.fillColor })));
+ const borders = dedupeDescriptors(styles.map((style) => ({ border: style.border })), borderKey, { border: undefined });
+ const styleNodes = styles.map((style) => {
+ const numFmtId = mapNumberFormatId(style.numberFormat);
+ const fontId = fonts.indexByKey.get(fontKey({ bold: style.bold, fontSize: style.fontSize })) || 0;
+ const fillId = fills.indexByKey.get(fillKey({ patternType: style.fillColor ? "solid" : "none", fillColor: style.fillColor })) || 0;
+ const borderId = borders.indexByKey.get(borderKey({ border: style.border })) || 0;
+ const applyNumberFormat = numFmtId !== 0 ? ` applyNumberFormat="1"` : "";
+ const applyAlignment = style.horizontalAlign || style.verticalAlign || style.wrapText ? ` applyAlignment="1"` : "";
+ const applyFont = fontId !== 0 ? ` applyFont="1"` : "";
+ const applyFill = fillId !== 0 ? ` applyFill="1"` : "";
+ const applyBorder = borderId !== 0 ? ` applyBorder="1"` : "";
+ const alignmentAttributes = [
+ style.horizontalAlign ? ` horizontal="${style.horizontalAlign}"` : "",
+ style.verticalAlign ? ` vertical="${style.verticalAlign}"` : "",
+ style.wrapText ? ` wrapText="1"` : ""
+ ].join("");
+ const alignmentNode = alignmentAttributes
+ ? ` `
+ : "";
+ return `${alignmentNode} `;
+ }).join("");
+ return `
+
+
+
+ ${fonts.items.map(buildFontXml).join("")}
+
+
+ ${fills.items.map(buildFillXml).join("")}
+
+
+ ${borders.items.map(buildBorderXml).join("")}
+
+
+
+
+
+ ${styleNodes}
+
+
+
+
+ `;
+ }
+ function dedupeDescriptors(items, keyFn, defaultItem) {
+ const uniqueItems = [defaultItem];
+ const indexByKey = new Map([[keyFn(defaultItem), 0]]);
+ for (const item of items) {
+ const key = keyFn(item);
+ if (!indexByKey.has(key)) {
+ indexByKey.set(key, uniqueItems.length);
+ uniqueItems.push(item);
+ }
+ }
+ return { items: uniqueItems, indexByKey };
+ }
+ function fontKey(font) {
+ return [
+ font.bold ? "bold" : "",
+ font.fontSize !== undefined ? String(font.fontSize) : ""
+ ].join(STYLE_KEY_DELIMITER);
+ }
+ function fillKey(fill) {
+ return [
+ fill.patternType || "none",
+ fill.fillColor || ""
+ ].join(STYLE_KEY_DELIMITER);
+ }
+ function borderKey(border) {
+ return border.border || "";
+ }
+ function buildFontXml(font) {
+ const parts = [
+ font.bold ? " " : "",
+ font.fontSize !== undefined ? ` ` : ""
+ ].join("");
+ return parts ? `${parts} ` : ` `;
+ }
+ function buildFillXml(fill) {
+ if (fill.patternType === "gray125") {
+ return ` `;
+ }
+ if (!fill.fillColor || fill.patternType === "none") {
+ return ` `;
+ }
+ return ` `;
+ }
+ function dedupeFillDescriptors(items) {
+ const uniqueItems = [DEFAULT_FILL_NONE, DEFAULT_FILL_GRAY125];
+ const indexByKey = new Map([
+ [fillKey(DEFAULT_FILL_NONE), 0],
+ [fillKey(DEFAULT_FILL_GRAY125), 1]
+ ]);
+ for (const item of items) {
+ const normalizedItem = {
+ patternType: item.fillColor ? "solid" : (item.patternType || "none"),
+ fillColor: item.fillColor
+ };
+ const key = fillKey(normalizedItem);
+ if (!indexByKey.has(key)) {
+ indexByKey.set(key, uniqueItems.length);
+ uniqueItems.push(normalizedItem);
+ }
+ }
+ return { items: uniqueItems, indexByKey };
+ }
+ function buildBorderXml(border) {
+ if (!border.border) {
+ return ` `;
+ }
+ return ` `;
+ }
+ function mapNumberFormatId(numberFormat) {
+ switch (numberFormat) {
+ case "integer":
+ return 1;
+ case "decimal":
+ return 2;
+ case "text":
+ return 49;
+ case "date":
+ return 14;
+ case "datetime":
+ return 22;
+ case "percent":
+ return 10;
+ case "general":
+ default:
+ return 0;
+ }
+ }
+ function parseWorkbookEntries(entries) {
+ const workbookXml = decodeRequiredEntry(entries, "xl/workbook.xml");
+ const workbookRelsXml = decodeRequiredEntry(entries, "xl/_rels/workbook.xml.rels");
+ const stylesXml = entries["xl/styles.xml"] ? decodeUtf8(entries["xl/styles.xml"]) : null;
+ const sharedStringsXml = entries["xl/sharedStrings.xml"] ? decodeUtf8(entries["xl/sharedStrings.xml"]) : null;
+ const workbookDocument = parseXmlDocument(workbookXml);
+ const relationshipsDocument = parseXmlDocument(workbookRelsXml);
+ const styleBook = parseStylesXml(stylesXml);
+ const sharedStrings = parseSharedStringsXml(sharedStringsXml);
+ const relationshipMap = new Map();
+ const relationshipElements = Array.from(relationshipsDocument.getElementsByTagNameNS("http://schemas.openxmlformats.org/package/2006/relationships", "Relationship"));
+ for (const relationshipElement of relationshipElements) {
+ const id = relationshipElement.getAttribute("Id");
+ const target = relationshipElement.getAttribute("Target");
+ if (id && target) {
+ relationshipMap.set(id, normalizeWorkbookTarget(target));
+ }
+ }
+ const sheetElements = Array.from(workbookDocument.getElementsByTagNameNS("http://schemas.openxmlformats.org/spreadsheetml/2006/main", "sheet"));
+ return {
+ sheets: sheetElements.map((sheetElement) => {
+ const name = sheetElement.getAttribute("name") || "";
+ validateSheetName(name);
+ const relationshipId = sheetElement.getAttributeNS("http://schemas.openxmlformats.org/officeDocument/2006/relationships", "id") || sheetElement.getAttribute("r:id");
+ if (!relationshipId) {
+ throw new Error(`Worksheet relationship id is missing for sheet: ${name}`);
+ }
+ const target = relationshipMap.get(relationshipId);
+ if (!target) {
+ throw new Error(`Worksheet relationship target is missing for sheet: ${name}`);
+ }
+ const worksheetXml = decodeRequiredEntry(entries, target);
+ return parseWorksheetXml(name, worksheetXml, styleBook, sharedStrings);
+ })
+ };
+ }
+ function normalizeWorkbookTarget(target) {
+ return target.startsWith("xl/") ? target : `xl/${target.replace(/^\.\//, "")}`;
+ }
+ function parseWorksheetXml(name, xmlText, styleBook, sharedStrings) {
+ const document = parseXmlDocument(xmlText);
+ const rowElements = Array.from(document.getElementsByTagNameNS("http://schemas.openxmlformats.org/spreadsheetml/2006/main", "row"));
+ return {
+ name,
+ columns: parseWorksheetColumns(document),
+ freezePane: parseWorksheetFreezePane(document),
+ mergedRanges: parseWorksheetMergedRanges(document),
+ rows: rowElements.map((rowElement) => ({
+ height: parseOptionalNumber(rowElement.getAttribute("ht")),
+ cells: parseWorksheetRowCells(rowElement, styleBook, sharedStrings)
+ }))
+ };
+ }
+ function parseWorksheetColumns(document) {
+ const colElements = Array.from(document.getElementsByTagNameNS("http://schemas.openxmlformats.org/spreadsheetml/2006/main", "col"));
+ if (colElements.length === 0) {
+ return undefined;
+ }
+ const columns = [];
+ for (const colElement of colElements) {
+ const min = Number(colElement.getAttribute("min") || "0");
+ const max = Number(colElement.getAttribute("max") || "0");
+ const width = parseOptionalNumber(colElement.getAttribute("width"));
+ const hidden = colElement.getAttribute("hidden") === "1" ? true : undefined;
+ for (let index = min; index <= max; index += 1) {
+ columns[index - 1] = { width, hidden };
+ }
+ }
+ while (columns.length > 0 && !columns[columns.length - 1]) {
+ columns.pop();
+ }
+ return columns.length > 0 ? columns.map((column) => column || {}) : undefined;
+ }
+ function parseWorksheetMergedRanges(document) {
+ const mergeCellElements = Array.from(document.getElementsByTagNameNS("http://schemas.openxmlformats.org/spreadsheetml/2006/main", "mergeCell"));
+ if (mergeCellElements.length === 0) {
+ return undefined;
+ }
+ return mergeCellElements
+ .map((element) => normalizeMergedRange(element.getAttribute("ref") || ""))
+ .filter(Boolean);
+ }
+ function parseWorksheetFreezePane(document) {
+ const paneElement = document.getElementsByTagNameNS("http://schemas.openxmlformats.org/spreadsheetml/2006/main", "pane")[0];
+ if (!paneElement || paneElement.getAttribute("state") !== "frozen") {
+ return undefined;
+ }
+ const rowSplit = parseOptionalNumber(paneElement.getAttribute("ySplit"));
+ const colSplit = parseOptionalNumber(paneElement.getAttribute("xSplit"));
+ if (rowSplit === undefined && colSplit === undefined) {
+ return undefined;
+ }
+ return {
+ rowSplit,
+ colSplit
+ };
+ }
+ function parseWorksheetRowCells(rowElement, styleBook, sharedStrings) {
+ const cells = [];
+ const cellElements = Array.from(rowElement.getElementsByTagNameNS("http://schemas.openxmlformats.org/spreadsheetml/2006/main", "c")).filter((element) => element.parentElement === rowElement);
+ for (const cellElement of cellElements) {
+ const reference = cellElement.getAttribute("r") || "";
+ const columnIndex = decodeColumnReference(reference);
+ while (cells.length < columnIndex) {
+ cells.push({});
+ }
+ cells.push(parseWorksheetCell(cellElement, styleBook, sharedStrings));
+ }
+ return cells;
+ }
+ function parseWorksheetCell(cellElement, styleBook, sharedStrings) {
+ const type = cellElement.getAttribute("t") || "";
+ const styleIndex = Number(cellElement.getAttribute("s") || "0");
+ const formulaElement = findDirectChild(cellElement, "f");
+ const valueElement = findDirectChild(cellElement, "v");
+ const inlineStringElement = findDirectChild(cellElement, "is");
+ let value;
+ if (type === "inlineStr") {
+ const textElement = inlineStringElement ? findDirectChild(inlineStringElement, "t") : null;
+ value = textElement ? (textElement.textContent || "") : "";
+ }
+ else if (type === "s") {
+ const sharedStringIndex = Number((valueElement === null || valueElement === void 0 ? void 0 : valueElement.textContent) || "0");
+ value = Number.isFinite(sharedStringIndex) ? (sharedStrings[sharedStringIndex] || "") : "";
+ }
+ else if (type === "b") {
+ value = (valueElement === null || valueElement === void 0 ? void 0 : valueElement.textContent) === "1";
+ }
+ else if (type === "str") {
+ value = (valueElement === null || valueElement === void 0 ? void 0 : valueElement.textContent) || "";
+ }
+ else if (valueElement) {
+ const rawValue = valueElement.textContent || "";
+ value = rawValue === "" ? "" : Number(rawValue);
+ }
+ const style = styleBook[styleIndex] || DEFAULT_STYLE;
+ const cell = {};
+ if (style.numberFormat !== "general") {
+ cell.numberFormat = style.numberFormat;
+ }
+ if (style.horizontalAlign) {
+ cell.horizontalAlign = style.horizontalAlign;
+ }
+ if (style.verticalAlign) {
+ cell.verticalAlign = style.verticalAlign;
+ }
+ if (style.wrapText) {
+ cell.wrapText = true;
+ }
+ if (style.bold) {
+ cell.bold = true;
+ }
+ if (style.fontSize !== undefined) {
+ cell.fontSize = style.fontSize;
+ }
+ if (style.fillColor) {
+ cell.fillColor = denormalizeColor(style.fillColor);
+ }
+ if (style.border) {
+ cell.border = style.border;
+ }
+ if (formulaElement) {
+ cell.formula = formulaElement.textContent || "";
+ }
+ if (value !== undefined) {
+ cell.value = value;
+ }
+ return cell;
+ }
+ function parseSharedStringsXml(xmlText) {
+ if (!xmlText) {
+ return [];
+ }
+ const document = parseXmlDocument(xmlText);
+ const stringItems = Array.from(document.getElementsByTagNameNS("http://schemas.openxmlformats.org/spreadsheetml/2006/main", "si"));
+ return stringItems.map((item) => extractSharedStringText(item));
+ }
+ function extractSharedStringText(itemElement) {
+ const directText = findDirectChild(itemElement, "t");
+ if (directText) {
+ return directText.textContent || "";
+ }
+ const richTextRuns = Array.from(itemElement.getElementsByTagNameNS("http://schemas.openxmlformats.org/spreadsheetml/2006/main", "r"));
+ if (richTextRuns.length === 0) {
+ return "";
+ }
+ return richTextRuns
+ .map((run) => { var _a; return ((_a = findDirectChild(run, "t")) === null || _a === void 0 ? void 0 : _a.textContent) || ""; })
+ .join("");
+ }
+ function parseStylesXml(xmlText) {
+ if (!xmlText) {
+ return [DEFAULT_STYLE];
+ }
+ const document = parseXmlDocument(xmlText);
+ const fonts = parseFonts(document);
+ const fills = parseFills(document);
+ const borders = parseBorders(document);
+ const xfElements = Array.from(document.getElementsByTagNameNS("http://schemas.openxmlformats.org/spreadsheetml/2006/main", "xf")).filter((element) => { var _a; return ((_a = element.parentElement) === null || _a === void 0 ? void 0 : _a.localName) === "cellXfs"; });
+ if (xfElements.length === 0) {
+ return [DEFAULT_STYLE];
+ }
+ return xfElements.map((xfElement) => {
+ var _a, _b, _c, _d;
+ const numFmtId = Number(xfElement.getAttribute("numFmtId") || "0");
+ const fontId = Number(xfElement.getAttribute("fontId") || "0");
+ const fillId = Number(xfElement.getAttribute("fillId") || "0");
+ const borderId = Number(xfElement.getAttribute("borderId") || "0");
+ const alignmentElement = findDirectChild(xfElement, "alignment");
+ const horizontalAlign = alignmentElement === null || alignmentElement === void 0 ? void 0 : alignmentElement.getAttribute("horizontal");
+ const verticalAlign = alignmentElement === null || alignmentElement === void 0 ? void 0 : alignmentElement.getAttribute("vertical");
+ return {
+ numberFormat: parseNumberFormatId(numFmtId),
+ horizontalAlign: horizontalAlign || undefined,
+ verticalAlign: verticalAlign || undefined,
+ wrapText: (alignmentElement === null || alignmentElement === void 0 ? void 0 : alignmentElement.getAttribute("wrapText")) === "1" ? true : undefined,
+ bold: ((_a = fonts[fontId]) === null || _a === void 0 ? void 0 : _a.bold) ? true : undefined,
+ fontSize: (_b = fonts[fontId]) === null || _b === void 0 ? void 0 : _b.fontSize,
+ fillColor: (_c = fills[fillId]) === null || _c === void 0 ? void 0 : _c.fillColor,
+ border: (_d = borders[borderId]) === null || _d === void 0 ? void 0 : _d.border
+ };
+ });
+ }
+ function parseFonts(document) {
+ return Array.from(document.getElementsByTagNameNS("http://schemas.openxmlformats.org/spreadsheetml/2006/main", "font")).map((fontElement) => {
+ var _a;
+ return ({
+ bold: findDirectChild(fontElement, "b") ? true : undefined,
+ fontSize: parseOptionalNumber(((_a = findDirectChild(fontElement, "sz")) === null || _a === void 0 ? void 0 : _a.getAttribute("val")) || null)
+ });
+ });
+ }
+ function parseFills(document) {
+ return Array.from(document.getElementsByTagNameNS("http://schemas.openxmlformats.org/spreadsheetml/2006/main", "fill")).map((fillElement) => {
+ const patternFill = findDirectChild(fillElement, "patternFill");
+ const patternType = patternFill === null || patternFill === void 0 ? void 0 : patternFill.getAttribute("patternType");
+ const fgColor = patternFill ? findDirectChild(patternFill, "fgColor") : null;
+ return {
+ patternType: patternType || undefined,
+ fillColor: (fgColor === null || fgColor === void 0 ? void 0 : fgColor.getAttribute("rgb")) || undefined
+ };
+ });
+ }
+ function parseBorders(document) {
+ return Array.from(document.getElementsByTagNameNS("http://schemas.openxmlformats.org/spreadsheetml/2006/main", "border")).map((borderElement) => {
+ const left = findDirectChild(borderElement, "left");
+ const style = left === null || left === void 0 ? void 0 : left.getAttribute("style");
+ return {
+ border: style && BORDER_STYLES.includes(style) ? style : undefined
+ };
+ });
+ }
+ function parseNumberFormatId(numFmtId) {
+ switch (numFmtId) {
+ case 1:
+ return "integer";
+ case 2:
+ return "decimal";
+ case 10:
+ return "percent";
+ case 14:
+ return "date";
+ case 22:
+ return "datetime";
+ default:
+ return "general";
+ }
+ }
+ function resolveCellNumberFormat(cell) {
+ if (cell.numberFormat) {
+ return cell.numberFormat;
+ }
+ if (cell.formula === undefined && cell.value !== undefined) {
+ return "text";
+ }
+ return "general";
+ }
+ function buildInlineStringTextXml(value) {
+ const sanitizedValue = sanitizeXmlText(value);
+ const preserveWhitespace = /^[\s]/.test(sanitizedValue) || /[\s]$/.test(sanitizedValue) || sanitizedValue.includes("\n") || sanitizedValue.includes("\r") || sanitizedValue.includes("\t");
+ const preserveAttribute = preserveWhitespace ? ` xml:space="preserve"` : "";
+ return `${escapeXml(sanitizedValue)} `;
+ }
+ function parseOptionalNumber(value) {
+ if (!value) {
+ return undefined;
+ }
+ return Number(value);
+ }
+ function findDirectChild(element, localName) {
+ for (const childNode of Array.from(element.childNodes)) {
+ if (childNode.nodeType !== Node.ELEMENT_NODE) {
+ continue;
+ }
+ const childElement = childNode;
+ if (childElement.localName === localName) {
+ return childElement;
+ }
+ }
+ return null;
+ }
+ function parseXmlDocument(xmlText) {
+ const document = new DOMParser().parseFromString(xmlText, "application/xml");
+ if (document.querySelector("parsererror")) {
+ throw new Error("Failed to parse XML document");
+ }
+ return document;
+ }
+ function decodeRequiredEntry(entries, name) {
+ const bytes = entries[name];
+ if (!bytes) {
+ throw new Error(`Required ZIP entry is missing: ${name}`);
+ }
+ return decodeUtf8(bytes);
+ }
+ function encodeUtf8(value) {
+ return TEXT_ENCODER.encode(value);
+ }
+ function decodeUtf8(bytes) {
+ return TEXT_DECODER.decode(bytes);
+ }
+ function escapeXml(value) {
+ return sanitizeXmlText(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ }
+ function sanitizeXmlText(value) {
+ return value.replace(/[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD]/g, "");
+ }
+ function encodeColumnName(columnIndex) {
+ let current = columnIndex + 1;
+ let result = "";
+ while (current > 0) {
+ const remainder = (current - 1) % 26;
+ result = String.fromCharCode(65 + remainder) + result;
+ current = Math.floor((current - 1) / 26);
+ }
+ return result;
+ }
+ function encodeCellReference(rowIndex, columnIndex) {
+ if (rowIndex <= 0 && columnIndex <= 0) {
+ return "";
+ }
+ return `${encodeColumnName(columnIndex)}${rowIndex + 1}`;
+ }
+ function resolveActivePane(freezePane) {
+ if (freezePane.rowSplit && freezePane.colSplit) {
+ return "bottomRight";
+ }
+ if (freezePane.rowSplit) {
+ return "bottomLeft";
+ }
+ return "topRight";
+ }
+ function decodeColumnReference(reference) {
+ const match = /^([A-Z]+)\d+$/i.exec(reference);
+ if (!match) {
+ throw new Error(`Invalid cell reference: ${reference}`);
+ }
+ const letters = match[1].toUpperCase();
+ let value = 0;
+ for (const character of letters) {
+ value = (value * 26) + (character.charCodeAt(0) - 64);
+ }
+ return value - 1;
+ }
+ function packZip(entries) {
+ const localParts = [];
+ const centralParts = [];
+ let offset = 0;
+ for (const entry of entries) {
+ const nameBytes = encodeUtf8(entry.name);
+ const crc32 = computeCrc32(entry.data);
+ const localHeader = new Uint8Array(30 + nameBytes.length);
+ const localView = new DataView(localHeader.buffer);
+ localView.setUint32(0, 0x04034b50, true);
+ localView.setUint16(4, 20, true);
+ localView.setUint16(6, 0, true);
+ localView.setUint16(8, 0, true);
+ localView.setUint16(10, 0, true);
+ localView.setUint16(12, 0, true);
+ localView.setUint32(14, crc32, true);
+ localView.setUint32(18, entry.data.byteLength, true);
+ localView.setUint32(22, entry.data.byteLength, true);
+ localView.setUint16(26, nameBytes.length, true);
+ localView.setUint16(28, 0, true);
+ localHeader.set(nameBytes, 30);
+ const centralHeader = new Uint8Array(46 + nameBytes.length);
+ const centralView = new DataView(centralHeader.buffer);
+ centralView.setUint32(0, 0x02014b50, true);
+ centralView.setUint16(4, 20, true);
+ centralView.setUint16(6, 20, true);
+ centralView.setUint16(8, 0, true);
+ centralView.setUint16(10, 0, true);
+ centralView.setUint16(12, 0, true);
+ centralView.setUint16(14, 0, true);
+ centralView.setUint32(16, crc32, true);
+ centralView.setUint32(20, entry.data.byteLength, true);
+ centralView.setUint32(24, entry.data.byteLength, true);
+ centralView.setUint16(28, nameBytes.length, true);
+ centralView.setUint16(30, 0, true);
+ centralView.setUint16(32, 0, true);
+ centralView.setUint16(34, 0, true);
+ centralView.setUint16(36, 0, true);
+ centralView.setUint32(38, 0, true);
+ centralView.setUint32(42, offset, true);
+ centralHeader.set(nameBytes, 46);
+ localParts.push(localHeader, entry.data);
+ centralParts.push(centralHeader);
+ offset += localHeader.byteLength + entry.data.byteLength;
+ }
+ const centralDirectoryOffset = offset;
+ const centralDirectorySize = centralParts.reduce((sum, part) => sum + part.byteLength, 0);
+ const endOfCentralDirectory = new Uint8Array(22);
+ const endView = new DataView(endOfCentralDirectory.buffer);
+ endView.setUint32(0, 0x06054b50, true);
+ endView.setUint16(4, 0, true);
+ endView.setUint16(6, 0, true);
+ endView.setUint16(8, entries.length, true);
+ endView.setUint16(10, entries.length, true);
+ endView.setUint32(12, centralDirectorySize, true);
+ endView.setUint32(16, centralDirectoryOffset, true);
+ endView.setUint16(20, 0, true);
+ return concatUint8Arrays([...localParts, ...centralParts, endOfCentralDirectory]);
+ }
+ function unpackZip(bytes) {
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ const endOffset = findEndOfCentralDirectoryOffset(bytes);
+ const totalEntries = view.getUint16(endOffset + 10, true);
+ const centralDirectoryOffset = view.getUint32(endOffset + 16, true);
+ const entries = {};
+ let pointer = centralDirectoryOffset;
+ for (let index = 0; index < totalEntries; index += 1) {
+ if (view.getUint32(pointer, true) !== 0x02014b50) {
+ throw new Error("Invalid ZIP central directory header");
+ }
+ const compressionMethod = view.getUint16(pointer + 10, true);
+ const compressedSize = view.getUint32(pointer + 20, true);
+ const uncompressedSize = view.getUint32(pointer + 24, true);
+ const fileNameLength = view.getUint16(pointer + 28, true);
+ const extraLength = view.getUint16(pointer + 30, true);
+ const commentLength = view.getUint16(pointer + 32, true);
+ const localHeaderOffset = view.getUint32(pointer + 42, true);
+ const fileName = decodeUtf8(bytes.subarray(pointer + 46, pointer + 46 + fileNameLength));
+ const localView = new DataView(bytes.buffer, bytes.byteOffset + localHeaderOffset, bytes.byteLength - localHeaderOffset);
+ if (localView.getUint32(0, true) !== 0x04034b50) {
+ throw new Error(`Invalid ZIP local header for entry: ${fileName}`);
+ }
+ const localFileNameLength = localView.getUint16(26, true);
+ const localExtraLength = localView.getUint16(28, true);
+ const dataOffset = localHeaderOffset + 30 + localFileNameLength + localExtraLength;
+ const data = bytes.slice(dataOffset, dataOffset + compressedSize);
+ if (compressionMethod !== 0) {
+ throw new Error(`Unsupported ZIP compression method for entry ${fileName}: ${compressionMethod}`);
+ }
+ if (compressedSize !== uncompressedSize) {
+ throw new Error(`Stored ZIP entry size mismatch: ${fileName}`);
+ }
+ entries[fileName] = data;
+ pointer += 46 + fileNameLength + extraLength + commentLength;
+ }
+ return entries;
+ }
+ async function unpackZipAsync(bytes) {
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ const endOffset = findEndOfCentralDirectoryOffset(bytes);
+ const totalEntries = view.getUint16(endOffset + 10, true);
+ const centralDirectoryOffset = view.getUint32(endOffset + 16, true);
+ const entries = {};
+ let pointer = centralDirectoryOffset;
+ for (let index = 0; index < totalEntries; index += 1) {
+ if (view.getUint32(pointer, true) !== 0x02014b50) {
+ throw new Error("Invalid ZIP central directory header");
+ }
+ const compressionMethod = view.getUint16(pointer + 10, true);
+ const compressedSize = view.getUint32(pointer + 20, true);
+ const uncompressedSize = view.getUint32(pointer + 24, true);
+ const fileNameLength = view.getUint16(pointer + 28, true);
+ const extraLength = view.getUint16(pointer + 30, true);
+ const commentLength = view.getUint16(pointer + 32, true);
+ const localHeaderOffset = view.getUint32(pointer + 42, true);
+ const fileName = decodeUtf8(bytes.subarray(pointer + 46, pointer + 46 + fileNameLength));
+ const localView = new DataView(bytes.buffer, bytes.byteOffset + localHeaderOffset, bytes.byteLength - localHeaderOffset);
+ if (localView.getUint32(0, true) !== 0x04034b50) {
+ throw new Error(`Invalid ZIP local header for entry: ${fileName}`);
+ }
+ const localFileNameLength = localView.getUint16(26, true);
+ const localExtraLength = localView.getUint16(28, true);
+ const dataOffset = localHeaderOffset + 30 + localFileNameLength + localExtraLength;
+ const data = bytes.slice(dataOffset, dataOffset + compressedSize);
+ if (compressionMethod === 0) {
+ if (compressedSize !== uncompressedSize) {
+ throw new Error(`Stored ZIP entry size mismatch: ${fileName}`);
+ }
+ entries[fileName] = data;
+ }
+ else if (compressionMethod === 8) {
+ entries[fileName] = await inflateDeflateRaw(data, uncompressedSize, fileName);
+ }
+ else {
+ throw new Error(`Unsupported ZIP compression method for entry ${fileName}: ${compressionMethod}`);
+ }
+ pointer += 46 + fileNameLength + extraLength + commentLength;
+ }
+ return entries;
+ }
+ async function inflateDeflateRaw(compressed, expectedSize, fileName) {
+ var _a;
+ if (typeof DecompressionStream !== "function") {
+ throw new Error(`ZIP deflate compression is not supported in this runtime: ${fileName}`);
+ }
+ const sourceStream = typeof Blob === "function" && typeof ((_a = Blob.prototype) === null || _a === void 0 ? void 0 : _a.stream) === "function"
+ ? new Blob([compressed]).stream()
+ : new Response(compressed).body;
+ if (!sourceStream) {
+ throw new Error(`ZIP deflate stream source is not available in this runtime: ${fileName}`);
+ }
+ const decompressedStream = sourceStream.pipeThrough(new DecompressionStream("deflate-raw"));
+ const buffer = await new Response(decompressedStream).arrayBuffer();
+ const inflated = new Uint8Array(buffer);
+ if (inflated.byteLength !== expectedSize) {
+ throw new Error(`Deflated ZIP entry size mismatch: ${fileName}`);
+ }
+ return inflated;
+ }
+ function findEndOfCentralDirectoryOffset(bytes) {
+ for (let index = bytes.byteLength - 22; index >= 0; index -= 1) {
+ if (bytes[index] === 0x50 &&
+ bytes[index + 1] === 0x4b &&
+ bytes[index + 2] === 0x05 &&
+ bytes[index + 3] === 0x06) {
+ return index;
+ }
+ }
+ throw new Error("ZIP end of central directory not found");
+ }
+ function concatUint8Arrays(parts) {
+ const totalLength = parts.reduce((sum, part) => sum + part.byteLength, 0);
+ const result = new Uint8Array(totalLength);
+ let offset = 0;
+ for (const part of parts) {
+ result.set(part, offset);
+ offset += part.byteLength;
+ }
+ return result;
+ }
+ function computeCrc32(bytes) {
+ let crc = 0xffffffff;
+ for (const byte of bytes) {
+ crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ byte) & 0xff];
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+ }
+ function buildCrc32Table() {
+ const table = new Uint32Array(256);
+ for (let index = 0; index < 256; index += 1) {
+ let value = index;
+ for (let bit = 0; bit < 8; bit += 1) {
+ value = (value & 1) !== 0 ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1);
+ }
+ table[index] = value >>> 0;
+ }
+ return table;
+ }
+ globalThis.__mikuprojectExcelIo = {
+ XlsxWorkbookCodec
+ };
+})();
diff --git a/src/js/main.js b/src/js/main.js
new file mode 100644
index 0000000..4976e72
--- /dev/null
+++ b/src/js/main.js
@@ -0,0 +1,1366 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ const mikuprojectXml = globalThis.__mikuprojectXml;
+ if (!mikuprojectXml) {
+ throw new Error("mikuproject XML module is not loaded");
+ }
+ const mikuprojectExcelIo = globalThis.__mikuprojectExcelIo;
+ if (!mikuprojectExcelIo) {
+ throw new Error("mikuproject Excel IO module is not loaded");
+ }
+ const mikuprojectProjectXlsx = globalThis.__mikuprojectProjectXlsx;
+ if (!mikuprojectProjectXlsx) {
+ throw new Error("mikuproject Project XLSX module is not loaded");
+ }
+ const mikuprojectProjectWorkbookJson = globalThis.__mikuprojectProjectWorkbookJson;
+ if (!mikuprojectProjectWorkbookJson) {
+ throw new Error("mikuproject Project Workbook JSON module is not loaded");
+ }
+ const mikuprojectWbsXlsx = globalThis.__mikuprojectWbsXlsx;
+ if (!mikuprojectWbsXlsx) {
+ throw new Error("mikuproject WBS XLSX module is not loaded");
+ }
+ const mikuprojectWbsMarkdown = globalThis.__mikuprojectWbsMarkdown;
+ if (!mikuprojectWbsMarkdown) {
+ throw new Error("mikuproject WBS Markdown module is not loaded");
+ }
+ const mikuprojectNativeSvg = globalThis.__mikuprojectNativeSvg;
+ if (!mikuprojectNativeSvg) {
+ throw new Error("mikuproject native SVG module is not loaded");
+ }
+ let currentModel = null;
+ let currentNativeSvg = "";
+ let lastSavedXmlText = "";
+ let lastSavedXmlStamp = "";
+ let currentTabId = "input";
+ let isXmlSourceDirty = true;
+ let isRefreshingTransformTab = false;
+ function getElement(id) {
+ const element = document.getElementById(id);
+ if (!element) {
+ throw new Error(`Element not found: ${id}`);
+ }
+ return element;
+ }
+ function getTextArea(id) {
+ return getElement(id);
+ }
+ function getInput(id) {
+ return getElement(id);
+ }
+ function getTabButtons() {
+ return Array.from(document.querySelectorAll(".md-top-tab[data-tab]"));
+ }
+ function getTabPanels() {
+ return Array.from(document.querySelectorAll(".md-tab-panel[data-tab-panel]"));
+ }
+ function setActiveTab(tabId, options = {}) {
+ currentTabId = tabId;
+ for (const button of getTabButtons()) {
+ const isActive = button.dataset.tab === tabId;
+ button.classList.toggle("is-active", isActive);
+ button.setAttribute("aria-selected", isActive ? "true" : "false");
+ button.tabIndex = isActive ? 0 : -1;
+ }
+ for (const panel of getTabPanels()) {
+ panel.hidden = panel.dataset.tabPanel !== tabId;
+ }
+ if (tabId === "transform" && !options.skipTransformRefresh && !isRefreshingTransformTab) {
+ void refreshTransformTab().catch((error) => {
+ setStatus(error instanceof Error ? error.message : "Transform の更新に失敗しました");
+ });
+ }
+ }
+ async function refreshTransformTab() {
+ if (isRefreshingTransformTab) {
+ return;
+ }
+ isRefreshingTransformTab = true;
+ try {
+ if (!currentModel || isXmlSourceDirty) {
+ const xmlText = getTextArea("xmlInput").value.trim();
+ if (!xmlText) {
+ setStatus("XML が空です");
+ return;
+ }
+ parseCurrentXml({ silent: true });
+ }
+ await exportCurrentMermaid({ silent: true });
+ }
+ finally {
+ isRefreshingTransformTab = false;
+ }
+ }
+ function moveTabFocus(currentButton, direction) {
+ const buttons = getTabButtons();
+ const currentIndex = buttons.indexOf(currentButton);
+ if (currentIndex < 0) {
+ return;
+ }
+ const nextIndex = (currentIndex + direction + buttons.length) % buttons.length;
+ const nextButton = buttons[nextIndex];
+ nextButton.focus();
+ const nextTab = nextButton.dataset.tab;
+ if (nextTab === "input" || nextTab === "transform" || nextTab === "output") {
+ setActiveTab(nextTab);
+ }
+ }
+ function bindTabs() {
+ const buttons = getTabButtons();
+ if (buttons.length === 0) {
+ return;
+ }
+ for (const button of buttons) {
+ button.addEventListener("click", () => {
+ const tabId = button.dataset.tab;
+ if (tabId === "input" || tabId === "transform" || tabId === "output") {
+ setActiveTab(tabId);
+ }
+ });
+ button.addEventListener("keydown", (event) => {
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") {
+ event.preventDefault();
+ moveTabFocus(button, 1);
+ return;
+ }
+ if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
+ event.preventDefault();
+ moveTabFocus(button, -1);
+ return;
+ }
+ if (event.key === "Home") {
+ event.preventDefault();
+ buttons[0].focus();
+ setActiveTab("input");
+ return;
+ }
+ if (event.key === "End") {
+ event.preventDefault();
+ buttons[buttons.length - 1].focus();
+ setActiveTab("output");
+ }
+ });
+ }
+ setActiveTab(currentTabId);
+ }
+ function parseHolidayDateList(raw) {
+ if (!raw) {
+ return [];
+ }
+ const seen = new Set();
+ const holidays = [];
+ for (const token of raw.split(/[\s,、;]+/)) {
+ const value = token.trim();
+ if (!value) {
+ continue;
+ }
+ const match = value.match(/^(\d{4}-\d{2}-\d{2})/);
+ if (!match) {
+ continue;
+ }
+ const dateText = match[1];
+ if (seen.has(dateText)) {
+ continue;
+ }
+ seen.add(dateText);
+ holidays.push(dateText);
+ }
+ return holidays;
+ }
+ function parseWbsDefaultHolidayDates() {
+ return parseHolidayDateList(getTextArea("wbsHolidayDatesInput").value.trim());
+ }
+ function parseOptionalNonNegativeInteger(raw) {
+ const value = raw.trim();
+ if (!value) {
+ return undefined;
+ }
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed)) {
+ return undefined;
+ }
+ return Math.max(0, Math.floor(parsed));
+ }
+ function parseWbsDisplayDaysBeforeBaseDate() {
+ return parseOptionalNonNegativeInteger(getInput("wbsDisplayDaysBeforeInput").value);
+ }
+ function parseWbsDisplayDaysAfterBaseDate() {
+ return parseOptionalNonNegativeInteger(getInput("wbsDisplayDaysAfterInput").value);
+ }
+ function useBusinessDaysForWbsDisplayRange() {
+ return true;
+ }
+ function useBusinessDaysForWbsProgressBand() {
+ return true;
+ }
+ function updateWbsHolidaySummary(holidayDates) {
+ const summary = getElement("wbsHolidaySummary");
+ if (holidayDates.length === 0) {
+ summary.textContent = "既定祝日: 0 件";
+ return;
+ }
+ summary.textContent = `既定祝日: ${holidayDates.length} 件 (${holidayDates.join(", ")})`;
+ }
+ function syncWbsHolidayDatesInput(model) {
+ const input = getTextArea("wbsHolidayDatesInput");
+ if (!model) {
+ input.value = "";
+ updateWbsHolidaySummary([]);
+ return;
+ }
+ const holidayDates = mikuprojectWbsXlsx.collectWbsHolidayDates(model);
+ input.value = holidayDates.join("\n");
+ updateWbsHolidaySummary(holidayDates);
+ }
+ function showToast(message) {
+ const toast = document.getElementById("toast");
+ if (toast && typeof toast.show === "function") {
+ toast.show(message, 2200);
+ }
+ }
+ function getAiPromptText() {
+ var _a;
+ const template = document.getElementById("aiPromptTemplate");
+ if (!template) {
+ return "";
+ }
+ return (((_a = template.content) === null || _a === void 0 ? void 0 : _a.textContent) || template.textContent || "").trim();
+ }
+ async function copyTextToClipboard(text) {
+ if (typeof navigator !== "undefined" &&
+ navigator.clipboard &&
+ typeof navigator.clipboard.writeText === "function") {
+ await navigator.clipboard.writeText(text);
+ return;
+ }
+ const textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.setAttribute("readonly", "readonly");
+ textarea.style.position = "fixed";
+ textarea.style.opacity = "0";
+ document.body.appendChild(textarea);
+ textarea.select();
+ document.execCommand("copy");
+ document.body.removeChild(textarea);
+ }
+ async function copyAiPrompt() {
+ const promptText = getAiPromptText();
+ if (!promptText) {
+ throw new Error("生成AIプロンプトが見つかりません");
+ }
+ await copyTextToClipboard(promptText);
+ showToast("生成AIプロンプトをクリップボードにコピーしました");
+ setStatus("生成AIプロンプトをクリップボードにコピーしました");
+ }
+ function setSvgPreviewMarkup(markup) {
+ getElement("nativeSvgPreview").innerHTML = markup;
+ }
+ function updateSvgButton() {
+ getElement("downloadSvgBtn").disabled = !currentModel;
+ }
+ function buildCurrentWbsOptions(model) {
+ syncWbsHolidayDatesInput(model);
+ return {
+ holidayDates: parseWbsDefaultHolidayDates(),
+ displayDaysBeforeBaseDate: parseWbsDisplayDaysBeforeBaseDate(),
+ displayDaysAfterBaseDate: parseWbsDisplayDaysAfterBaseDate(),
+ useBusinessDaysForDisplayRange: useBusinessDaysForWbsDisplayRange(),
+ useBusinessDaysForProgressBand: useBusinessDaysForWbsProgressBand()
+ };
+ }
+ function downloadBlob(blob, filename) {
+ const objectUrl = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = objectUrl;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
+ }
+ async function renderSvgPreview() {
+ if (!currentModel) {
+ currentNativeSvg = "";
+ updateSvgButton();
+ setSvgPreviewMarkup(`SVG を生成すると、ここにプレビューを表示します。
`);
+ return;
+ }
+ currentNativeSvg = mikuprojectNativeSvg.exportNativeSvg(currentModel, buildCurrentWbsOptions(currentModel));
+ setSvgPreviewMarkup(currentNativeSvg);
+ updateSvgButton();
+ }
+ function setStatus(message) {
+ getElement("statusMessage").textContent = message;
+ }
+ function formatSaveStamp(date) {
+ return [
+ date.getFullYear(),
+ String(date.getMonth() + 1).padStart(2, "0"),
+ String(date.getDate()).padStart(2, "0")
+ ].join("-") + " " + [
+ String(date.getHours()).padStart(2, "0"),
+ String(date.getMinutes()).padStart(2, "0")
+ ].join(":");
+ }
+ function updateXmlSaveState(isDirty) {
+ const node = getElement("xmlSaveState");
+ node.textContent = isDirty
+ ? "XML 保存状態: 未保存"
+ : `XML 保存状態: 保存済み (${lastSavedXmlStamp || "-"})`;
+ node.classList.toggle("md-save-state--dirty", isDirty);
+ node.classList.toggle("md-save-state--clean", !isDirty);
+ }
+ function markXmlDirty() {
+ updateXmlSaveState(true);
+ }
+ function markXmlSavedCurrent() {
+ lastSavedXmlText = getTextArea("xmlInput").value;
+ lastSavedXmlStamp = formatSaveStamp(new Date());
+ updateXmlSaveState(false);
+ }
+ function refreshXmlSaveState() {
+ updateXmlSaveState(getTextArea("xmlInput").value !== lastSavedXmlText);
+ }
+ function syncXmlTextFromModel(model) {
+ const xmlText = mikuprojectXml.exportMsProjectXml(model);
+ getTextArea("xmlInput").value = xmlText;
+ isXmlSourceDirty = false;
+ refreshXmlSaveState();
+ return xmlText;
+ }
+ function renderPreviewList(containerId, items) {
+ const container = getElement(containerId);
+ if (items.length === 0) {
+ container.innerHTML = `まだ表示できる項目がありません。
`;
+ return;
+ }
+ container.innerHTML = items.join("");
+ }
+ function formatFirstBaselineSummary(item) {
+ var _a, _b;
+ const baseline = item.baselines[0];
+ if (!baseline) {
+ return "-";
+ }
+ return `#${(_a = baseline.number) !== null && _a !== void 0 ? _a : "-"} ${baseline.start || "-"} -> ${baseline.finish || "-"} / Work=${baseline.work || "-"} / Cost=${(_b = baseline.cost) !== null && _b !== void 0 ? _b : "-"}`;
+ }
+ function formatFirstTimephasedSummary(item) {
+ var _a, _b;
+ const timephasedData = item.timephasedData[0];
+ if (!timephasedData) {
+ return "-";
+ }
+ return `Type=${(_a = timephasedData.type) !== null && _a !== void 0 ? _a : "-"} ${timephasedData.start || "-"} -> ${timephasedData.finish || "-"} / Unit=${(_b = timephasedData.unit) !== null && _b !== void 0 ? _b : "-"} / Value=${timephasedData.value || "-"}`;
+ }
+ function formatFirstExtendedAttributeSummary(item) {
+ const attribute = item.extendedAttributes[0];
+ if (!attribute) {
+ return "-";
+ }
+ return `FieldID=${attribute.fieldID || "-"} / Value=${attribute.value || "-"}`;
+ }
+ function formatFirstProjectExtendedAttributeSummary(project) {
+ const attribute = project.extendedAttributes[0];
+ if (!attribute) {
+ return "-";
+ }
+ return `FieldID=${attribute.fieldID || "-"} / FieldName=${attribute.fieldName || "-"} / Alias=${attribute.alias || "-"}`;
+ }
+ function formatFirstOutlineCodeSummary(project) {
+ const outlineCode = project.outlineCodes[0];
+ if (!outlineCode) {
+ return "-";
+ }
+ return `FieldID=${outlineCode.fieldID || "-"} / FieldName=${outlineCode.fieldName || "-"} / Alias=${outlineCode.alias || "-"}`;
+ }
+ function formatFirstWbsMaskSummary(project) {
+ var _a, _b;
+ const wbsMask = project.wbsMasks[0];
+ if (!wbsMask) {
+ return "-";
+ }
+ return `Level=${wbsMask.level} / Mask=${wbsMask.mask || "-"} / Length=${(_a = wbsMask.length) !== null && _a !== void 0 ? _a : "-"} / Sequence=${(_b = wbsMask.sequence) !== null && _b !== void 0 ? _b : "-"}`;
+ }
+ function formatCalendarWeekDaySummary(calendar) {
+ const weekDay = calendar.weekDays[0];
+ if (!weekDay) {
+ return "-";
+ }
+ const workingTimes = weekDay.workingTimes.length > 0
+ ? weekDay.workingTimes.map((item) => `${item.fromTime}-${item.toTime}`).join(", ")
+ : "-";
+ return `DayType=${weekDay.dayType} / Working=${weekDay.dayWorking ? 1 : 0} / Times=${workingTimes}`;
+ }
+ function formatCalendarExceptionSummary(calendar) {
+ const exception = calendar.exceptions[0];
+ if (!exception) {
+ return "-";
+ }
+ return `${exception.name || "(no name)"} ${exception.fromDate || "-"} -> ${exception.toDate || "-"} / Working=${exception.dayWorking ? 1 : 0}`;
+ }
+ function formatCalendarWorkWeekSummary(calendar) {
+ const workWeek = calendar.workWeeks[0];
+ if (!workWeek) {
+ return "-";
+ }
+ return `${workWeek.name || "(no name)"} ${workWeek.fromDate || "-"} -> ${workWeek.toDate || "-"} / WeekDays=${workWeek.weekDays.length}`;
+ }
+ function formatCalendarReferenceSummary(model, calendar) {
+ const projectRefs = model.project.calendarUID === calendar.uid ? 1 : 0;
+ const taskRefs = model.tasks.filter((task) => task.calendarUID === calendar.uid).length;
+ const resourceRefs = model.resources.filter((resource) => resource.calendarUID === calendar.uid).length;
+ const baseRefs = model.calendars.filter((item) => item.baseCalendarUID === calendar.uid).length;
+ return `Project=${projectRefs} / Tasks=${taskRefs} / Resources=${resourceRefs} / BaseOf=${baseRefs}`;
+ }
+ function formatCalendarLink(model, calendarUID) {
+ if (!calendarUID) {
+ return "-";
+ }
+ const calendar = model.calendars.find((item) => item.uid === calendarUID);
+ return calendar ? `${calendarUID} (${calendar.name || "(no name)"})` : `${calendarUID} (missing)`;
+ }
+ function formatTaskLink(model, taskUID) {
+ if (!taskUID) {
+ return "-";
+ }
+ const task = model.tasks.find((item) => item.uid === taskUID);
+ return task ? `${taskUID} (${task.name || "(no name)"})` : `${taskUID} (missing)`;
+ }
+ function formatResourceLink(model, resourceUID) {
+ if (!resourceUID) {
+ return "-";
+ }
+ const resource = model.resources.find((item) => item.uid === resourceUID);
+ return resource ? `${resourceUID} (${resource.name || "(no name)"})` : `${resourceUID} (missing)`;
+ }
+ function renderValidationIssues(issues) {
+ const container = getElement("validationIssues");
+ const label = container.previousElementSibling;
+ if (issues.length === 0) {
+ container.classList.add("md-hidden");
+ container.innerHTML = "";
+ label === null || label === void 0 ? void 0 : label.classList.add("md-hidden");
+ updateFeedbackVisibility();
+ return;
+ }
+ const sections = ["project", "tasks", "resources", "assignments", "calendars"];
+ const sectionLabels = {
+ project: "Project",
+ tasks: "Tasks",
+ resources: "Resources",
+ assignments: "Assignments",
+ calendars: "Calendars"
+ };
+ container.classList.remove("md-hidden");
+ label === null || label === void 0 ? void 0 : label.classList.remove("md-hidden");
+ container.innerHTML = `
+ 検証メッセージ
+ ${sections
+ .map((scope) => {
+ const scopedIssues = issues.filter((issue) => issue.scope === scope);
+ if (scopedIssues.length === 0) {
+ return "";
+ }
+ return `
+
+
${sectionLabels[scope]}
+
+ ${scopedIssues.map((issue) => `[${issue.level}] ${issue.message} `).join("")}
+
+
+ `;
+ })
+ .join("")}
+ `;
+ updateFeedbackVisibility();
+ }
+ function renderImportWarnings(warnings) {
+ const container = getElement("importWarnings");
+ const label = container.previousElementSibling;
+ if (warnings.length === 0) {
+ container.classList.add("md-hidden");
+ container.innerHTML = "";
+ label === null || label === void 0 ? void 0 : label.classList.add("md-hidden");
+ updateFeedbackVisibility();
+ return;
+ }
+ container.classList.remove("md-hidden");
+ label === null || label === void 0 ? void 0 : label.classList.remove("md-hidden");
+ container.innerHTML = `
+ 取込 warning
+
+ ${warnings.map((warning) => `${escapeHtml(warning.message)} `).join("")}
+
+ `;
+ updateFeedbackVisibility();
+ }
+ function renderXlsxImportSummary(changes) {
+ const container = getElement("xlsxImportSummary");
+ const label = container.previousElementSibling;
+ if (changes.length === 0) {
+ container.classList.add("md-hidden");
+ container.innerHTML = "";
+ label === null || label === void 0 ? void 0 : label.classList.add("md-hidden");
+ updateFeedbackVisibility();
+ return;
+ }
+ const scopeLabel = {
+ project: "Project",
+ tasks: "Tasks",
+ resources: "Resources",
+ assignments: "Assignments",
+ calendars: "Calendars"
+ };
+ const scopeCounts = {
+ project: 0,
+ tasks: 0,
+ resources: 0,
+ assignments: 0,
+ calendars: 0
+ };
+ const groupedByScope = new Map();
+ const groupedChanges = new Map();
+ for (const change of changes) {
+ const groupKey = `${change.scope}:${change.uid}:${change.label}`;
+ const currentGroup = groupedChanges.get(groupKey);
+ if (currentGroup) {
+ currentGroup.items.push({
+ field: change.field,
+ before: change.before,
+ after: change.after
+ });
+ continue;
+ }
+ groupedChanges.set(groupKey, {
+ scope: change.scope,
+ uid: change.uid,
+ label: change.label,
+ items: [{
+ field: change.field,
+ before: change.before,
+ after: change.after
+ }]
+ });
+ scopeCounts[change.scope] += 1;
+ }
+ for (const group of groupedChanges.values()) {
+ const scopedGroups = groupedByScope.get(group.scope) || [];
+ scopedGroups.push({
+ uid: group.uid,
+ label: group.label,
+ items: group.items
+ });
+ groupedByScope.set(group.scope, scopedGroups);
+ }
+ const changedScopes = ["project", "tasks", "resources", "assignments", "calendars"].filter((scope) => scopeCounts[scope] > 0);
+ const unchangedScopes = ["project", "tasks", "resources", "assignments", "calendars"].filter((scope) => scopeCounts[scope] === 0);
+ container.classList.remove("md-hidden");
+ label === null || label === void 0 ? void 0 : label.classList.remove("md-hidden");
+ container.innerHTML = `
+ XLSX Import 反映結果
+
+ ${changedScopes.map((scope) => `${scopeLabel[scope]} ${scopeCounts[scope]} `).join("")}
+
+ ${unchangedScopes.length > 0 ? `変更なし: ${unchangedScopes.map((scope) => scopeLabel[scope]).join(", ")}
` : ""}
+ ${changedScopes.map((scope) => `
+
+
${scopeLabel[scope]}
+
+ ${(groupedByScope.get(scope) || []).map((group) => `
+
+ UID=${group.uid} ${escapeHtml(group.label)}
+
+ ${group.items.map((item) => `${escapeHtml(item.field)}: ${escapeHtml(formatChangeValue(item.before))} -> ${escapeHtml(formatChangeValue(item.after))}`).join(" / ")}
+
+
+ `).join("")}
+
+
+ `).join("")}
+ 反映後の XML は更新済みです。必要なら XML Export で保存できます。
+ `;
+ updateFeedbackVisibility();
+ }
+ function updateFeedbackVisibility() {
+ const stack = document.querySelector(".md-feedback-stack");
+ const validationIssues = getElement("validationIssues");
+ const importWarnings = getElement("importWarnings");
+ const xlsxImportSummary = getElement("xlsxImportSummary");
+ const shouldShow = !validationIssues.classList.contains("md-hidden")
+ || !importWarnings.classList.contains("md-hidden")
+ || !xlsxImportSummary.classList.contains("md-hidden");
+ stack === null || stack === void 0 ? void 0 : stack.classList.toggle("md-hidden", !shouldShow);
+ }
+ function formatChangeValue(value) {
+ if (value === undefined) {
+ return "(empty)";
+ }
+ return String(value);
+ }
+ function escapeHtml(value) {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ }
+ function updateSummary(model) {
+ updateSvgButton();
+ syncWbsHolidayDatesInput(model);
+ getElement("summaryProjectName").textContent = (model === null || model === void 0 ? void 0 : model.project.name) || "-";
+ getElement("summaryTaskCount").textContent = String((model === null || model === void 0 ? void 0 : model.tasks.length) || 0);
+ getElement("summaryResourceCount").textContent = String((model === null || model === void 0 ? void 0 : model.resources.length) || 0);
+ getElement("summaryAssignmentCount").textContent = String((model === null || model === void 0 ? void 0 : model.assignments.length) || 0);
+ getElement("summaryCalendarCount").textContent = String((model === null || model === void 0 ? void 0 : model.calendars.length) || 0);
+ getTextArea("modelOutput").value = model ? JSON.stringify(model, null, 2) : "";
+ renderPreviewList("projectPreview", model ? [`
+
+
${model.project.name || "(no name)"}
+
Title=${model.project.title || "-"}
+Author=${model.project.author || "-"} / Company=${model.project.company || "-"}
+Start=${model.project.startDate || "-"} / Finish=${model.project.finishDate || "-"}
+Calendar=${formatCalendarLink(model, model.project.calendarUID)}
+OutlineCodes=${model.project.outlineCodes.length} / WBSMasks=${model.project.wbsMasks.length} / Ext=${model.project.extendedAttributes.length}
+OutlineCode1=${formatFirstOutlineCodeSummary(model.project)}
+WBSMask1=${formatFirstWbsMaskSummary(model.project)}
+Ext1=${formatFirstProjectExtendedAttributeSummary(model.project)}
+
+ `] : []);
+ renderPreviewList("taskPreview", model ? model.tasks.map((task) => `
+
+
${task.name || "(no name)"}
+
UID=${task.uid} / ID=${task.id} / Outline=${task.outlineNumber || task.outlineLevel}
+Calendar=${formatCalendarLink(model, task.calendarUID)}
+Start=${task.start || "-"}
+Finish=${task.finish || "-"}
+Predecessors=${task.predecessors.map((item) => item.predecessorUid).join(", ") || "-"}
+Ext=${task.extendedAttributes.length} / Baselines=${task.baselines.length} / Timephased=${task.timephasedData.length}
+Ext1=${formatFirstExtendedAttributeSummary(task)}
+Baseline1=${formatFirstBaselineSummary(task)}
+Timephased1=${formatFirstTimephasedSummary(task)}
+
+ `) : []);
+ renderPreviewList("resourcePreview", model ? model.resources.map((resource) => `
+
+
${resource.name || "(no name)"}
+
UID=${resource.uid} / ID=${resource.id}
+Initials=${resource.initials || "-"}
+Group=${resource.group || "-"}
+Calendar=${formatCalendarLink(model, resource.calendarUID)}
+Ext=${resource.extendedAttributes.length} / Baselines=${resource.baselines.length} / Timephased=${resource.timephasedData.length}
+Ext1=${formatFirstExtendedAttributeSummary(resource)}
+Baseline1=${formatFirstBaselineSummary(resource)}
+Timephased1=${formatFirstTimephasedSummary(resource)}
+
+ `) : []);
+ renderPreviewList("assignmentPreview", model ? model.assignments.map((assignment) => `
+
+
Assignment ${assignment.uid || "-"}
+
Task=${formatTaskLink(model, assignment.taskUid)}
+Resource=${formatResourceLink(model, assignment.resourceUid)}
+Start=${assignment.start || "-"}
+Finish=${assignment.finish || "-"}
+Ext=${assignment.extendedAttributes.length} / Baselines=${assignment.baselines.length} / Timephased=${assignment.timephasedData.length}
+Ext1=${formatFirstExtendedAttributeSummary(assignment)}
+Baseline1=${formatFirstBaselineSummary(assignment)}
+Timephased1=${formatFirstTimephasedSummary(assignment)}
+
+ `) : []);
+ renderPreviewList("calendarPreview", model ? model.calendars.map((calendar) => `
+
+
${calendar.name || "(no name)"}
+
UID=${calendar.uid}
+Base=${calendar.isBaseCalendar ? 1 : 0} / Baseline=${calendar.isBaselineCalendar ? 1 : 0} / BaseCalendarUID=${calendar.baseCalendarUID || "-"}
+WeekDays=${calendar.weekDays.length} / Exceptions=${calendar.exceptions.length} / WorkWeeks=${calendar.workWeeks.length}
+Refs=${formatCalendarReferenceSummary(model, calendar)}
+WeekDay1=${formatCalendarWeekDaySummary(calendar)}
+Exception1=${formatCalendarExceptionSummary(calendar)}
+WorkWeek1=${formatCalendarWorkWeekSummary(calendar)}
+
+ `) : []);
+ }
+ function loadSample() {
+ currentModel = null;
+ getTextArea("xmlInput").value = mikuprojectXml.SAMPLE_XML;
+ isXmlSourceDirty = true;
+ markXmlDirty();
+ setStatus("サンプル XML を読み込みました");
+ setActiveTab("input");
+ }
+ async function importXmlFromFile(file) {
+ if (!file) {
+ return;
+ }
+ const xmlText = await file.text();
+ getTextArea("xmlInput").value = xmlText;
+ markXmlDirty();
+ currentModel = mikuprojectXml.importMsProjectXml(xmlText);
+ isXmlSourceDirty = false;
+ const issues = mikuprojectXml.validateProjectModel(currentModel);
+ updateSummary(currentModel);
+ renderValidationIssues(issues);
+ renderImportWarnings([]);
+ renderXlsxImportSummary([]);
+ setStatus(issues.length > 0 ? `XML ファイルを読み込んで解析しました。検証で ${issues.length} 件の問題があります` : "XML ファイルを読み込んで解析しました");
+ showToast("XML を読み込んで解析しました");
+ setActiveTab("transform", { skipTransformRefresh: true });
+ await exportCurrentMermaid({ silent: true });
+ }
+ function ensureCurrentModel() {
+ if (currentModel) {
+ return currentModel;
+ }
+ const xmlText = getTextArea("xmlInput").value.trim();
+ if (!xmlText) {
+ throw new Error("内部モデルがありません");
+ }
+ currentModel = mikuprojectXml.importMsProjectXml(xmlText);
+ isXmlSourceDirty = false;
+ return currentModel;
+ }
+ function parseCurrentXml(options = {}) {
+ const xmlText = getTextArea("xmlInput").value.trim();
+ if (!xmlText) {
+ setStatus("XML が空です");
+ return;
+ }
+ currentModel = mikuprojectXml.importMsProjectXml(xmlText);
+ isXmlSourceDirty = false;
+ const issues = mikuprojectXml.validateProjectModel(currentModel);
+ updateSummary(currentModel);
+ renderValidationIssues(issues);
+ renderImportWarnings([]);
+ renderXlsxImportSummary([]);
+ if (!options.silent) {
+ setStatus(issues.length > 0 ? `XML を解析しました。検証で ${issues.length} 件の問題があります` : "XML を内部モデルへ変換しました");
+ showToast("XML を解析しました");
+ }
+ setActiveTab("transform", { skipTransformRefresh: true });
+ }
+ async function exportCurrentMermaid(options = {}) {
+ if (!currentModel) {
+ setStatus("内部モデルがありません");
+ return;
+ }
+ const mermaidText = mikuprojectXml.exportMermaidGantt(currentModel);
+ getTextArea("mermaidOutput").value = mermaidText;
+ await renderSvgPreview();
+ if (!options.silent) {
+ setStatus("内部モデルから Mermaid gantt を生成し、native SVG preview を更新しました");
+ showToast("Mermaid を生成しました");
+ }
+ setActiveTab("transform", { skipTransformRefresh: true });
+ }
+ function exportCurrentCsv() {
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const csvText = mikuprojectXml.exportCsvParentId(model);
+ const now = new Date();
+ const stamp = [
+ now.getFullYear(),
+ String(now.getMonth() + 1).padStart(2, "0"),
+ String(now.getDate()).padStart(2, "0"),
+ String(now.getHours()).padStart(2, "0"),
+ String(now.getMinutes()).padStart(2, "0")
+ ].join("");
+ downloadBlob(new Blob([`${csvText}\n`], { type: "text/csv;charset=utf-8" }), `mikuproject-export-${stamp}.csv`);
+ setStatus("内部モデルから CSV + ParentID を生成して保存しました");
+ showToast("CSV を保存しました");
+ setActiveTab("output");
+ }
+ function exportCurrentProjectOverviewView() {
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const viewText = JSON.stringify(mikuprojectXml.exportProjectOverviewView(model), null, 2);
+ getTextArea("projectOverviewOutput").value = viewText;
+ downloadBlob(new Blob([`${viewText}\n`], { type: "application/json;charset=utf-8" }), "mikuproject-project-overview-view.editjson");
+ setStatus("project_overview_view を生成して保存しました");
+ showToast("project_overview_view を保存しました");
+ setActiveTab("output");
+ }
+ function exportCurrentAiProjectionBundle() {
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const projectOverview = mikuprojectXml.exportProjectOverviewView(model);
+ const phaseDetailViewsFull = (projectOverview.phases || [])
+ .map((phase) => phase === null || phase === void 0 ? void 0 : phase.uid)
+ .filter((uid) => Boolean(uid))
+ .map((phaseUid) => mikuprojectXml.exportPhaseDetailView(model, phaseUid, { mode: "full" }));
+ const bundle = {
+ view_type: "ai_projection_bundle",
+ project_overview_view: projectOverview,
+ phase_detail_views_full: phaseDetailViewsFull
+ };
+ const bundleText = JSON.stringify(bundle, null, 2);
+ getTextArea("aiBundleOutput").value = bundleText;
+ downloadBlob(new Blob([`${bundleText}\n`], { type: "application/json;charset=utf-8" }), "mikuproject-full-bundle.editjson");
+ setStatus(`AI 連携用まとめ JSON を生成して保存しました (phase_detail_view full ${phaseDetailViewsFull.length} 件)`);
+ showToast("AI 連携用まとめ JSON を保存しました");
+ setActiveTab("output");
+ }
+ function extractLastJsonBlock(value) {
+ var _a, _b;
+ const matches = Array.from(value.matchAll(/```json\s*([\s\S]*?)```/g));
+ if (matches.length > 0) {
+ return ((_b = (_a = matches.at(-1)) === null || _a === void 0 ? void 0 : _a[1]) === null || _b === void 0 ? void 0 : _b.trim()) || "";
+ }
+ return value.trim();
+ }
+ function detectJsonDocumentKind(documentLike) {
+ if (!documentLike || typeof documentLike !== "object") {
+ return undefined;
+ }
+ const candidate = documentLike;
+ if (candidate.format === "mikuproject_workbook_json") {
+ return "workbook_json";
+ }
+ if (candidate.view_type === "project_draft_view") {
+ return "project_draft_view";
+ }
+ return undefined;
+ }
+ async function importProjectDraftFromText() {
+ const sourceText = getTextArea("projectDraftImportInput").value.trim();
+ if (!sourceText) {
+ throw new Error("project_draft_view JSON を入力してください");
+ }
+ const jsonText = extractLastJsonBlock(sourceText);
+ const draft = JSON.parse(jsonText);
+ currentModel = mikuprojectXml.importProjectDraftView(draft);
+ syncXmlTextFromModel(currentModel);
+ const issues = mikuprojectXml.validateProjectModel(currentModel);
+ updateSummary(currentModel);
+ renderValidationIssues(issues);
+ renderImportWarnings([]);
+ renderXlsxImportSummary([]);
+ await exportCurrentMermaid({ silent: true });
+ setStatus(issues.length > 0 ? `project_draft_view を取り込みました。検証で ${issues.length} 件の問題があります` : "project_draft_view を取り込みました");
+ showToast("project_draft_view を取り込みました");
+ setActiveTab("transform", { skipTransformRefresh: true });
+ }
+ function loadProjectDraftSample() {
+ const sampleDraftText = JSON.stringify(mikuprojectXml.SAMPLE_PROJECT_DRAFT_VIEW, null, 2);
+ getTextArea("projectDraftImportInput").value = sampleDraftText;
+ setStatus("サンプル project_draft_view を読み込みました");
+ setActiveTab("input");
+ }
+ async function importProjectDraftFromFile(file) {
+ if (!file) {
+ throw new Error("project_draft_view JSON ファイルを選択してください");
+ }
+ const sourceText = await file.text();
+ getTextArea("projectDraftImportInput").value = sourceText;
+ await importProjectDraftFromText();
+ }
+ async function importWorkbookJsonFromSourceText(sourceText) {
+ const trimmedSourceText = sourceText.trim();
+ if (!trimmedSourceText) {
+ throw new Error("workbook JSON を入力してください");
+ }
+ const documentLike = JSON.parse(extractLastJsonBlock(trimmedSourceText));
+ const baseModel = ensureCurrentModel();
+ const result = mikuprojectProjectWorkbookJson.importProjectWorkbookJson(documentLike, baseModel);
+ currentModel = result.model;
+ const issues = mikuprojectXml.validateProjectModel(currentModel);
+ updateSummary(currentModel);
+ renderValidationIssues(issues);
+ renderImportWarnings(result.warnings);
+ renderXlsxImportSummary(result.changes);
+ if (result.changes.length > 0) {
+ getTextArea("xmlInput").value = mikuprojectXml.exportMsProjectXml(currentModel);
+ markXmlDirty();
+ }
+ isXmlSourceDirty = false;
+ const summaryText = result.changes.length > 0
+ ? `JSON を読み込んで ${result.changes.length} 件の変更を反映しました。XML は再生成済みで、必要なら XML Export で保存できます`
+ : "JSON に反映対象の変更はありませんでした。XML は未変更です";
+ const warningText = result.warnings.length > 0 ? `。JSON 取込で ${result.warnings.length} 件の warning を無視しました` : "";
+ setStatus(issues.length > 0 ? `${summaryText}${warningText}。検証で ${issues.length} 件の問題があります` : `${summaryText}${warningText}`);
+ showToast("JSON を反映しました");
+ setActiveTab("transform", { skipTransformRefresh: true });
+ await exportCurrentMermaid({ silent: true });
+ }
+ async function importWorkbookJsonFromFile(file) {
+ if (!file) {
+ throw new Error("workbook JSON ファイルを選択してください");
+ }
+ const sourceText = await file.text();
+ await importWorkbookJsonFromSourceText(sourceText);
+ }
+ async function importFromFile(file) {
+ if (!file) {
+ return;
+ }
+ const normalizedName = file.name.trim().toLowerCase();
+ if (normalizedName.endsWith(".xml")) {
+ await importXmlFromFile(file);
+ return;
+ }
+ if (normalizedName.endsWith(".xlsx")) {
+ await importXlsxFromFile(file);
+ return;
+ }
+ if (normalizedName.endsWith(".csv")) {
+ await importCsvFromFile(file);
+ return;
+ }
+ if (normalizedName.endsWith(".editjson")) {
+ await importProjectDraftFromFile(file);
+ return;
+ }
+ if (normalizedName.endsWith(".json")) {
+ const sourceText = await file.text();
+ const documentLike = JSON.parse(extractLastJsonBlock(sourceText));
+ const kind = detectJsonDocumentKind(documentLike);
+ if (kind === "workbook_json") {
+ await importWorkbookJsonFromSourceText(sourceText);
+ return;
+ }
+ if (kind === "project_draft_view") {
+ getTextArea("projectDraftImportInput").value = sourceText;
+ await importProjectDraftFromText();
+ return;
+ }
+ throw new Error("JSON の format / view_type を判別できません。workbook JSON か project_draft_view を指定してください");
+ }
+ throw new Error("対応していないファイル形式です。.xml / .xlsx / .json / .editjson / .csv を指定してください");
+ }
+ function exportCurrentPhaseDetailView(mode = "scoped") {
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const requestedPhaseUid = getInput("phaseDetailUidInput").value.trim() || undefined;
+ const requestedRootUid = mode === "scoped" ? getInput("phaseDetailRootUidInput").value.trim() || undefined : undefined;
+ const maxDepthText = getInput("phaseDetailMaxDepthInput").value.trim();
+ const requestedMaxDepth = mode === "scoped" && maxDepthText !== "" ? Number.parseInt(maxDepthText, 10) : undefined;
+ if (typeof requestedMaxDepth === "number" && (!Number.isFinite(requestedMaxDepth) || requestedMaxDepth < 0)) {
+ throw new Error("max depth は 0 以上の整数で指定してください");
+ }
+ const view = mikuprojectXml.exportPhaseDetailView(model, requestedPhaseUid, {
+ mode,
+ rootUid: requestedRootUid,
+ maxDepth: requestedMaxDepth
+ });
+ if ((_a = view.phase) === null || _a === void 0 ? void 0 : _a.uid) {
+ getInput("phaseDetailUidInput").value = view.phase.uid;
+ }
+ getInput("phaseDetailRootUidInput").value = ((_b = view.scope) === null || _b === void 0 ? void 0 : _b.root_uid) || "";
+ getInput("phaseDetailMaxDepthInput").value = typeof ((_c = view.scope) === null || _c === void 0 ? void 0 : _c.max_depth) === "number" ? String(view.scope.max_depth) : "";
+ const viewText = JSON.stringify(view, null, 2);
+ getTextArea("phaseDetailOutput").value = viewText;
+ const phaseSuffix = ((_d = view.phase) === null || _d === void 0 ? void 0 : _d.uid) ? `-${view.phase.uid}` : "";
+ const modeSuffix = ((_e = view.scope) === null || _e === void 0 ? void 0 : _e.mode) === "scoped" ? "-scoped" : "-full";
+ const rootSuffix = ((_f = view.scope) === null || _f === void 0 ? void 0 : _f.root_uid) ? `-root-${view.scope.root_uid}` : "";
+ const depthSuffix = typeof ((_g = view.scope) === null || _g === void 0 ? void 0 : _g.max_depth) === "number" ? `-depth-${view.scope.max_depth}` : "";
+ downloadBlob(new Blob([`${viewText}\n`], { type: "application/json;charset=utf-8" }), `mikuproject-phase-detail-view${phaseSuffix}${modeSuffix}${rootSuffix}${depthSuffix}.editjson`);
+ setStatus(`phase_detail_view (${((_h = view.scope) === null || _h === void 0 ? void 0 : _h.mode) === "scoped" ? "scoped" : "full"}) を生成して保存しました`);
+ showToast(`phase_detail_view (${((_j = view.scope) === null || _j === void 0 ? void 0 : _j.mode) === "scoped" ? "scoped" : "full"}) を保存しました`);
+ setActiveTab("output");
+ }
+ function exportCurrentXlsx() {
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const workbook = mikuprojectProjectXlsx.exportProjectWorkbook(model);
+ const codec = new mikuprojectExcelIo.XlsxWorkbookCodec();
+ const bytes = codec.exportWorkbook(workbook);
+ const now = new Date();
+ const stamp = [
+ now.getFullYear(),
+ String(now.getMonth() + 1).padStart(2, "0"),
+ String(now.getDate()).padStart(2, "0"),
+ String(now.getHours()).padStart(2, "0"),
+ String(now.getMinutes()).padStart(2, "0")
+ ].join("");
+ downloadBlob(new Blob([bytes], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }), `mikuproject-export-${stamp}.xlsx`);
+ setStatus("XLSX ファイルをエクスポートしました");
+ showToast("XLSX を保存しました");
+ setActiveTab("output");
+ }
+ function exportCurrentWorkbookJson() {
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const jsonText = JSON.stringify(mikuprojectProjectWorkbookJson.exportProjectWorkbookJson(model), null, 2);
+ const now = new Date();
+ const stamp = [
+ now.getFullYear(),
+ String(now.getMonth() + 1).padStart(2, "0"),
+ String(now.getDate()).padStart(2, "0"),
+ String(now.getHours()).padStart(2, "0"),
+ String(now.getMinutes()).padStart(2, "0")
+ ].join("");
+ getTextArea("workbookJsonOutput").value = jsonText;
+ downloadBlob(new Blob([`${jsonText}\n`], { type: "application/json;charset=utf-8" }), `mikuproject-workbook-${stamp}.json`);
+ setStatus("XLSX 相当の workbook JSON を生成して保存しました");
+ showToast("JSON を保存しました");
+ setActiveTab("output");
+ }
+ function exportCurrentWbsXlsx() {
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const defaultHolidayDates = parseWbsDefaultHolidayDates();
+ const displayDaysBeforeBaseDate = parseWbsDisplayDaysBeforeBaseDate();
+ const displayDaysAfterBaseDate = parseWbsDisplayDaysAfterBaseDate();
+ const useBusinessDaysForDisplayRange = useBusinessDaysForWbsDisplayRange();
+ const useBusinessDaysForProgressBand = useBusinessDaysForWbsProgressBand();
+ const workbook = mikuprojectWbsXlsx.exportWbsWorkbook(model, {
+ holidayDates: defaultHolidayDates,
+ displayDaysBeforeBaseDate,
+ displayDaysAfterBaseDate,
+ useBusinessDaysForDisplayRange,
+ useBusinessDaysForProgressBand
+ });
+ const codec = new mikuprojectExcelIo.XlsxWorkbookCodec();
+ const bytes = codec.exportWorkbook(workbook);
+ const now = new Date();
+ const stamp = [
+ now.getFullYear(),
+ String(now.getMonth() + 1).padStart(2, "0"),
+ String(now.getDate()).padStart(2, "0"),
+ String(now.getHours()).padStart(2, "0"),
+ String(now.getMinutes()).padStart(2, "0")
+ ].join("");
+ downloadBlob(new Blob([bytes], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }), `mikuproject-wbs-${stamp}.xlsx`);
+ const displayRangeText = displayDaysBeforeBaseDate !== undefined || displayDaysAfterBaseDate !== undefined
+ ? ` / 表示期間 営業日 基準日前 ${displayDaysBeforeBaseDate || 0} 日, 基準日後 ${displayDaysAfterBaseDate || 0} 日`
+ : "";
+ const progressBandText = " / 進捗帯 営業日";
+ setStatus(`WBS XLSX ファイルをエクスポートしました${defaultHolidayDates.length > 0 ? ` (祝日 ${defaultHolidayDates.length} 件)` : ""}${displayRangeText}${progressBandText}`);
+ showToast("WBS XLSX を保存しました");
+ setActiveTab("output");
+ }
+ async function importXlsxFromFile(file) {
+ if (!file) {
+ return;
+ }
+ const baseModel = ensureCurrentModel();
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ const codec = new mikuprojectExcelIo.XlsxWorkbookCodec();
+ const workbook = typeof codec.importWorkbookAsync === "function"
+ ? await codec.importWorkbookAsync(bytes)
+ : codec.importWorkbook(bytes);
+ const result = mikuprojectProjectXlsx.importProjectWorkbookDetailed(workbook, baseModel);
+ currentModel = result.model;
+ const issues = mikuprojectXml.validateProjectModel(currentModel);
+ updateSummary(currentModel);
+ renderValidationIssues(issues);
+ renderImportWarnings([]);
+ renderXlsxImportSummary(result.changes);
+ if (result.changes.length > 0) {
+ getTextArea("xmlInput").value = mikuprojectXml.exportMsProjectXml(currentModel);
+ markXmlDirty();
+ }
+ const summaryText = result.changes.length > 0
+ ? `XLSX を読み込んで ${result.changes.length} 件の変更を反映しました。XML は再生成済みで、必要なら XML Export で保存できます`
+ : "XLSX に反映対象の変更はありませんでした。XML は未変更です";
+ isXmlSourceDirty = false;
+ setStatus(issues.length > 0 ? `${summaryText}。検証で ${issues.length} 件の問題があります` : summaryText);
+ showToast("XLSX を反映しました");
+ setActiveTab("transform", { skipTransformRefresh: true });
+ await exportCurrentMermaid({ silent: true });
+ }
+ async function importCsvFromFile(file) {
+ if (!file) {
+ return;
+ }
+ const csvText = (await file.text()).trim();
+ if (!csvText) {
+ setStatus("CSV が空です");
+ return;
+ }
+ currentModel = mikuprojectXml.importCsvParentId(csvText);
+ isXmlSourceDirty = false;
+ const issues = mikuprojectXml.validateProjectModel(currentModel);
+ updateSummary(currentModel);
+ renderValidationIssues(issues);
+ renderImportWarnings([]);
+ renderXlsxImportSummary([]);
+ setStatus(issues.length > 0 ? `CSV ファイルを読み込んで解析しました。検証で ${issues.length} 件の問題があります` : "CSV + ParentID を内部モデルへ変換しました");
+ showToast("CSV を読み込みました");
+ setActiveTab("transform", { skipTransformRefresh: true });
+ await exportCurrentMermaid({ silent: true });
+ }
+ function downloadCurrentXml() {
+ const model = ensureCurrentModel();
+ const xmlText = syncXmlTextFromModel(model);
+ const blob = new Blob([`${xmlText}\n`], { type: "application/xml;charset=utf-8" });
+ const objectUrl = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ const now = new Date();
+ const stamp = [
+ now.getFullYear(),
+ String(now.getMonth() + 1).padStart(2, "0"),
+ String(now.getDate()).padStart(2, "0"),
+ String(now.getHours()).padStart(2, "0"),
+ String(now.getMinutes()).padStart(2, "0")
+ ].join("");
+ link.href = objectUrl;
+ link.download = `mikuproject-export-${stamp}.xml`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
+ markXmlSavedCurrent();
+ setStatus("XML ファイルをエクスポートしました");
+ showToast("XML を保存しました");
+ setActiveTab("output");
+ }
+ async function downloadCurrentSvg() {
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const mermaidText = mikuprojectXml.exportMermaidGantt(model);
+ getTextArea("mermaidOutput").value = mermaidText;
+ await renderSvgPreview();
+ if (!currentNativeSvg) {
+ setStatus("出力する SVG がありません");
+ return;
+ }
+ downloadBlob(new Blob([currentNativeSvg], { type: "image/svg+xml;charset=utf-8" }), "mikuproject-native.svg");
+ setStatus("SVG を保存しました");
+ showToast("SVG を保存しました");
+ setActiveTab("output");
+ }
+ function downloadCurrentMermaidMarkdown() {
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const mermaidText = mikuprojectXml.exportMermaidGantt(model);
+ getTextArea("mermaidOutput").value = mermaidText;
+ const now = new Date();
+ const stamp = [
+ now.getFullYear(),
+ String(now.getMonth() + 1).padStart(2, "0"),
+ String(now.getDate()).padStart(2, "0")
+ ].join("");
+ const markdownText = `\`\`\`mermaid\n${mermaidText}\n\`\`\`\n`;
+ downloadBlob(new Blob([markdownText], { type: "text/markdown;charset=utf-8" }), `mermaid-${stamp}.md`);
+ setStatus("Mermaid Markdown を保存しました");
+ showToast("Mermaid Markdown を保存しました");
+ setActiveTab("output");
+ }
+ function downloadCurrentWbsMarkdown() {
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const defaultHolidayDates = mikuprojectWbsXlsx.collectWbsHolidayDates(model);
+ syncWbsHolidayDatesInput(model);
+ const displayDaysBeforeBaseDate = parseOptionalNonNegativeInteger(getInput("wbsDisplayDaysBeforeInput").value);
+ const displayDaysAfterBaseDate = parseOptionalNonNegativeInteger(getInput("wbsDisplayDaysAfterInput").value);
+ const useBusinessDaysForDisplayRange = useBusinessDaysForWbsDisplayRange();
+ const useBusinessDaysForProgressBand = useBusinessDaysForWbsProgressBand();
+ const markdownText = mikuprojectWbsMarkdown.exportWbsMarkdown(model, {
+ holidayDates: defaultHolidayDates,
+ displayDaysBeforeBaseDate,
+ displayDaysAfterBaseDate,
+ useBusinessDaysForDisplayRange,
+ useBusinessDaysForProgressBand
+ });
+ const now = new Date();
+ const stamp = [
+ now.getFullYear(),
+ String(now.getMonth() + 1).padStart(2, "0"),
+ String(now.getDate()).padStart(2, "0")
+ ].join("");
+ downloadBlob(new Blob([markdownText], { type: "text/markdown;charset=utf-8" }), `mikuproject-wbs-${stamp}.md`);
+ setStatus("WBS Markdown を保存しました");
+ showToast("WBS Markdown を保存しました");
+ setActiveTab("output");
+ }
+ function runRoundTripCheck() {
+ if (!currentModel) {
+ parseCurrentXml();
+ if (!currentModel) {
+ return;
+ }
+ }
+ const exportedXml = mikuprojectXml.exportMsProjectXml(currentModel);
+ const reparsedModel = mikuprojectXml.importMsProjectXml(exportedXml);
+ const validationIssues = mikuprojectXml.validateProjectModel(reparsedModel);
+ renderValidationIssues(validationIssues);
+ if (validationIssues.some((issue) => issue.level === "error")) {
+ throw new Error(validationIssues.map((issue) => issue.message).join("\n"));
+ }
+ const normalizedLeft = JSON.stringify(mikuprojectXml.normalizeProjectModel(currentModel));
+ const normalizedRight = JSON.stringify(mikuprojectXml.normalizeProjectModel(reparsedModel));
+ if (normalizedLeft !== normalizedRight) {
+ throw new Error("再読込後の内部モデルが一致しません");
+ }
+ setStatus("再読込テストに成功しました");
+ showToast("再読込テスト成功");
+ setActiveTab("transform");
+ }
+ function bindEvents() {
+ getElement("loadSampleBtn").addEventListener("click", loadSample);
+ getElement("importFileInput").addEventListener("click", (event) => {
+ const input = event.target;
+ if (input) {
+ input.value = "";
+ }
+ });
+ getElement("importFileBtn").addEventListener("click", () => {
+ const input = getElement("importFileInput");
+ input.value = "";
+ input.click();
+ });
+ getElement("downloadSvgBtn").addEventListener("click", () => {
+ void downloadCurrentSvg().catch((error) => {
+ setStatus(error instanceof Error ? error.message : "SVG 保存に失敗しました");
+ });
+ });
+ getElement("exportMermaidMdBtn").addEventListener("click", () => {
+ try {
+ downloadCurrentMermaidMarkdown();
+ }
+ catch (error) {
+ setStatus(error instanceof Error ? error.message : "Mermaid Markdown 保存に失敗しました");
+ }
+ });
+ getElement("exportCsvBtn").addEventListener("click", () => {
+ try {
+ exportCurrentCsv();
+ }
+ catch (error) {
+ setStatus(error instanceof Error ? error.message : "CSV 生成に失敗しました");
+ }
+ });
+ getElement("exportProjectOverviewBtn").addEventListener("click", () => {
+ try {
+ exportCurrentProjectOverviewView();
+ }
+ catch (error) {
+ setStatus(error instanceof Error ? error.message : "project_overview_view 生成に失敗しました");
+ }
+ });
+ getElement("exportAiBundleBtn").addEventListener("click", () => {
+ try {
+ exportCurrentAiProjectionBundle();
+ }
+ catch (error) {
+ setStatus(error instanceof Error ? error.message : "AI 連携用まとめ JSON 生成に失敗しました");
+ }
+ });
+ getElement("loadProjectDraftSampleBtn").addEventListener("click", loadProjectDraftSample);
+ getElement("copyAiPromptBtn").addEventListener("click", async () => {
+ try {
+ await copyAiPrompt();
+ }
+ catch (error) {
+ setStatus(error instanceof Error ? error.message : "生成AIプロンプトのコピーに失敗しました");
+ }
+ });
+ getElement("importProjectDraftBtn").addEventListener("click", async () => {
+ try {
+ await importProjectDraftFromText();
+ }
+ catch (error) {
+ setStatus(error instanceof Error ? error.message : "project_draft_view 取り込みに失敗しました");
+ }
+ });
+ getElement("exportPhaseDetailBtn").addEventListener("click", () => {
+ try {
+ exportCurrentPhaseDetailView("scoped");
+ }
+ catch (error) {
+ setStatus(error instanceof Error ? error.message : "phase_detail_view 生成に失敗しました");
+ }
+ });
+ getElement("exportPhaseDetailFullBtn").addEventListener("click", () => {
+ try {
+ exportCurrentPhaseDetailView("full");
+ }
+ catch (error) {
+ setStatus(error instanceof Error ? error.message : "phase_detail_view 生成に失敗しました");
+ }
+ });
+ getElement("exportXlsxBtn").addEventListener("click", () => {
+ try {
+ exportCurrentXlsx();
+ }
+ catch (error) {
+ setStatus(error instanceof Error ? error.message : "XLSX エクスポートに失敗しました");
+ }
+ });
+ getElement("exportWorkbookJsonBtn").addEventListener("click", () => {
+ try {
+ exportCurrentWorkbookJson();
+ }
+ catch (error) {
+ setStatus(error instanceof Error ? error.message : "JSON エクスポートに失敗しました");
+ }
+ });
+ getElement("exportWbsXlsxBtn").addEventListener("click", () => {
+ try {
+ exportCurrentWbsXlsx();
+ }
+ catch (error) {
+ setStatus(error instanceof Error ? error.message : "WBS XLSX エクスポートに失敗しました");
+ }
+ });
+ getElement("exportWbsMdBtn").addEventListener("click", () => {
+ try {
+ downloadCurrentWbsMarkdown();
+ }
+ catch (error) {
+ setStatus(error instanceof Error ? error.message : "WBS Markdown 保存に失敗しました");
+ }
+ });
+ getElement("downloadXmlBtn").addEventListener("click", () => {
+ try {
+ downloadCurrentXml();
+ }
+ catch (error) {
+ setStatus(error instanceof Error ? error.message : "XML 保存に失敗しました");
+ }
+ });
+ getElement("roundTripBtn").addEventListener("click", () => {
+ try {
+ runRoundTripCheck();
+ }
+ catch (error) {
+ setStatus(error instanceof Error ? error.message : "再読込テストに失敗しました");
+ }
+ });
+ getElement("importFileInput").addEventListener("change", async (event) => {
+ const input = event.target;
+ const file = (input === null || input === void 0 ? void 0 : input.files) && input.files[0];
+ if (file) {
+ setStatus(`${file.name} を読み込んでいます...`);
+ }
+ try {
+ await importFromFile(file);
+ }
+ catch (error) {
+ console.error("[mikuproject] file import failed", error);
+ setStatus(error instanceof Error ? error.message : "ファイル読込に失敗しました");
+ }
+ finally {
+ if (input) {
+ input.value = "";
+ }
+ }
+ });
+ getTextArea("xmlInput").addEventListener("input", () => {
+ isXmlSourceDirty = true;
+ refreshXmlSaveState();
+ });
+ }
+ function initialize() {
+ bindTabs();
+ bindEvents();
+ updateSummary(null);
+ renderValidationIssues([]);
+ renderImportWarnings([]);
+ renderXlsxImportSummary([]);
+ updateSvgButton();
+ loadSample();
+ }
+ globalThis.__mikuprojectMainTestHooks = {
+ parseCurrentXml,
+ exportCurrentMermaid,
+ renderValidationIssues,
+ renderXlsxImportSummary,
+ updateFeedbackVisibility
+ };
+ document.addEventListener("DOMContentLoaded", initialize);
+})();
diff --git a/src/js/markdown-escape.js b/src/js/markdown-escape.js
new file mode 100644
index 0000000..df16abe
--- /dev/null
+++ b/src/js/markdown-escape.js
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ function escapeMarkdownLineStart(text) {
+ return String(text || "")
+ .replace(/^(\s*)([#>])/u, "$1\\$2")
+ .replace(/^(\s*)([-+*])(\s+)/u, "$1\\$2$3")
+ .replace(/^(\s*)(\d+)\.(\s+)/u, "$1$2\\.$3");
+ }
+ function escapeMarkdownLiteralParts(text) {
+ const source = String(text || "");
+ const parts = [];
+ let buffer = "";
+ function pushTextBuffer() {
+ if (!buffer) {
+ return;
+ }
+ parts.push({ kind: "text", text: buffer, rawText: buffer });
+ buffer = "";
+ }
+ function pushEscaped(textValue, rawText) {
+ pushTextBuffer();
+ if (!textValue) {
+ return;
+ }
+ parts.push({ kind: "escaped", text: textValue, rawText });
+ }
+ for (let index = 0; index < source.length; index += 1) {
+ const ch = source[index];
+ const atLineStart = index === 0;
+ const next = source[index + 1] || "";
+ if (ch === "\\") {
+ pushEscaped("\\\\", ch);
+ continue;
+ }
+ if (ch === "&") {
+ pushEscaped("&", ch);
+ continue;
+ }
+ if (ch === "<") {
+ pushEscaped("<", ch);
+ continue;
+ }
+ if (ch === ">") {
+ pushEscaped(">", ch);
+ continue;
+ }
+ if (/[`*_{}\[\]()!|~]/.test(ch)) {
+ pushEscaped(`\\${ch}`, ch);
+ continue;
+ }
+ if (atLineStart && /[#]/.test(ch)) {
+ pushEscaped(`\\${ch}`, ch);
+ continue;
+ }
+ if (atLineStart && /[-+*]/.test(ch) && /\s/u.test(next)) {
+ pushEscaped(`\\${ch}`, ch);
+ continue;
+ }
+ if (atLineStart && /\d/u.test(ch)) {
+ let digitRun = ch;
+ let cursor = index + 1;
+ while (cursor < source.length && /\d/u.test(source[cursor])) {
+ digitRun += source[cursor];
+ cursor += 1;
+ }
+ if (source[cursor] === "." && /\s/u.test(source[cursor + 1] || "")) {
+ pushTextBuffer();
+ parts.push({ kind: "text", text: digitRun, rawText: digitRun });
+ parts.push({ kind: "escaped", text: "\\.", rawText: "." });
+ index = cursor;
+ continue;
+ }
+ }
+ buffer += ch;
+ }
+ pushTextBuffer();
+ return parts;
+ }
+ function escapeMarkdownLiteralText(text) {
+ return String(text || "")
+ .replace(/\r\n?/g, "\n")
+ .split("\n")
+ .map((line) => escapeMarkdownLiteralParts(line).map((part) => part.text).join(""))
+ .join("\n");
+ }
+ // Table cells need the normal literal escaping plus pipe escaping and
+ // line-break conversion because Markdown tables do not preserve raw newlines.
+ function escapeMarkdownTableCell(text) {
+ return escapeMarkdownLiteralText(text).replaceAll("|", "\\|").replaceAll("\n", " ");
+ }
+ globalThis.__mikuprojectMarkdownEscape = {
+ escapeMarkdownLineStart,
+ escapeMarkdownLiteralParts,
+ escapeMarkdownLiteralText,
+ escapeMarkdownTableCell
+ };
+})();
diff --git a/src/js/msproject-xml.js b/src/js/msproject-xml.js
new file mode 100644
index 0000000..836d6e2
--- /dev/null
+++ b/src/js/msproject-xml.js
@@ -0,0 +1,3568 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ const SAMPLE_PROJECT_DRAFT_VIEW = {
+ view_type: "project_draft_view",
+ project: {
+ name: "mikuproject開発",
+ planned_start: "2026-03-16",
+ planned_finish: "2026-04-01"
+ },
+ tasks: [
+ {
+ uid: "draft-100",
+ name: "基盤整備",
+ parent_uid: null,
+ position: 0,
+ is_summary: true,
+ percent_complete: 100,
+ planned_start: "2026-03-16",
+ planned_finish: "2026-03-17"
+ },
+ {
+ uid: "draft-110",
+ name: "着手",
+ parent_uid: "draft-100",
+ position: 0,
+ is_milestone: true,
+ percent_complete: 100,
+ planned_start: "2026-03-16",
+ planned_finish: "2026-03-16"
+ },
+ {
+ uid: "draft-120",
+ name: "初期実装(MS Project XML 調査・基軸フォーマット選定・内部モデルの概要確定)",
+ parent_uid: "draft-100",
+ position: 1,
+ percent_complete: 100,
+ planned_start: "2026-03-16",
+ planned_finish: "2026-03-16"
+ },
+ {
+ uid: "draft-130",
+ name: "round-trip拡張(MS Project XML → 内部JSON形式 → MS Project XML の往復対応)",
+ parent_uid: "draft-100",
+ position: 2,
+ percent_complete: 100,
+ planned_start: "2026-03-17",
+ planned_finish: "2026-03-17"
+ },
+ {
+ uid: "draft-150",
+ name: "架空検討フェーズ【架空】",
+ parent_uid: null,
+ position: 1,
+ is_summary: true,
+ percent_complete: 25,
+ planned_start: "2026-03-19",
+ planned_finish: "2026-03-25"
+ },
+ {
+ uid: "draft-160",
+ name: "ユーザー操作フローの見直し【架空】",
+ parent_uid: "draft-150",
+ position: 0,
+ percent_complete: 50,
+ planned_start: "2026-03-19",
+ planned_finish: "2026-03-23"
+ },
+ {
+ uid: "draft-170",
+ name: "画面構成の再整理【架空】",
+ parent_uid: "draft-150",
+ position: 1,
+ planned_start: "2026-03-24",
+ planned_finish: "2026-03-25"
+ },
+ {
+ uid: "draft-200",
+ name: "XLSX / UI 強化",
+ parent_uid: null,
+ position: 2,
+ is_summary: true,
+ planned_start: "2026-03-27",
+ planned_finish: "2026-03-28"
+ },
+ {
+ uid: "draft-210",
+ name: "GitHub リポジトリ独立化",
+ parent_uid: "draft-200",
+ position: 0,
+ is_milestone: true,
+ planned_start: "2026-03-27",
+ planned_finish: "2026-03-27"
+ },
+ {
+ uid: "draft-220",
+ name: "MS Project XML と XLSX の相互変換・round-trip実装",
+ parent_uid: "draft-200",
+ position: 1,
+ planned_start: "2026-03-27",
+ planned_finish: "2026-03-27"
+ },
+ {
+ uid: "draft-230",
+ name: "XLSXレイアウト再設計・再整理",
+ parent_uid: "draft-200",
+ position: 2,
+ planned_start: "2026-03-28",
+ planned_finish: "2026-03-28"
+ },
+ {
+ uid: "draft-300",
+ name: "リリース",
+ parent_uid: null,
+ position: 3,
+ is_summary: true,
+ planned_start: "2026-03-29",
+ planned_finish: "2026-03-29"
+ },
+ {
+ uid: "draft-310",
+ name: "v1.0 リリース",
+ parent_uid: "draft-300",
+ position: 0,
+ is_milestone: true,
+ planned_start: "2026-03-29",
+ planned_finish: "2026-03-29"
+ }
+ ]
+ };
+ const SAMPLE_XML = buildSampleXml();
+ /*
+
+ Sample Project Title
+ Local HTML Tools
+ Toshiki Iga
+ 2026-03-16T08:30:00
+ 2026-03-16T09:10:00
+ 14
+ 2026-03-16T09:00:00
+ 2026-03-16T09:00:00
+ 2026-03-31T18:00:00
+ 1
+ 09:00:00
+ 18:00:00
+ 480
+ 2400
+ 20
+ 2026-03-19T09:00:00
+ 1
+ 2
+ 7
+ JPY
+ 0
+ ¥
+ 0
+ 2026-04-01T00:00:00
+ 1
+ 0
+ 1
+ 2
+ 5000/h
+ 7000/h
+ 0
+ 0
+ 0
+ 1
+ 1
+ 0
+ 1
+ 1
+ 1
+ 0
+ 1
+ 0
+ 1
+
+
+ 188743731
+ Outline Code1
+ Phase
+ 1
+ 0
+ 0
+ 0
+ 0
+
+
+ 1
+ *
+ 0
+ 0
+
+
+
+
+ PLAN
+ Planning
+
+
+ BUILD
+ Implementation
+
+
+
+
+
+
+ 1
+ A
+ 1
+ 1
+
+
+ 2
+ 00
+ 2
+ 1
+
+
+
+
+ 188743734
+ Text1
+ Owner
+ 0
+ 0
+ 1
+
+
+
+
+ 1
+ Standard
+ 1
+ 1
+
+
+ 元日(公式)
+ 2026-01-01T00:00:00
+ 2026-01-01T23:59:59
+ 0
+
+
+ 成人の日(公式)
+ 2026-01-12T00:00:00
+ 2026-01-12T23:59:59
+ 0
+
+
+ 建国記念の日(公式)
+ 2026-02-11T00:00:00
+ 2026-02-11T23:59:59
+ 0
+
+
+ 天皇誕生日(公式)
+ 2026-02-23T00:00:00
+ 2026-02-23T23:59:59
+ 0
+
+
+ 春分の日(公式)
+ 2026-03-20T00:00:00
+ 2026-03-20T23:59:59
+ 0
+
+
+ 昭和の日(公式)
+ 2026-04-29T00:00:00
+ 2026-04-29T23:59:59
+ 0
+
+
+ 憲法記念日(公式)
+ 2026-05-03T00:00:00
+ 2026-05-03T23:59:59
+ 0
+
+
+ みどりの日(公式)
+ 2026-05-04T00:00:00
+ 2026-05-04T23:59:59
+ 0
+
+
+ こどもの日(公式)
+ 2026-05-05T00:00:00
+ 2026-05-05T23:59:59
+ 0
+
+
+ 休日(公式)
+ 2026-05-06T00:00:00
+ 2026-05-06T23:59:59
+ 0
+
+
+ 海の日(公式)
+ 2026-07-20T00:00:00
+ 2026-07-20T23:59:59
+ 0
+
+
+ 山の日(公式)
+ 2026-08-11T00:00:00
+ 2026-08-11T23:59:59
+ 0
+
+
+ 敬老の日(公式)
+ 2026-09-21T00:00:00
+ 2026-09-21T23:59:59
+ 0
+
+
+ 休日(公式)
+ 2026-09-22T00:00:00
+ 2026-09-22T23:59:59
+ 0
+
+
+ 秋分の日(公式)
+ 2026-09-23T00:00:00
+ 2026-09-23T23:59:59
+ 0
+
+
+ スポーツの日(公式)
+ 2026-10-12T00:00:00
+ 2026-10-12T23:59:59
+ 0
+
+
+ 文化の日(公式)
+ 2026-11-03T00:00:00
+ 2026-11-03T23:59:59
+ 0
+
+
+ 勤労感謝の日(公式)
+ 2026-11-23T00:00:00
+ 2026-11-23T23:59:59
+ 0
+
+
+ 元日(公式)
+ 2027-01-01T00:00:00
+ 2027-01-01T23:59:59
+ 0
+
+
+ 成人の日(公式)
+ 2027-01-11T00:00:00
+ 2027-01-11T23:59:59
+ 0
+
+
+ 建国記念の日(公式)
+ 2027-02-11T00:00:00
+ 2027-02-11T23:59:59
+ 0
+
+
+ 天皇誕生日(公式)
+ 2027-02-23T00:00:00
+ 2027-02-23T23:59:59
+ 0
+
+
+ 春分の日(公式)
+ 2027-03-21T00:00:00
+ 2027-03-21T23:59:59
+ 0
+
+
+ 休日(公式)
+ 2027-03-22T00:00:00
+ 2027-03-22T23:59:59
+ 0
+
+
+ 昭和の日(公式)
+ 2027-04-29T00:00:00
+ 2027-04-29T23:59:59
+ 0
+
+
+ 憲法記念日(公式)
+ 2027-05-03T00:00:00
+ 2027-05-03T23:59:59
+ 0
+
+
+ みどりの日(公式)
+ 2027-05-04T00:00:00
+ 2027-05-04T23:59:59
+ 0
+
+
+ こどもの日(公式)
+ 2027-05-05T00:00:00
+ 2027-05-05T23:59:59
+ 0
+
+
+ 海の日(公式)
+ 2027-07-19T00:00:00
+ 2027-07-19T23:59:59
+ 0
+
+
+ 山の日(公式)
+ 2027-08-11T00:00:00
+ 2027-08-11T23:59:59
+ 0
+
+
+ 敬老の日(公式)
+ 2027-09-20T00:00:00
+ 2027-09-20T23:59:59
+ 0
+
+
+ 秋分の日(公式)
+ 2027-09-23T00:00:00
+ 2027-09-23T23:59:59
+ 0
+
+
+ スポーツの日(公式)
+ 2027-10-11T00:00:00
+ 2027-10-11T23:59:59
+ 0
+
+
+ 文化の日(公式)
+ 2027-11-03T00:00:00
+ 2027-11-03T23:59:59
+ 0
+
+
+ 勤労感謝の日(公式)
+ 2027-11-23T00:00:00
+ 2027-11-23T23:59:59
+ 0
+
+
+ 元日(推定)
+ 2028-01-01T00:00:00
+ 2028-01-01T23:59:59
+ 0
+
+
+ 成人の日(推定)
+ 2028-01-10T00:00:00
+ 2028-01-10T23:59:59
+ 0
+
+
+ 建国記念の日(推定)
+ 2028-02-11T00:00:00
+ 2028-02-11T23:59:59
+ 0
+
+
+ 天皇誕生日(推定)
+ 2028-02-23T00:00:00
+ 2028-02-23T23:59:59
+ 0
+
+
+ 春分の日(推定)
+ 2028-03-20T00:00:00
+ 2028-03-20T23:59:59
+ 0
+
+
+ 昭和の日(推定)
+ 2028-04-29T00:00:00
+ 2028-04-29T23:59:59
+ 0
+
+
+ 憲法記念日(推定)
+ 2028-05-03T00:00:00
+ 2028-05-03T23:59:59
+ 0
+
+
+ みどりの日(推定)
+ 2028-05-04T00:00:00
+ 2028-05-04T23:59:59
+ 0
+
+
+ こどもの日(推定)
+ 2028-05-05T00:00:00
+ 2028-05-05T23:59:59
+ 0
+
+
+ 海の日(推定)
+ 2028-07-17T00:00:00
+ 2028-07-17T23:59:59
+ 0
+
+
+ 山の日(推定)
+ 2028-08-11T00:00:00
+ 2028-08-11T23:59:59
+ 0
+
+
+ 敬老の日(推定)
+ 2028-09-18T00:00:00
+ 2028-09-18T23:59:59
+ 0
+
+
+ 秋分の日(推定)
+ 2028-09-22T00:00:00
+ 2028-09-22T23:59:59
+ 0
+
+
+ スポーツの日(推定)
+ 2028-10-09T00:00:00
+ 2028-10-09T23:59:59
+ 0
+
+
+ 文化の日(推定)
+ 2028-11-03T00:00:00
+ 2028-11-03T23:59:59
+ 0
+
+
+ 勤労感謝の日(推定)
+ 2028-11-23T00:00:00
+ 2028-11-23T23:59:59
+ 0
+
+
+ 元日(推定)
+ 2029-01-01T00:00:00
+ 2029-01-01T23:59:59
+ 0
+
+
+ 成人の日(推定)
+ 2029-01-08T00:00:00
+ 2029-01-08T23:59:59
+ 0
+
+
+ 建国記念の日(推定)
+ 2029-02-11T00:00:00
+ 2029-02-11T23:59:59
+ 0
+
+
+ 休日(推定)
+ 2029-02-12T00:00:00
+ 2029-02-12T23:59:59
+ 0
+
+
+ 天皇誕生日(推定)
+ 2029-02-23T00:00:00
+ 2029-02-23T23:59:59
+ 0
+
+
+ 春分の日(推定)
+ 2029-03-20T00:00:00
+ 2029-03-20T23:59:59
+ 0
+
+
+ 昭和の日(推定)
+ 2029-04-29T00:00:00
+ 2029-04-29T23:59:59
+ 0
+
+
+ 休日(推定)
+ 2029-04-30T00:00:00
+ 2029-04-30T23:59:59
+ 0
+
+
+ 憲法記念日(推定)
+ 2029-05-03T00:00:00
+ 2029-05-03T23:59:59
+ 0
+
+
+ みどりの日(推定)
+ 2029-05-04T00:00:00
+ 2029-05-04T23:59:59
+ 0
+
+
+ こどもの日(推定)
+ 2029-05-05T00:00:00
+ 2029-05-05T23:59:59
+ 0
+
+
+ 海の日(推定)
+ 2029-07-16T00:00:00
+ 2029-07-16T23:59:59
+ 0
+
+
+ 山の日(推定)
+ 2029-08-11T00:00:00
+ 2029-08-11T23:59:59
+ 0
+
+
+ 敬老の日(推定)
+ 2029-09-17T00:00:00
+ 2029-09-17T23:59:59
+ 0
+
+
+ 秋分の日(推定)
+ 2029-09-23T00:00:00
+ 2029-09-23T23:59:59
+ 0
+
+
+ 休日(推定)
+ 2029-09-24T00:00:00
+ 2029-09-24T23:59:59
+ 0
+
+
+ スポーツの日(推定)
+ 2029-10-08T00:00:00
+ 2029-10-08T23:59:59
+ 0
+
+
+ 文化の日(推定)
+ 2029-11-03T00:00:00
+ 2029-11-03T23:59:59
+ 0
+
+
+ 勤労感謝の日(推定)
+ 2029-11-23T00:00:00
+ 2029-11-23T23:59:59
+ 0
+
+
+ 元日(推定)
+ 2030-01-01T00:00:00
+ 2030-01-01T23:59:59
+ 0
+
+
+ 成人の日(推定)
+ 2030-01-14T00:00:00
+ 2030-01-14T23:59:59
+ 0
+
+
+ 建国記念の日(推定)
+ 2030-02-11T00:00:00
+ 2030-02-11T23:59:59
+ 0
+
+
+ 天皇誕生日(推定)
+ 2030-02-23T00:00:00
+ 2030-02-23T23:59:59
+ 0
+
+
+ 春分の日(推定)
+ 2030-03-20T00:00:00
+ 2030-03-20T23:59:59
+ 0
+
+
+ 昭和の日(推定)
+ 2030-04-29T00:00:00
+ 2030-04-29T23:59:59
+ 0
+
+
+ 憲法記念日(推定)
+ 2030-05-03T00:00:00
+ 2030-05-03T23:59:59
+ 0
+
+
+ みどりの日(推定)
+ 2030-05-04T00:00:00
+ 2030-05-04T23:59:59
+ 0
+
+
+ こどもの日(推定)
+ 2030-05-05T00:00:00
+ 2030-05-05T23:59:59
+ 0
+
+
+ 休日(推定)
+ 2030-05-06T00:00:00
+ 2030-05-06T23:59:59
+ 0
+
+
+ 海の日(推定)
+ 2030-07-15T00:00:00
+ 2030-07-15T23:59:59
+ 0
+
+
+ 山の日(推定)
+ 2030-08-11T00:00:00
+ 2030-08-11T23:59:59
+ 0
+
+
+ 休日(推定)
+ 2030-08-12T00:00:00
+ 2030-08-12T23:59:59
+ 0
+
+
+ 敬老の日(推定)
+ 2030-09-16T00:00:00
+ 2030-09-16T23:59:59
+ 0
+
+
+ 秋分の日(推定)
+ 2030-09-23T00:00:00
+ 2030-09-23T23:59:59
+ 0
+
+
+ スポーツの日(推定)
+ 2030-10-14T00:00:00
+ 2030-10-14T23:59:59
+ 0
+
+
+ 文化の日(推定)
+ 2030-11-03T00:00:00
+ 2030-11-03T23:59:59
+ 0
+
+
+ 休日(推定)
+ 2030-11-04T00:00:00
+ 2030-11-04T23:59:59
+ 0
+
+
+ 勤労感謝の日(推定)
+ 2030-11-23T00:00:00
+ 2030-11-23T23:59:59
+ 0
+
+
+
+
+ 2
+ 1
+
+
+ 09:00:00
+ 12:00:00
+
+
+ 13:00:00
+ 18:00:00
+
+
+
+
+
+
+ 2
+ Development
+ 0
+ 1
+
+
+ Spring Sprint
+ 2026-03-16T00:00:00
+ 2026-03-31T23:59:59
+
+
+ 2
+ 1
+
+
+ 10:00:00
+ 18:00:00
+
+
+
+
+
+
+
+
+ 6
+ 1
+
+
+ 10:00:00
+ 15:00:00
+
+
+
+
+
+
+
+
+ 1
+ 1
+ Project Summary
+ 1
+ 1
+ 1
+ 1
+ 1
+ 500
+ 2026-03-16T09:00:00
+ 2026-03-20T18:00:00
+ PT40H0M0S
+ PT0H0M0S
+ PT0H0M0S
+ PT40H0M0S
+ PT0H0M0S
+ PT0H0M0S
+ PT0H0M0S
+ 200000
+ 100000
+ 100000
+ PT20H0M0S
+ PT20H0M0S
+ 0
+ 1
+ 0
+ 50
+ 50
+
+
+ 2
+ 2
+ Design
+ 2
+ 1.1
+ 1.1
+ 1
+ 1
+ 500
+ 2026-03-16T09:00:00
+ 2026-03-17T18:00:00
+ PT16H0M0S
+ 2026-03-16T09:00:00
+ 2026-03-17T18:00:00
+ PT0H0M0S
+ PT0H0M0S
+ PT16H0M0S
+ PT0H0M0S
+ PT0H0M0S
+ PT0H0M0S
+ 80000
+ 80000
+ 0
+ PT0H0M0S
+ PT16H0M0S
+ 0
+ 0
+ 0
+ 100
+ 100
+ Design completed
+
+ 188743734
+ Miku
+
+
+ 0
+ 2026-03-16T09:00:00
+ 2026-03-17T18:00:00
+ PT16H0M0S
+ 80000
+
+
+ 1
+ 2
+ 2026-03-16T09:00:00
+ 2026-03-16T18:00:00
+ 2
+ PT8H0M0S
+
+
+
+ 3
+ 3
+ Implementation
+ 2
+ 1.2
+ 1.2
+ 1
+ 1
+ 700
+ 2026-03-18T09:00:00
+ 2026-03-20T18:00:00
+ PT24H0M0S
+ 2026-03-21T18:00:00
+ PT0H0M0S
+ PT0H0M0S
+ PT24H0M0S
+ PT0H0M0S
+ PT4H0M0S
+ PT2H0M0S
+ 120000
+ 0
+ 120000
+ PT24H0M0S
+ PT0H0M0S
+ 4
+ 2026-03-18T09:00:00
+ 0
+ 0
+ 1
+ 0
+ 0
+ Implementation starts after design
+
+ 2
+ 1
+ PT0H0M0S
+
+
+
+
+
+ 1
+ 1
+ Miku
+ 1
+ MK
+ Engineering
+ 0
+ 1
+ 2
+ 5000/h
+ 2
+ 7000/h
+ 2
+ 1000
+ PT40H0M0S
+ PT20H0M0S
+ PT20H0M0S
+ 200000
+ 100000
+ 100000
+ 50
+
+ 188743737
+ Platform Team
+
+
+ 0
+ 2026-03-16T09:00:00
+ 2026-03-20T18:00:00
+ PT40H0M0S
+ 200000
+
+
+ 1
+ 1
+ 2026-03-16T09:00:00
+ 2026-03-16T18:00:00
+ 2
+ PT8H0M0S
+
+
+
+
+
+ 1
+ 2
+ 1
+ 2026-03-16T09:00:00
+ 2026-03-17T18:00:00
+ PT0H0M0S
+ PT0H0M0S
+ PT0H0M0S
+ 0
+ 0
+ 1
+ PT16H0M0S
+ 80000
+ 40000
+ 40000
+ 50
+ PT2H0M0S
+ PT1H0M0S
+ PT8H0M0S
+ PT8H0M0S
+
+ 255852547
+ Design Slot
+
+
+ 0
+ 2026-03-16T09:00:00
+ 2026-03-17T18:00:00
+ PT16H0M0S
+ 80000
+
+
+ 1
+ 1
+ 2026-03-16T09:00:00
+ 2026-03-16T18:00:00
+ 2
+ PT8H0M0S
+
+
+
+ 2
+ 3
+ 1
+ 2026-03-18T09:00:00
+ 2026-03-20T18:00:00
+ PT0H0M0S
+ PT0H0M0S
+ PT0H0M0S
+ 0
+ 0
+ 1
+ PT24H0M0S
+ 120000
+ 0
+ 120000
+ 0
+ PT0H0M0S
+ PT0H0M0S
+ PT0H0M0S
+ PT24H0M0S
+
+
+
+
+ */
+ function buildSampleXml() {
+ const sampleModel = importProjectDraftView(SAMPLE_PROJECT_DRAFT_VIEW);
+ sampleModel.project.currentDate = "2026-03-23T09:00:00";
+ sampleModel.project.statusDate = "2026-03-23T09:00:00";
+ return exportMsProjectXml(sampleModel);
+ }
+ function textContent(parent, tagName) {
+ const element = parent.getElementsByTagName(tagName)[0];
+ return String((element === null || element === void 0 ? void 0 : element.textContent) || "").trim();
+ }
+ function parseBoolean(value) {
+ return value === "1" || value.toLowerCase() === "true";
+ }
+ function parseNumber(value, defaultValue = 0) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : defaultValue;
+ }
+ function parseDateValue(value) {
+ if (!value) {
+ return null;
+ }
+ const timestamp = Date.parse(value);
+ return Number.isFinite(timestamp) ? timestamp : null;
+ }
+ function parseDateOnly(value) {
+ const text = String(value || "").trim().slice(0, 10);
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) {
+ return null;
+ }
+ const parsed = new Date(`${text}T00:00:00`);
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+ }
+ function formatDateOnly(value) {
+ const year = value.getFullYear();
+ const month = String(value.getMonth() + 1).padStart(2, "0");
+ const day = String(value.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+ }
+ function addDateDays(base, days) {
+ const next = new Date(base.getTime());
+ next.setDate(next.getDate() + days);
+ return next;
+ }
+ function addDateYears(base, years) {
+ const next = new Date(base.getTime());
+ next.setFullYear(next.getFullYear() + years);
+ return next;
+ }
+ function toMsDayType(value) {
+ const day = value.getDay();
+ return day === 0 ? 1 : day + 1;
+ }
+ function buildNthWeekdayOfMonth(year, monthIndex, jsWeekday, nth) {
+ const first = new Date(year, monthIndex, 1);
+ const offset = (jsWeekday - first.getDay() + 7) % 7;
+ return new Date(year, monthIndex, 1 + offset + ((nth - 1) * 7));
+ }
+ function calculateVernalEquinoxDay(year) {
+ return Math.floor(20.8431 + (0.242194 * (year - 1980)) - Math.floor((year - 1980) / 4));
+ }
+ function calculateAutumnalEquinoxDay(year) {
+ return Math.floor(23.2488 + (0.242194 * (year - 1980)) - Math.floor((year - 1980) / 4));
+ }
+ function buildJapaneseHolidayMapForYear(year) {
+ const holidays = new Map();
+ const addHoliday = (date, name) => {
+ holidays.set(formatDateOnly(date), name);
+ };
+ addHoliday(new Date(year, 0, 1), "元日");
+ addHoliday(buildNthWeekdayOfMonth(year, 0, 1, 2), "成人の日");
+ addHoliday(new Date(year, 1, 11), "建国記念の日");
+ addHoliday(new Date(year, 1, 23), "天皇誕生日");
+ addHoliday(new Date(year, 2, calculateVernalEquinoxDay(year)), "春分の日");
+ addHoliday(new Date(year, 3, 29), "昭和の日");
+ addHoliday(new Date(year, 4, 3), "憲法記念日");
+ addHoliday(new Date(year, 4, 4), "みどりの日");
+ addHoliday(new Date(year, 4, 5), "こどもの日");
+ addHoliday(buildNthWeekdayOfMonth(year, 6, 1, 3), "海の日");
+ addHoliday(new Date(year, 7, 11), "山の日");
+ addHoliday(buildNthWeekdayOfMonth(year, 8, 1, 3), "敬老の日");
+ addHoliday(new Date(year, 8, calculateAutumnalEquinoxDay(year)), "秋分の日");
+ addHoliday(buildNthWeekdayOfMonth(year, 9, 1, 2), "スポーツの日");
+ addHoliday(new Date(year, 10, 3), "文化の日");
+ addHoliday(new Date(year, 10, 23), "勤労感謝の日");
+ const baseHolidayDates = Array.from(holidays.keys()).sort();
+ for (const dateText of baseHolidayDates) {
+ const date = parseDateOnly(dateText);
+ if (!date || date.getDay() !== 0) {
+ continue;
+ }
+ let substitute = addDateDays(date, 1);
+ while (holidays.has(formatDateOnly(substitute))) {
+ substitute = addDateDays(substitute, 1);
+ }
+ holidays.set(formatDateOnly(substitute), "休日");
+ }
+ const sortedDates = Array.from(holidays.keys()).sort();
+ for (let index = 0; index < sortedDates.length - 1; index += 1) {
+ const current = parseDateOnly(sortedDates[index]);
+ const next = parseDateOnly(sortedDates[index + 1]);
+ if (!current || !next) {
+ continue;
+ }
+ const gapDays = Math.floor((next.getTime() - current.getTime()) / 86400000);
+ if (gapDays !== 2) {
+ continue;
+ }
+ const between = addDateDays(current, 1);
+ const betweenText = formatDateOnly(between);
+ if (holidays.has(betweenText) || between.getDay() === 0) {
+ continue;
+ }
+ holidays.set(betweenText, "休日");
+ }
+ return new Map(Array.from(holidays.entries()).sort((left, right) => left[0].localeCompare(right[0])));
+ }
+ function buildDefaultWorkingTimes(project) {
+ const start = project.defaultStartTime || "09:00:00";
+ const finish = project.defaultFinishTime || "18:00:00";
+ if (start < "12:00:00" && finish > "13:00:00") {
+ return [
+ { fromTime: start, toTime: "12:00:00" },
+ { fromTime: "13:00:00", toTime: finish }
+ ];
+ }
+ return [{ fromTime: start, toTime: finish }];
+ }
+ function buildDefaultStandardWeekDays(project) {
+ const workingTimes = buildDefaultWorkingTimes(project);
+ return Array.from({ length: 7 }, (_, index) => {
+ const dayType = index + 1;
+ const dayWorking = dayType !== 1 && dayType !== 7;
+ return {
+ dayType,
+ dayWorking,
+ workingTimes: dayWorking ? workingTimes.map((item) => ({ ...item })) : []
+ };
+ });
+ }
+ function buildDefaultJapaneseHolidayExceptions(project) {
+ const start = parseDateOnly(project.startDate) || parseDateOnly(project.finishDate) || new Date();
+ const finishLimit = parseDateOnly(project.finishDate) || start;
+ const rangeStart = start.getTime() <= finishLimit.getTime() ? start : finishLimit;
+ const rangeFinish = start.getTime() <= finishLimit.getTime() ? finishLimit : start;
+ const exceptions = [];
+ for (let year = rangeStart.getFullYear(); year <= rangeFinish.getFullYear(); year += 1) {
+ const holidays = buildJapaneseHolidayMapForYear(year);
+ for (const [dateText, name] of holidays.entries()) {
+ const date = parseDateOnly(dateText);
+ if (!date || date.getTime() < rangeStart.getTime() || date.getTime() > rangeFinish.getTime()) {
+ continue;
+ }
+ exceptions.push({
+ name,
+ fromDate: `${dateText}T00:00:00`,
+ toDate: `${dateText}T23:59:59`,
+ dayWorking: false,
+ workingTimes: []
+ });
+ }
+ }
+ return exceptions;
+ }
+ function findFallbackCalendarUid(model) {
+ var _a;
+ const baseCalendar = model.calendars.find((calendar) => calendar.isBaseCalendar);
+ return (baseCalendar === null || baseCalendar === void 0 ? void 0 : baseCalendar.uid) || ((_a = model.calendars[0]) === null || _a === void 0 ? void 0 : _a.uid);
+ }
+ function allocateDefaultCalendarUid(model) {
+ const usedUids = new Set(model.calendars.map((calendar) => String(calendar.uid)));
+ let candidate = 1;
+ while (usedUids.has(String(candidate))) {
+ candidate += 1;
+ }
+ return String(candidate);
+ }
+ function ensureDefaultProjectCalendar(model) {
+ if (model.calendars.length === 0) {
+ const uid = allocateDefaultCalendarUid(model);
+ model.calendars.push({
+ uid,
+ name: "Standard",
+ isBaseCalendar: true,
+ isBaselineCalendar: true,
+ weekDays: buildDefaultStandardWeekDays(model.project),
+ exceptions: buildDefaultJapaneseHolidayExceptions(model.project),
+ workWeeks: []
+ });
+ model.project.calendarUID = uid;
+ }
+ return model;
+ }
+ function parseWeekDays(parent) {
+ var _a;
+ return Array.from(((_a = parent.getElementsByTagName("WeekDays")[0]) === null || _a === void 0 ? void 0 : _a.getElementsByTagName("WeekDay")) || []).map((weekDay) => {
+ var _a;
+ return ({
+ dayType: parseNumber(textContent(weekDay, "DayType"), 0),
+ dayWorking: parseBoolean(textContent(weekDay, "DayWorking")),
+ workingTimes: Array.from(((_a = weekDay.getElementsByTagName("WorkingTimes")[0]) === null || _a === void 0 ? void 0 : _a.getElementsByTagName("WorkingTime")) || []).map((workingTime) => ({
+ fromTime: textContent(workingTime, "FromTime"),
+ toTime: textContent(workingTime, "ToTime")
+ }))
+ });
+ });
+ }
+ function appendWeekDays(doc, parent, weekDays) {
+ if (weekDays.length === 0) {
+ return;
+ }
+ const weekDaysElement = doc.createElement("WeekDays");
+ for (const weekDay of weekDays) {
+ const weekDayElement = doc.createElement("WeekDay");
+ appendTextElement(doc, weekDayElement, "DayType", weekDay.dayType);
+ appendTextElement(doc, weekDayElement, "DayWorking", weekDay.dayWorking);
+ if (weekDay.workingTimes.length > 0) {
+ const workingTimesElement = doc.createElement("WorkingTimes");
+ for (const workingTime of weekDay.workingTimes) {
+ const workingTimeElement = doc.createElement("WorkingTime");
+ appendTextElement(doc, workingTimeElement, "FromTime", workingTime.fromTime);
+ appendTextElement(doc, workingTimeElement, "ToTime", workingTime.toTime);
+ workingTimesElement.appendChild(workingTimeElement);
+ }
+ weekDayElement.appendChild(workingTimesElement);
+ }
+ weekDaysElement.appendChild(weekDayElement);
+ }
+ parent.appendChild(weekDaysElement);
+ }
+ function parseWorkingTimes(parent) {
+ var _a;
+ return Array.from(((_a = parent.getElementsByTagName("WorkingTimes")[0]) === null || _a === void 0 ? void 0 : _a.getElementsByTagName("WorkingTime")) || []).map((workingTime) => ({
+ fromTime: textContent(workingTime, "FromTime"),
+ toTime: textContent(workingTime, "ToTime")
+ }));
+ }
+ function appendWorkingTimes(doc, parent, workingTimes) {
+ if (workingTimes.length === 0) {
+ return;
+ }
+ const workingTimesElement = doc.createElement("WorkingTimes");
+ for (const workingTime of workingTimes) {
+ const workingTimeElement = doc.createElement("WorkingTime");
+ appendTextElement(doc, workingTimeElement, "FromTime", workingTime.fromTime);
+ appendTextElement(doc, workingTimeElement, "ToTime", workingTime.toTime);
+ workingTimesElement.appendChild(workingTimeElement);
+ }
+ parent.appendChild(workingTimesElement);
+ }
+ function parseOutlineCodeMasks(parent) {
+ const masksElement = parent.getElementsByTagName("Masks")[0];
+ if (!masksElement) {
+ return [];
+ }
+ return Array.from(masksElement.children)
+ .filter((child) => child.tagName === "Mask")
+ .map((mask) => ({
+ level: parseNumber(textContent(mask, "Level"), 0),
+ mask: textContent(mask, "Mask") || undefined,
+ length: textContent(mask, "Length") ? parseNumber(textContent(mask, "Length"), 0) : undefined,
+ sequence: textContent(mask, "Sequence") ? parseNumber(textContent(mask, "Sequence"), 0) : undefined
+ }));
+ }
+ function parseOutlineCodeValues(parent) {
+ const valuesElement = parent.getElementsByTagName("Values")[0];
+ if (!valuesElement) {
+ return [];
+ }
+ return Array.from(valuesElement.children)
+ .filter((child) => child.tagName === "Value")
+ .map((value) => ({
+ value: textContent(value, "Value"),
+ description: textContent(value, "Description") || undefined
+ }));
+ }
+ function isPlaceholderUid(value) {
+ return String(value || "").trim() === "0";
+ }
+ function isUnassignedResourceUid(value) {
+ return String(value || "").trim() === "-65535";
+ }
+ function describeTask(task) {
+ return `UID=${task.uid}${task.name ? ` (${task.name})` : ""}`;
+ }
+ function isComparableOutlineNumber(value) {
+ if (!value) {
+ return false;
+ }
+ return value.split(".").every((part) => /^\d+$/.test(part));
+ }
+ function compareOutlineNumbers(left, right) {
+ if (!left || !right) {
+ return 0;
+ }
+ const leftParts = left.split(".").map((part) => Number(part));
+ const rightParts = right.split(".").map((part) => Number(part));
+ const maxLength = Math.max(leftParts.length, rightParts.length);
+ for (let index = 0; index < maxLength; index += 1) {
+ const leftPart = leftParts[index];
+ const rightPart = rightParts[index];
+ if (leftPart === undefined) {
+ return -1;
+ }
+ if (rightPart === undefined) {
+ return 1;
+ }
+ if (leftPart !== rightPart) {
+ return leftPart - rightPart;
+ }
+ }
+ return 0;
+ }
+ function detectTaskOrderIssue(tasks) {
+ let previousComparableTask = null;
+ for (const task of tasks) {
+ if (isPlaceholderUid(task.uid)) {
+ continue;
+ }
+ if (!isComparableOutlineNumber(task.outlineNumber)) {
+ continue;
+ }
+ if (previousComparableTask && compareOutlineNumbers(previousComparableTask.outlineNumber, task.outlineNumber) >= 0) {
+ return {
+ previous: previousComparableTask,
+ current: task
+ };
+ }
+ previousComparableTask = task;
+ }
+ return null;
+ }
+ function describeResource(resource) {
+ return `UID=${resource.uid || "(なし)"}${resource.name ? ` (${resource.name})` : ""}`;
+ }
+ function describeCalendar(calendar) {
+ return `UID=${calendar.uid}${calendar.name ? ` (${calendar.name})` : ""}`;
+ }
+ function describeAssignment(assignment) {
+ return `UID=${assignment.uid || "(なし)"}`;
+ }
+ function describeTaskRef(model, taskUid) {
+ if (!taskUid) {
+ return "TaskUID=(なし)";
+ }
+ const task = model.tasks.find((item) => item.uid === taskUid);
+ return task ? `TaskUID=${taskUid}${task.name ? ` (${task.name})` : ""}` : `TaskUID=${taskUid}`;
+ }
+ function describeResourceRef(model, resourceUid) {
+ if (!resourceUid) {
+ return "ResourceUID=(なし)";
+ }
+ const resource = model.resources.find((item) => item.uid === resourceUid);
+ return resource ? `ResourceUID=${resourceUid}${resource.name ? ` (${resource.name})` : ""}` : `ResourceUID=${resourceUid}`;
+ }
+ function parseXmlDocument(xmlText) {
+ const parser = new DOMParser();
+ const xml = parser.parseFromString(xmlText, "application/xml");
+ const parserError = xml.getElementsByTagName("parsererror")[0];
+ if (parserError) {
+ throw new Error("XML の解析に失敗しました");
+ }
+ return xml;
+ }
+ function normalizeMermaidText(value, fallback) {
+ const text = String(value || fallback).replace(/[::#,,]/g, " ").replace(/\s+/g, " ").trim();
+ return text || fallback;
+ }
+ function normalizeMermaidGanttLabel(value, fallback, leadingPrefix) {
+ const text = normalizeMermaidText(value, fallback);
+ return /^\d/.test(text) ? `${leadingPrefix} ${text}` : text;
+ }
+ function normalizeMermaidTaskId(value, fallback) {
+ return String(value || fallback).replace(/[^A-Za-z0-9_]/g, "_");
+ }
+ function toMermaidDuration(duration) {
+ const text = String(duration || "").trim();
+ if (!text) {
+ return null;
+ }
+ const match = /^P(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)$/.exec(text);
+ if (!match) {
+ return null;
+ }
+ const hours = Number(match[1] || 0);
+ const minutes = Number(match[2] || 0);
+ const seconds = Number(match[3] || 0);
+ const parts = [];
+ if (hours > 0) {
+ parts.push(`${hours}h`);
+ }
+ if (minutes > 0) {
+ parts.push(`${minutes}m`);
+ }
+ if (seconds > 0) {
+ parts.push(`${seconds}s`);
+ }
+ return parts.length > 0 ? parts.join(" ") : null;
+ }
+ function formatMermaidLag(duration) {
+ const short = toMermaidDuration(duration);
+ if (short) {
+ return short;
+ }
+ return String(duration || "").trim();
+ }
+ function isZeroDuration(duration) {
+ const text = String(duration || "").trim();
+ return text === "" || text === "PT0H0M0S" || text === "PT0M0S" || text === "PT0S";
+ }
+ function describePredecessorType(type) {
+ if (type === undefined) {
+ return "default";
+ }
+ const typeMap = {
+ 0: "FF",
+ 1: "FS",
+ 2: "FF",
+ 3: "SF",
+ 4: "SS"
+ };
+ return typeMap[type] || `type=${type}`;
+ }
+ function formatDependencyType(type) {
+ if (type === undefined) {
+ return "FS";
+ }
+ const typeMap = {
+ 0: "FF",
+ 1: "FS",
+ 2: "FF",
+ 3: "SF",
+ 4: "SS"
+ };
+ return typeMap[type] || String(type);
+ }
+ function parseDurationHours(duration) {
+ const text = String(duration || "").trim();
+ if (!text) {
+ return undefined;
+ }
+ const match = /^(-)?P(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)$/.exec(text);
+ if (!match) {
+ return undefined;
+ }
+ const sign = match[1] ? -1 : 1;
+ const hours = Number(match[2] || 0);
+ const minutes = Number(match[3] || 0);
+ const seconds = Number(match[4] || 0);
+ return sign * (hours + minutes / 60 + seconds / 3600);
+ }
+ function buildTaskParentMap(tasks) {
+ const parentMap = new Map();
+ const stack = [];
+ for (const task of tasks) {
+ while (stack.length > 0 && task.outlineLevel <= stack[stack.length - 1].outlineLevel) {
+ stack.pop();
+ }
+ parentMap.set(task.uid, stack.length > 0 ? stack[stack.length - 1].uid : null);
+ if (task.summary) {
+ stack.push(task);
+ }
+ }
+ return parentMap;
+ }
+ function buildTaskPositionMap(tasks, parentMap) {
+ const counters = new Map();
+ const positionMap = new Map();
+ for (const task of tasks) {
+ const parentUid = parentMap.get(task.uid) || "__root__";
+ const position = counters.get(parentUid) || 0;
+ positionMap.set(task.uid, position);
+ counters.set(parentUid, position + 1);
+ }
+ return positionMap;
+ }
+ function collectTopLevelPhases(tasks) {
+ return tasks.filter((task) => !isPlaceholderUid(task.uid) && task.summary && task.outlineLevel === 1);
+ }
+ function collectPhaseTaskUids(tasks, phaseUid) {
+ const phaseIndex = tasks.findIndex((task) => task.uid === phaseUid);
+ if (phaseIndex < 0) {
+ return new Set();
+ }
+ const phase = tasks[phaseIndex];
+ const uids = new Set();
+ for (let index = phaseIndex + 1; index < tasks.length; index += 1) {
+ const task = tasks[index];
+ if (task.outlineLevel <= phase.outlineLevel) {
+ break;
+ }
+ if (!isPlaceholderUid(task.uid)) {
+ uids.add(task.uid);
+ }
+ }
+ return uids;
+ }
+ function collectTaskSubtreeUids(tasks, rootUid, maxDepth) {
+ const rootIndex = tasks.findIndex((task) => task.uid === rootUid);
+ if (rootIndex < 0) {
+ return new Set();
+ }
+ const rootTask = tasks[rootIndex];
+ const uids = new Set();
+ if (!isPlaceholderUid(rootTask.uid)) {
+ uids.add(rootTask.uid);
+ }
+ for (let index = rootIndex + 1; index < tasks.length; index += 1) {
+ const task = tasks[index];
+ if (task.outlineLevel <= rootTask.outlineLevel) {
+ break;
+ }
+ const relativeDepth = task.outlineLevel - rootTask.outlineLevel;
+ if (typeof maxDepth === "number" && relativeDepth > maxDepth) {
+ continue;
+ }
+ if (!isPlaceholderUid(task.uid)) {
+ uids.add(task.uid);
+ }
+ }
+ return uids;
+ }
+ function resolvePhaseUidForTask(taskUid, parentMap, phaseUidSet) {
+ let currentUid = taskUid;
+ while (currentUid) {
+ if (phaseUidSet.has(currentUid)) {
+ return currentUid;
+ }
+ currentUid = parentMap.get(currentUid) || null;
+ }
+ return undefined;
+ }
+ function buildDefaultRules(scope) {
+ if (scope === "project_overview_view") {
+ return {
+ allow_patch_ops: ["add_task", "update_task", "move_task"],
+ forbid_completed_task_changes: true,
+ forbid_summary_task_direct_edit: true
+ };
+ }
+ return {
+ allow_patch_ops: ["add_task", "update_task", "move_task", "link_tasks", "unlink_tasks"],
+ forbid_completed_task_changes: true,
+ forbid_summary_task_direct_edit: true
+ };
+ }
+ function toIsoLocalString(value) {
+ const year = value.getFullYear();
+ const month = String(value.getMonth() + 1).padStart(2, "0");
+ const day = String(value.getDate()).padStart(2, "0");
+ const hour = String(value.getHours()).padStart(2, "0");
+ const minute = String(value.getMinutes()).padStart(2, "0");
+ const second = String(value.getSeconds()).padStart(2, "0");
+ return `${year}-${month}-${day}T${hour}:${minute}:${second}`;
+ }
+ function addHoursToDateTime(dateTime, hours) {
+ const parsed = new Date(dateTime);
+ if (Number.isNaN(parsed.getTime())) {
+ return dateTime;
+ }
+ parsed.setTime(parsed.getTime() + (hours * 60 * 60 * 1000));
+ return toIsoLocalString(parsed);
+ }
+ function isDateOnlyText(value) {
+ return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value.trim());
+ }
+ function withTimeOnDate(dateText, timeText) {
+ return `${dateText}T${timeText}`;
+ }
+ function buildProjectDraftRequest(input) {
+ return {
+ view_type: "project_draft_request",
+ project: {
+ name: input.name,
+ planned_start: input.plannedStart || undefined
+ },
+ requirements: {
+ goal: input.goal || undefined,
+ team_count: input.teamCount,
+ must_have_phases: input.mustHavePhases || [],
+ must_have_milestones: input.mustHaveMilestones || []
+ }
+ };
+ }
+ function importProjectDraftView(draft) {
+ var _a, _b;
+ if (!draft || typeof draft !== "object") {
+ throw new Error("project_draft_view がオブジェクトではありません");
+ }
+ const data = draft;
+ if (data.view_type !== "project_draft_view") {
+ throw new Error("view_type が project_draft_view ではありません");
+ }
+ if (!((_b = (_a = data.project) === null || _a === void 0 ? void 0 : _a.name) === null || _b === void 0 ? void 0 : _b.trim())) {
+ throw new Error("project.name がありません");
+ }
+ const inputTasks = Array.isArray(data.tasks) ? data.tasks : [];
+ const seenUids = new Set();
+ for (const task of inputTasks) {
+ const uid = String(task.uid || "").trim();
+ if (!uid) {
+ throw new Error("draft task の uid がありません");
+ }
+ if (seenUids.has(uid)) {
+ throw new Error(`draft task の uid が重複しています: ${uid}`);
+ }
+ seenUids.add(uid);
+ if (!String(task.name || "").trim()) {
+ throw new Error(`draft task の name がありません: ${uid}`);
+ }
+ }
+ for (const task of inputTasks) {
+ const parentUid = task.parent_uid == null || task.parent_uid === "" ? null : String(task.parent_uid);
+ if (parentUid && !seenUids.has(parentUid)) {
+ throw new Error(`draft task の parent_uid が既存 uid を指していません: ${String(task.uid || "")} -> ${parentUid}`);
+ }
+ }
+ const projectStart = data.project.planned_start || data.project.planned_finish || toIsoLocalString(new Date());
+ const draftUidMap = new Map();
+ inputTasks.forEach((task, index) => {
+ draftUidMap.set(String(task.uid || "").trim(), String(index + 1));
+ });
+ const normalizedTasks = inputTasks.map((task, index) => ({
+ uid: draftUidMap.get(String(task.uid || "").trim()) || String(index + 1),
+ name: String(task.name || "").trim(),
+ parentUid: task.parent_uid == null || task.parent_uid === "" ? null : (draftUidMap.get(String(task.parent_uid)) || null),
+ position: typeof task.position === "number" && Number.isFinite(task.position) ? task.position : index,
+ isSummary: Boolean(task.is_summary),
+ isMilestone: Boolean(task.is_milestone),
+ percentComplete: typeof task.percent_complete === "number" && Number.isFinite(task.percent_complete)
+ ? Math.max(0, Math.min(100, task.percent_complete))
+ : 0,
+ plannedDuration: task.planned_duration || undefined,
+ plannedDurationHours: typeof task.planned_duration_hours === "number" && Number.isFinite(task.planned_duration_hours)
+ ? task.planned_duration_hours
+ : undefined,
+ plannedStart: task.planned_start || undefined,
+ plannedFinish: task.planned_finish || undefined,
+ predecessorUids: [
+ ...(Array.isArray(task.predecessor_uids) ? task.predecessor_uids : []),
+ ...(Array.isArray(task.predecessors)
+ ? task.predecessors.flatMap((item) => {
+ if (typeof item === "string") {
+ return [item];
+ }
+ return (item === null || item === void 0 ? void 0 : item.task_uid) ? [item.task_uid] : [];
+ })
+ : [])
+ ].map((item) => draftUidMap.get(String(item)) || String(item))
+ }));
+ const byParent = new Map();
+ for (const task of normalizedTasks) {
+ const siblings = byParent.get(task.parentUid) || [];
+ siblings.push(task);
+ byParent.set(task.parentUid, siblings);
+ }
+ for (const siblings of byParent.values()) {
+ siblings.sort((left, right) => left.position - right.position || left.uid.localeCompare(right.uid));
+ }
+ const orderedTasks = [];
+ function walk(parentUid, outlinePath) {
+ const siblings = byParent.get(parentUid) || [];
+ siblings.forEach((task, index) => {
+ const currentPath = [...outlinePath, index + 1];
+ const outlineNumber = currentPath.join(".");
+ let start = task.plannedStart || task.plannedFinish || projectStart;
+ let finish = task.plannedFinish
+ || (typeof task.plannedDurationHours === "number" ? addHoursToDateTime(start, task.plannedDurationHours) : start);
+ const dateOnlyTaskRange = !task.isMilestone
+ && isDateOnlyText(start)
+ && isDateOnlyText(finish)
+ && task.plannedDuration == null
+ && typeof task.plannedDurationHours !== "number";
+ if (dateOnlyTaskRange) {
+ start = withTimeOnDate(start, "09:00:00");
+ finish = withTimeOnDate(finish, "18:00:00");
+ }
+ const hasChildren = (byParent.get(task.uid) || []).length > 0;
+ orderedTasks.push({
+ uid: task.uid,
+ id: task.uid,
+ name: task.name,
+ outlineLevel: currentPath.length,
+ outlineNumber,
+ wbs: outlineNumber,
+ start,
+ finish,
+ duration: task.plannedDuration || (typeof task.plannedDurationHours === "number" ? `PT${task.plannedDurationHours}H` : "PT0H0M0S"),
+ milestone: task.isMilestone,
+ summary: task.isSummary || hasChildren,
+ percentComplete: task.percentComplete,
+ predecessors: task.predecessorUids.map((predecessorUid) => ({ predecessorUid })),
+ extendedAttributes: [],
+ baselines: [],
+ timephasedData: []
+ });
+ walk(task.uid, currentPath);
+ });
+ }
+ walk(null, []);
+ const taskFinishes = orderedTasks.map((task) => task.finish).filter(Boolean).sort();
+ return normalizeProjectModel(ensureDefaultProjectCalendar({
+ project: {
+ name: data.project.name.trim(),
+ title: data.project.name.trim(),
+ startDate: projectStart,
+ finishDate: data.project.planned_finish || taskFinishes.at(-1) || projectStart,
+ scheduleFromStart: true,
+ outlineCodes: [],
+ wbsMasks: [],
+ extendedAttributes: []
+ },
+ tasks: orderedTasks,
+ resources: [],
+ assignments: [],
+ calendars: []
+ }));
+ }
+ function exportProjectOverviewView(model) {
+ const parentMap = buildTaskParentMap(model.tasks);
+ const phaseTasks = collectTopLevelPhases(model.tasks);
+ const phaseUidSet = new Set(phaseTasks.map((task) => task.uid));
+ const allMilestones = model.tasks.filter((task) => !isPlaceholderUid(task.uid) && task.milestone);
+ const topLevelDependencyMap = new Map();
+ for (const task of model.tasks) {
+ const toPhaseUid = resolvePhaseUidForTask(task.uid, parentMap, phaseUidSet);
+ if (!toPhaseUid) {
+ continue;
+ }
+ for (const predecessor of task.predecessors) {
+ const fromPhaseUid = resolvePhaseUidForTask(predecessor.predecessorUid, parentMap, phaseUidSet);
+ if (!fromPhaseUid || fromPhaseUid === toPhaseUid) {
+ continue;
+ }
+ const key = `${fromPhaseUid}->${toPhaseUid}:${formatDependencyType(predecessor.type)}`;
+ if (!topLevelDependencyMap.has(key)) {
+ topLevelDependencyMap.set(key, {
+ from_uid: fromPhaseUid,
+ to_uid: toPhaseUid,
+ type: formatDependencyType(predecessor.type)
+ });
+ }
+ }
+ }
+ return {
+ view_type: "project_overview_view",
+ project: {
+ name: model.project.name,
+ planned_start: model.project.startDate,
+ planned_finish: model.project.finishDate,
+ status_date: model.project.statusDate
+ },
+ summary: {
+ task_count: model.tasks.filter((task) => !isPlaceholderUid(task.uid)).length,
+ summary_task_count: model.tasks.filter((task) => !isPlaceholderUid(task.uid) && task.summary).length,
+ milestone_count: allMilestones.length,
+ max_outline_level: model.tasks.reduce((max, task) => Math.max(max, task.outlineLevel || 0), 0)
+ },
+ phases: phaseTasks.map((phase) => {
+ const phaseTaskUids = collectPhaseTaskUids(model.tasks, phase.uid);
+ const descendantTasks = model.tasks.filter((task) => phaseTaskUids.has(task.uid));
+ return {
+ uid: phase.uid,
+ name: phase.name,
+ wbs: phase.wbs || phase.outlineNumber,
+ task_count: descendantTasks.length,
+ milestone_count: descendantTasks.filter((task) => task.milestone).length,
+ planned_start: phase.start,
+ planned_finish: phase.finish,
+ duration: phase.duration,
+ duration_hours: parseDurationHours(phase.duration),
+ percent_complete: phase.percentComplete,
+ sample_tasks: descendantTasks.slice(0, 3).map((task) => ({
+ uid: task.uid,
+ name: task.name
+ }))
+ };
+ }),
+ milestones: allMilestones.map((task) => ({
+ uid: task.uid,
+ name: task.name,
+ parent_uid: parentMap.get(task.uid),
+ date: task.finish || task.start
+ })),
+ top_level_dependencies: Array.from(topLevelDependencyMap.values()),
+ rules: buildDefaultRules("project_overview_view")
+ };
+ }
+ function exportPhaseDetailView(model, requestedPhaseUid, options) {
+ var _a;
+ const phaseTasks = collectTopLevelPhases(model.tasks);
+ if (phaseTasks.length === 0) {
+ throw new Error("phase が見つかりません");
+ }
+ const phase = requestedPhaseUid
+ ? phaseTasks.find((task) => task.uid === requestedPhaseUid)
+ : phaseTasks[0];
+ if (!phase) {
+ throw new Error(`phase が見つかりません: ${requestedPhaseUid}`);
+ }
+ const parentMap = buildTaskParentMap(model.tasks);
+ const positionMap = buildTaskPositionMap(model.tasks, parentMap);
+ const phaseTaskUids = collectPhaseTaskUids(model.tasks, phase.uid);
+ const phaseTasksOnly = model.tasks.filter((task) => phaseTaskUids.has(task.uid));
+ const mode = (options === null || options === void 0 ? void 0 : options.mode) === "scoped" ? "scoped" : "full";
+ const rootUid = mode === "scoped" ? ((_a = options === null || options === void 0 ? void 0 : options.rootUid) === null || _a === void 0 ? void 0 : _a.trim()) || undefined : undefined;
+ const maxDepth = mode === "scoped" && typeof (options === null || options === void 0 ? void 0 : options.maxDepth) === "number" && Number.isFinite(options.maxDepth) && options.maxDepth >= 0
+ ? Math.floor(options.maxDepth)
+ : undefined;
+ let scopedTaskUids = phaseTaskUids;
+ if (rootUid) {
+ const rootTask = phaseTasksOnly.find((task) => task.uid === rootUid);
+ if (!rootTask) {
+ throw new Error(`phase 配下に root_uid が見つかりません: ${rootUid}`);
+ }
+ scopedTaskUids = collectTaskSubtreeUids(phaseTasksOnly, rootUid, maxDepth);
+ }
+ const descendantTasks = phaseTasksOnly.filter((task) => scopedTaskUids.has(task.uid));
+ return {
+ view_type: "phase_detail_view",
+ project: {
+ name: model.project.name,
+ planned_start: model.project.startDate,
+ planned_finish: model.project.finishDate
+ },
+ phase: {
+ uid: phase.uid,
+ name: phase.name,
+ wbs: phase.wbs || phase.outlineNumber,
+ planned_start: phase.start,
+ planned_finish: phase.finish,
+ task_count: descendantTasks.length,
+ milestone_count: descendantTasks.filter((task) => task.milestone).length,
+ percent_complete: phase.percentComplete
+ },
+ scope: {
+ mode,
+ root_uid: rootUid || null,
+ max_depth: maxDepth !== null && maxDepth !== void 0 ? maxDepth : null
+ },
+ tasks: descendantTasks.map((task) => {
+ var _a;
+ return ({
+ uid: task.uid,
+ name: task.name,
+ parent_uid: parentMap.get(task.uid),
+ position: (_a = positionMap.get(task.uid)) !== null && _a !== void 0 ? _a : 0,
+ is_summary: task.summary,
+ is_milestone: task.milestone,
+ planned_duration: task.duration,
+ planned_duration_hours: parseDurationHours(task.duration),
+ planned_start: task.start,
+ planned_finish: task.finish,
+ percent_complete: task.percentComplete,
+ predecessor_uids: task.predecessors.map((item) => item.predecessorUid)
+ });
+ }),
+ milestones: descendantTasks.filter((task) => task.milestone).map((task) => ({
+ uid: task.uid,
+ name: task.name,
+ date: task.finish || task.start
+ })),
+ dependency_summary: descendantTasks.flatMap((task) => task.predecessors
+ .filter((predecessor) => scopedTaskUids.has(predecessor.predecessorUid))
+ .map((predecessor) => {
+ var _a;
+ return ({
+ from_uid: predecessor.predecessorUid,
+ to_uid: task.uid,
+ type: formatDependencyType(predecessor.type),
+ lag: predecessor.linkLag || "PT0H0M0S",
+ lag_hours: (_a = parseDurationHours(predecessor.linkLag || "PT0H0M0S")) !== null && _a !== void 0 ? _a : 0
+ });
+ })),
+ rules: buildDefaultRules("phase_detail_view")
+ };
+ }
+ function buildTaskSectionMap(tasks, projectName) {
+ const sectionMap = new Map();
+ const summaryStack = [];
+ for (const task of tasks) {
+ while (summaryStack.length > 0 && task.outlineLevel <= summaryStack[summaryStack.length - 1].outlineLevel) {
+ summaryStack.pop();
+ }
+ if (task.summary) {
+ summaryStack.push(task);
+ continue;
+ }
+ const sectionName = summaryStack.length > 0
+ ? normalizeMermaidGanttLabel(summaryStack[summaryStack.length - 1].name, "Summary", "Section")
+ : normalizeMermaidGanttLabel(projectName, "Tasks", "Section");
+ sectionMap.set(task.uid, sectionName);
+ }
+ return sectionMap;
+ }
+ function exportMermaidGantt(model) {
+ const lines = [
+ "gantt",
+ ` title ${normalizeMermaidGanttLabel(model.project.name, "Project", "Project")}`,
+ " dateFormat YYYY-MM-DDTHH:mm:ss",
+ " axisFormat %m/%d"
+ ];
+ const sectionMap = buildTaskSectionMap(model.tasks, model.project.name);
+ const taskNameMap = new Map(model.tasks.map((task) => [
+ task.uid,
+ normalizeMermaidGanttLabel(task.name, `Task ${task.uid}`, "Task")
+ ]));
+ const exportedTasks = model.tasks.filter((task) => !task.summary && task.start && task.finish);
+ let currentSection = "";
+ for (const task of exportedTasks) {
+ const section = sectionMap.get(task.uid) || "Tasks";
+ if (section !== currentSection) {
+ currentSection = section;
+ lines.push(` section ${section}`);
+ }
+ const tags = [];
+ if (task.critical) {
+ tags.push("crit");
+ }
+ if (task.milestone) {
+ tags.push("milestone");
+ }
+ else if (task.percentComplete >= 100) {
+ tags.push("done");
+ }
+ else if (task.percentComplete > 0) {
+ tags.push("active");
+ }
+ const taskId = `task_${normalizeMermaidTaskId(task.uid || task.id || "x", "x")}`;
+ const singlePredecessor = task.predecessors.length === 1 ? task.predecessors[0] : null;
+ const nativeDependencyTarget = singlePredecessor
+ ? `task_${normalizeMermaidTaskId(singlePredecessor.predecessorUid, "x")}`
+ : null;
+ const nativeDuration = !task.milestone ? toMermaidDuration(task.duration) : null;
+ const useNativeDependency = Boolean(singlePredecessor
+ && nativeDependencyTarget
+ && nativeDuration
+ && isZeroDuration(singlePredecessor.linkLag)
+ && (singlePredecessor.type === undefined || singlePredecessor.type === 1));
+ const fields = useNativeDependency
+ ? [...tags, taskId, `after ${nativeDependencyTarget}`, nativeDuration]
+ : [...tags, taskId, task.start, task.finish].filter(Boolean);
+ lines.push(` ${normalizeMermaidGanttLabel(task.name, `Task ${task.uid}`, "Task")} :${fields.join(", ")}`);
+ for (const predecessor of task.predecessors) {
+ const predecessorTaskId = `task_${normalizeMermaidTaskId(predecessor.predecessorUid, "x")}`;
+ const predecessorName = taskNameMap.get(predecessor.predecessorUid) || `Task ${predecessor.predecessorUid}`;
+ if (useNativeDependency && predecessorTaskId === nativeDependencyTarget) {
+ lines.push(` %% dependency(native): ${task.name || taskId} after ${predecessorName} (${taskId} after ${predecessorTaskId})`);
+ continue;
+ }
+ const details = [
+ `type=${describePredecessorType(predecessor.type)}`,
+ !isZeroDuration(predecessor.linkLag) ? `lag=${formatMermaidLag(predecessor.linkLag)}` : ""
+ ].filter(Boolean).join(", ");
+ lines.push(` %% dependency: ${task.name || taskId} after ${predecessorName}${details ? ` (${details})` : ""} [${taskId} after ${predecessorTaskId}]`);
+ if (!isZeroDuration(predecessor.linkLag)) {
+ lines.push(` %% dependency(pseudo): ${task.name || taskId} ~= after ${predecessorName} + ${formatMermaidLag(predecessor.linkLag)}`);
+ }
+ }
+ if (task.predecessors.length > 1) {
+ lines.push(` %% dependency(note): ${task.name || taskId} has multiple predecessors`);
+ }
+ else if (singlePredecessor && !useNativeDependency) {
+ const reasons = [
+ !isZeroDuration(singlePredecessor.linkLag) ? `lag=${formatMermaidLag(singlePredecessor.linkLag)}` : "",
+ singlePredecessor.type !== undefined && singlePredecessor.type !== 1 ? `type=${describePredecessorType(singlePredecessor.type)}` : "",
+ !nativeDuration && !task.milestone ? `duration=${task.duration || "(empty)"}` : ""
+ ].filter(Boolean).join(", ");
+ if (reasons) {
+ lines.push(` %% dependency(note): ${task.name || taskId} kept as comment because ${reasons}`);
+ }
+ }
+ }
+ if (exportedTasks.length === 0) {
+ lines.push(" section Tasks");
+ lines.push(" No tasks :milestone, empty_0, 1970-01-01T00:00:00, 1970-01-01T00:00:00");
+ }
+ return `${lines.join("\n")}\n`;
+ }
+ function buildTaskParentUidMap(tasks) {
+ const parentMap = new Map();
+ const stack = [];
+ for (const task of tasks) {
+ while (stack.length > 0 && task.outlineLevel <= stack[stack.length - 1].outlineLevel) {
+ stack.pop();
+ }
+ const parent = stack[stack.length - 1];
+ if (parent) {
+ parentMap.set(task.uid, parent.uid);
+ }
+ stack.push(task);
+ }
+ return parentMap;
+ }
+ function escapeCsvCell(value) {
+ const text = String(value !== null && value !== void 0 ? value : "");
+ if (/[",\n]/.test(text)) {
+ return `"${text.replace(/"/g, "\"\"")}"`;
+ }
+ return text;
+ }
+ function exportCsvParentId(model) {
+ const header = ["ID", "ParentID", "WBS", "Name", "Start", "Finish", "PredecessorID", "Resource", "PercentComplete", "PercentWorkComplete", "Milestone", "Summary", "Critical", "Type", "Priority", "Work", "CalendarUID", "ConstraintType", "ConstraintDate", "Deadline", "Notes"];
+ const parentMap = buildTaskParentUidMap(model.tasks);
+ const resourceMap = new Map(model.resources.map((resource) => [resource.uid, resource.name]));
+ const assignmentMap = new Map();
+ for (const assignment of model.assignments) {
+ const resourceName = resourceMap.get(assignment.resourceUid);
+ if (!resourceName) {
+ continue;
+ }
+ const names = assignmentMap.get(assignment.taskUid) || [];
+ if (!names.includes(resourceName)) {
+ names.push(resourceName);
+ }
+ assignmentMap.set(assignment.taskUid, names);
+ }
+ const rows = model.tasks.map((task) => {
+ var _a, _b, _c, _d;
+ return [
+ task.uid,
+ parentMap.get(task.uid) || "",
+ task.wbs || task.outlineNumber || "",
+ task.name,
+ task.start || "",
+ task.finish || "",
+ task.predecessors.map((item) => item.predecessorUid).join("|"),
+ (assignmentMap.get(task.uid) || []).join("|"),
+ task.percentComplete,
+ (_a = task.percentWorkComplete) !== null && _a !== void 0 ? _a : "",
+ task.milestone ? 1 : 0,
+ task.summary ? 1 : 0,
+ task.critical === undefined ? "" : (task.critical ? 1 : 0),
+ (_b = task.type) !== null && _b !== void 0 ? _b : "",
+ (_c = task.priority) !== null && _c !== void 0 ? _c : "",
+ task.work || "",
+ task.calendarUID || "",
+ (_d = task.constraintType) !== null && _d !== void 0 ? _d : "",
+ task.constraintDate || "",
+ task.deadline || "",
+ task.notes || ""
+ ];
+ });
+ return [header, ...rows].map((row) => row.map((cell) => escapeCsvCell(cell)).join(",")).join("\n") + "\n";
+ }
+ function parseCsvRows(csvText) {
+ const rows = [];
+ let row = [];
+ let cell = "";
+ let inQuotes = false;
+ for (let index = 0; index < csvText.length; index += 1) {
+ const char = csvText[index];
+ const next = csvText[index + 1];
+ if (char === "\"") {
+ if (inQuotes && next === "\"") {
+ cell += "\"";
+ index += 1;
+ }
+ else {
+ inQuotes = !inQuotes;
+ }
+ continue;
+ }
+ if (!inQuotes && char === ",") {
+ row.push(cell);
+ cell = "";
+ continue;
+ }
+ if (!inQuotes && (char === "\n" || char === "\r")) {
+ if (char === "\r" && next === "\n") {
+ index += 1;
+ }
+ row.push(cell);
+ rows.push(row);
+ row = [];
+ cell = "";
+ continue;
+ }
+ cell += char;
+ }
+ if (cell.length > 0 || row.length > 0) {
+ row.push(cell);
+ rows.push(row);
+ }
+ return rows.filter((item) => item.some((cellValue) => String(cellValue).trim() !== ""));
+ }
+ function parseCsvMultiValueCell(value) {
+ const normalized = String(value || "").trim();
+ if (!normalized) {
+ return [];
+ }
+ const items = normalized
+ .split(/[|;,、]/)
+ .map((item) => item.trim())
+ .filter(Boolean);
+ return Array.from(new Set(items));
+ }
+ function parseCsvBooleanCell(value, fallback) {
+ const normalized = String(value || "").trim().toLowerCase();
+ if (!normalized) {
+ return fallback;
+ }
+ if (["1", "true", "yes", "y", "on"].includes(normalized)) {
+ return true;
+ }
+ if (["0", "false", "no", "n", "off"].includes(normalized)) {
+ return false;
+ }
+ return fallback;
+ }
+ function importCsvParentId(csvText) {
+ const rows = parseCsvRows(csvText.trim());
+ if (rows.length === 0) {
+ throw new Error("CSV が空です");
+ }
+ const header = rows[0].map((item) => item.trim());
+ const requiredColumns = ["ID", "ParentID", "Name"];
+ for (const requiredColumn of requiredColumns) {
+ if (!header.includes(requiredColumn)) {
+ throw new Error(`CSV に必須列がありません: ${requiredColumn}`);
+ }
+ }
+ const columnIndex = (name) => header.indexOf(name);
+ const entries = rows.slice(1).map((row) => ({
+ id: String(row[columnIndex("ID")] || "").trim(),
+ parentId: String(row[columnIndex("ParentID")] || "").trim(),
+ wbs: String((columnIndex("WBS") >= 0 ? row[columnIndex("WBS")] : "") || "").trim(),
+ name: String(row[columnIndex("Name")] || "").trim(),
+ start: String((columnIndex("Start") >= 0 ? row[columnIndex("Start")] : "") || "").trim(),
+ finish: String((columnIndex("Finish") >= 0 ? row[columnIndex("Finish")] : "") || "").trim(),
+ predecessorId: String((columnIndex("PredecessorID") >= 0 ? row[columnIndex("PredecessorID")] : "") || "").trim(),
+ resource: String((columnIndex("Resource") >= 0 ? row[columnIndex("Resource")] : "") || "").trim(),
+ percentComplete: parseNumber(String((columnIndex("PercentComplete") >= 0 ? row[columnIndex("PercentComplete")] : "0") || "0").trim(), 0),
+ percentWorkComplete: columnIndex("PercentWorkComplete") >= 0 && String(row[columnIndex("PercentWorkComplete")] || "").trim()
+ ? parseNumber(String(row[columnIndex("PercentWorkComplete")] || "").trim(), 0)
+ : undefined,
+ milestone: parseCsvBooleanCell(String((columnIndex("Milestone") >= 0 ? row[columnIndex("Milestone")] : "") || "").trim(), false),
+ summary: columnIndex("Summary") >= 0 && String(row[columnIndex("Summary")] || "").trim()
+ ? parseCsvBooleanCell(String(row[columnIndex("Summary")] || "").trim(), false)
+ : undefined,
+ critical: columnIndex("Critical") >= 0 && String(row[columnIndex("Critical")] || "").trim()
+ ? parseCsvBooleanCell(String(row[columnIndex("Critical")] || "").trim(), false)
+ : undefined,
+ type: columnIndex("Type") >= 0 && String(row[columnIndex("Type")] || "").trim()
+ ? parseNumber(String(row[columnIndex("Type")] || "").trim(), 0)
+ : undefined,
+ priority: columnIndex("Priority") >= 0 && String(row[columnIndex("Priority")] || "").trim()
+ ? parseNumber(String(row[columnIndex("Priority")] || "").trim(), 0)
+ : undefined,
+ work: String((columnIndex("Work") >= 0 ? row[columnIndex("Work")] : "") || "").trim(),
+ calendarUID: String((columnIndex("CalendarUID") >= 0 ? row[columnIndex("CalendarUID")] : "") || "").trim(),
+ constraintType: columnIndex("ConstraintType") >= 0 && String(row[columnIndex("ConstraintType")] || "").trim()
+ ? parseNumber(String(row[columnIndex("ConstraintType")] || "").trim(), 0)
+ : undefined,
+ constraintDate: String((columnIndex("ConstraintDate") >= 0 ? row[columnIndex("ConstraintDate")] : "") || "").trim(),
+ deadline: String((columnIndex("Deadline") >= 0 ? row[columnIndex("Deadline")] : "") || "").trim(),
+ notes: String((columnIndex("Notes") >= 0 ? row[columnIndex("Notes")] : "") || "").trim(),
+ children: []
+ })).filter((entry) => entry.id);
+ const seenIds = new Set();
+ for (const entry of entries) {
+ if (seenIds.has(entry.id)) {
+ throw new Error(`CSV の ID が重複しています: ${entry.id}`);
+ }
+ seenIds.add(entry.id);
+ if (!entry.name) {
+ throw new Error(`CSV の Name が空です: ID=${entry.id}`);
+ }
+ if (entry.parentId && entry.parentId === entry.id) {
+ throw new Error(`CSV の ParentID が自身を指しています: ID=${entry.id}`);
+ }
+ }
+ const entryMap = new Map(entries.map((entry) => [entry.id, entry]));
+ for (const entry of entries) {
+ if (entry.parentId && !entryMap.has(entry.parentId)) {
+ throw new Error(`CSV の ParentID が既存 ID を指していません: ID=${entry.id}, ParentID=${entry.parentId}`);
+ }
+ }
+ const visiting = new Set();
+ const visited = new Set();
+ function checkCycle(entry) {
+ if (visited.has(entry.id)) {
+ return;
+ }
+ if (visiting.has(entry.id)) {
+ throw new Error(`CSV の ParentID が循環しています: ID=${entry.id}`);
+ }
+ visiting.add(entry.id);
+ if (entry.parentId) {
+ const parent = entryMap.get(entry.parentId);
+ if (parent) {
+ checkCycle(parent);
+ }
+ }
+ visiting.delete(entry.id);
+ visited.add(entry.id);
+ }
+ entries.forEach((entry) => checkCycle(entry));
+ const roots = [];
+ for (const entry of entries) {
+ const parent = entry.parentId ? entryMap.get(entry.parentId) : undefined;
+ if (parent) {
+ parent.children.push(entry);
+ }
+ else {
+ roots.push(entry);
+ }
+ }
+ const tasks = [];
+ function walk(entry, outlinePath) {
+ var _a;
+ const children = entry.children;
+ let start = entry.start;
+ let finish = entry.finish;
+ if ((!start || !finish) && children.length > 0) {
+ const childStarts = children.map((child) => child.start).filter(Boolean).sort();
+ const childFinishes = children.map((child) => child.finish).filter(Boolean).sort();
+ start = start || childStarts[0] || "";
+ finish = finish || childFinishes.at(-1) || "";
+ }
+ const outlineNumber = outlinePath.join(".");
+ tasks.push({
+ uid: entry.id,
+ id: entry.id,
+ name: entry.name,
+ outlineLevel: outlinePath.length,
+ outlineNumber,
+ wbs: entry.wbs || outlineNumber,
+ type: entry.type,
+ priority: entry.priority,
+ work: entry.work || undefined,
+ calendarUID: entry.calendarUID || undefined,
+ start,
+ finish,
+ duration: "PT0H0M0S",
+ milestone: entry.milestone || Boolean(start && finish && start === finish),
+ summary: (_a = entry.summary) !== null && _a !== void 0 ? _a : (children.length > 0),
+ critical: entry.critical,
+ percentComplete: Math.max(0, Math.min(100, entry.percentComplete)),
+ percentWorkComplete: entry.percentWorkComplete !== undefined ? Math.max(0, Math.min(100, entry.percentWorkComplete)) : undefined,
+ constraintType: entry.constraintType,
+ constraintDate: entry.constraintDate || undefined,
+ deadline: entry.deadline || undefined,
+ notes: entry.notes || undefined,
+ predecessors: parseCsvMultiValueCell(entry.predecessorId).map((item) => ({ predecessorUid: item })),
+ extendedAttributes: [],
+ baselines: [],
+ timephasedData: []
+ });
+ children.forEach((child, index) => walk(child, [...outlinePath, index + 1]));
+ }
+ roots.forEach((root, index) => walk(root, [index + 1]));
+ const resourceNames = Array.from(new Set(entries.flatMap((entry) => parseCsvMultiValueCell(entry.resource))));
+ const resources = resourceNames.map((name, index) => ({
+ uid: String(index + 1),
+ id: String(index + 1),
+ name,
+ extendedAttributes: [],
+ baselines: [],
+ timephasedData: []
+ }));
+ const resourceUidByName = new Map(resources.map((resource) => [resource.name, resource.uid]));
+ let assignmentUid = 1;
+ const taskByUid = new Map(tasks.map((task) => [task.uid, task]));
+ const assignments = entries.flatMap((entry) => {
+ const task = taskByUid.get(entry.id);
+ if (!task) {
+ return [];
+ }
+ return parseCsvMultiValueCell(entry.resource).map((name) => ({
+ uid: String(assignmentUid++),
+ taskUid: entry.id,
+ resourceUid: resourceUidByName.get(name) || "",
+ start: task.start || undefined,
+ finish: task.finish || undefined,
+ percentWorkComplete: task.percentComplete,
+ extendedAttributes: [],
+ baselines: [],
+ timephasedData: []
+ }));
+ });
+ const taskStarts = tasks.map((task) => task.start).filter(Boolean).sort();
+ const taskFinishes = tasks.map((task) => task.finish).filter(Boolean).sort();
+ return normalizeProjectModel(ensureDefaultProjectCalendar({
+ project: {
+ name: "CSV Imported Project",
+ title: "CSV Imported Project",
+ startDate: taskStarts[0] || "",
+ finishDate: taskFinishes.at(-1) || "",
+ scheduleFromStart: true,
+ outlineCodes: [],
+ wbsMasks: [],
+ extendedAttributes: []
+ },
+ tasks,
+ resources,
+ assignments,
+ calendars: []
+ }));
+ }
+ function importMsProjectXml(xmlText) {
+ var _a, _b, _c, _d, _e, _f, _g;
+ const xml = parseXmlDocument(xmlText);
+ const projectElement = xml.documentElement;
+ const calendars = Array.from(((_a = projectElement.getElementsByTagName("Calendars")[0]) === null || _a === void 0 ? void 0 : _a.getElementsByTagName("Calendar")) || []);
+ const tasks = Array.from(((_b = projectElement.getElementsByTagName("Tasks")[0]) === null || _b === void 0 ? void 0 : _b.getElementsByTagName("Task")) || []);
+ const resources = Array.from(((_c = projectElement.getElementsByTagName("Resources")[0]) === null || _c === void 0 ? void 0 : _c.getElementsByTagName("Resource")) || []);
+ const assignments = Array.from(((_d = projectElement.getElementsByTagName("Assignments")[0]) === null || _d === void 0 ? void 0 : _d.getElementsByTagName("Assignment")) || []);
+ return normalizeProjectModel(ensureDefaultProjectCalendar({
+ project: {
+ name: textContent(projectElement, "Name"),
+ title: textContent(projectElement, "Title") || undefined,
+ author: textContent(projectElement, "Author") || undefined,
+ company: textContent(projectElement, "Company") || undefined,
+ creationDate: textContent(projectElement, "CreationDate") || undefined,
+ lastSaved: textContent(projectElement, "LastSaved") || undefined,
+ saveVersion: textContent(projectElement, "SaveVersion") ? parseNumber(textContent(projectElement, "SaveVersion"), 0) : undefined,
+ currentDate: textContent(projectElement, "CurrentDate") || undefined,
+ startDate: textContent(projectElement, "StartDate"),
+ finishDate: textContent(projectElement, "FinishDate"),
+ scheduleFromStart: parseBoolean(textContent(projectElement, "ScheduleFromStart")),
+ defaultStartTime: textContent(projectElement, "DefaultStartTime") || undefined,
+ defaultFinishTime: textContent(projectElement, "DefaultFinishTime") || undefined,
+ minutesPerDay: textContent(projectElement, "MinutesPerDay") ? parseNumber(textContent(projectElement, "MinutesPerDay"), 0) : undefined,
+ minutesPerWeek: textContent(projectElement, "MinutesPerWeek") ? parseNumber(textContent(projectElement, "MinutesPerWeek"), 0) : undefined,
+ daysPerMonth: textContent(projectElement, "DaysPerMonth") ? parseNumber(textContent(projectElement, "DaysPerMonth"), 0) : undefined,
+ statusDate: textContent(projectElement, "StatusDate") || undefined,
+ weekStartDay: textContent(projectElement, "WeekStartDay") ? parseNumber(textContent(projectElement, "WeekStartDay"), 0) : undefined,
+ workFormat: textContent(projectElement, "WorkFormat") ? parseNumber(textContent(projectElement, "WorkFormat"), 0) : undefined,
+ durationFormat: textContent(projectElement, "DurationFormat") ? parseNumber(textContent(projectElement, "DurationFormat"), 0) : undefined,
+ currencyCode: textContent(projectElement, "CurrencyCode") || undefined,
+ currencyDigits: textContent(projectElement, "CurrencyDigits") ? parseNumber(textContent(projectElement, "CurrencyDigits"), 0) : undefined,
+ currencySymbol: textContent(projectElement, "CurrencySymbol") || undefined,
+ currencySymbolPosition: textContent(projectElement, "CurrencySymbolPosition") ? parseNumber(textContent(projectElement, "CurrencySymbolPosition"), 0) : undefined,
+ fyStartDate: textContent(projectElement, "FYStartDate") || undefined,
+ fiscalYearStart: textContent(projectElement, "FiscalYearStart") ? parseBoolean(textContent(projectElement, "FiscalYearStart")) : undefined,
+ criticalSlackLimit: textContent(projectElement, "CriticalSlackLimit") ? parseNumber(textContent(projectElement, "CriticalSlackLimit"), 0) : undefined,
+ defaultTaskType: textContent(projectElement, "DefaultTaskType") ? parseNumber(textContent(projectElement, "DefaultTaskType"), 0) : undefined,
+ defaultFixedCostAccrual: textContent(projectElement, "DefaultFixedCostAccrual") ? parseNumber(textContent(projectElement, "DefaultFixedCostAccrual"), 0) : undefined,
+ defaultStandardRate: textContent(projectElement, "DefaultStandardRate") || undefined,
+ defaultOvertimeRate: textContent(projectElement, "DefaultOvertimeRate") || undefined,
+ defaultTaskEVMethod: textContent(projectElement, "DefaultTaskEVMethod") ? parseNumber(textContent(projectElement, "DefaultTaskEVMethod"), 0) : undefined,
+ newTaskStartDate: textContent(projectElement, "NewTaskStartDate") ? parseNumber(textContent(projectElement, "NewTaskStartDate"), 0) : undefined,
+ newTasksAreManual: textContent(projectElement, "NewTasksAreManual") ? parseBoolean(textContent(projectElement, "NewTasksAreManual")) : undefined,
+ newTasksEffortDriven: textContent(projectElement, "NewTasksEffortDriven") ? parseBoolean(textContent(projectElement, "NewTasksEffortDriven")) : undefined,
+ newTasksEstimated: textContent(projectElement, "NewTasksEstimated") ? parseBoolean(textContent(projectElement, "NewTasksEstimated")) : undefined,
+ actualsInSync: textContent(projectElement, "ActualsInSync") ? parseBoolean(textContent(projectElement, "ActualsInSync")) : undefined,
+ editableActualCosts: textContent(projectElement, "EditableActualCosts") ? parseBoolean(textContent(projectElement, "EditableActualCosts")) : undefined,
+ honorConstraints: textContent(projectElement, "HonorConstraints") ? parseBoolean(textContent(projectElement, "HonorConstraints")) : undefined,
+ insertedProjectsLikeSummary: textContent(projectElement, "InsertedProjectsLikeSummary") ? parseBoolean(textContent(projectElement, "InsertedProjectsLikeSummary")) : undefined,
+ multipleCriticalPaths: textContent(projectElement, "MultipleCriticalPaths") ? parseBoolean(textContent(projectElement, "MultipleCriticalPaths")) : undefined,
+ taskUpdatesResource: textContent(projectElement, "TaskUpdatesResource") ? parseBoolean(textContent(projectElement, "TaskUpdatesResource")) : undefined,
+ updateManuallyScheduledTasksWhenEditingLinks: textContent(projectElement, "UpdateManuallyScheduledTasksWhenEditingLinks") ? parseBoolean(textContent(projectElement, "UpdateManuallyScheduledTasksWhenEditingLinks")) : undefined,
+ calendarUID: textContent(projectElement, "CalendarUID") || undefined,
+ outlineCodes: Array.from(((_e = projectElement.getElementsByTagName("OutlineCodes")[0]) === null || _e === void 0 ? void 0 : _e.getElementsByTagName("OutlineCode")) || []).map((outlineCode) => ({
+ fieldID: textContent(outlineCode, "FieldID") || undefined,
+ fieldName: textContent(outlineCode, "FieldName") || undefined,
+ alias: textContent(outlineCode, "Alias") || undefined,
+ onlyTableValues: textContent(outlineCode, "OnlyTableValues") ? parseBoolean(textContent(outlineCode, "OnlyTableValues")) : undefined,
+ enterprise: textContent(outlineCode, "Enterprise") ? parseBoolean(textContent(outlineCode, "Enterprise")) : undefined,
+ resourceSubstitutionEnabled: textContent(outlineCode, "ResourceSubstitutionEnabled") ? parseBoolean(textContent(outlineCode, "ResourceSubstitutionEnabled")) : undefined,
+ leafOnly: textContent(outlineCode, "LeafOnly") ? parseBoolean(textContent(outlineCode, "LeafOnly")) : undefined,
+ allLevelsRequired: textContent(outlineCode, "AllLevelsRequired") ? parseBoolean(textContent(outlineCode, "AllLevelsRequired")) : undefined,
+ masks: parseOutlineCodeMasks(outlineCode),
+ values: parseOutlineCodeValues(outlineCode)
+ })),
+ wbsMasks: Array.from(((_f = projectElement.getElementsByTagName("WBSMasks")[0]) === null || _f === void 0 ? void 0 : _f.getElementsByTagName("WBSMask")) || []).map((wbsMask) => ({
+ level: parseNumber(textContent(wbsMask, "Level"), 0),
+ mask: textContent(wbsMask, "Mask") || undefined,
+ length: textContent(wbsMask, "Length") ? parseNumber(textContent(wbsMask, "Length"), 0) : undefined,
+ sequence: textContent(wbsMask, "Sequence") ? parseNumber(textContent(wbsMask, "Sequence"), 0) : undefined
+ })),
+ extendedAttributes: Array.from(((_g = projectElement.getElementsByTagName("ExtendedAttributes")[0]) === null || _g === void 0 ? void 0 : _g.getElementsByTagName("ExtendedAttribute")) || []).map((attribute) => ({
+ fieldID: textContent(attribute, "FieldID") || undefined,
+ fieldName: textContent(attribute, "FieldName") || undefined,
+ alias: textContent(attribute, "Alias") || undefined,
+ calculationType: textContent(attribute, "CalculationType") ? parseNumber(textContent(attribute, "CalculationType"), 0) : undefined,
+ restrictValues: textContent(attribute, "RestrictValues") ? parseBoolean(textContent(attribute, "RestrictValues")) : undefined,
+ appendNewValues: textContent(attribute, "AppendNewValues") ? parseBoolean(textContent(attribute, "AppendNewValues")) : undefined
+ }))
+ },
+ calendars: calendars.map((calendar) => {
+ var _a, _b;
+ return ({
+ uid: textContent(calendar, "UID"),
+ name: textContent(calendar, "Name"),
+ isBaseCalendar: parseBoolean(textContent(calendar, "IsBaseCalendar")),
+ isBaselineCalendar: textContent(calendar, "IsBaselineCalendar") ? parseBoolean(textContent(calendar, "IsBaselineCalendar")) : undefined,
+ baseCalendarUID: textContent(calendar, "BaseCalendarUID") || undefined,
+ weekDays: parseWeekDays(calendar),
+ exceptions: Array.from(((_a = calendar.getElementsByTagName("Exceptions")[0]) === null || _a === void 0 ? void 0 : _a.getElementsByTagName("Exception")) || []).map((exception) => ({
+ name: textContent(exception, "Name") || undefined,
+ fromDate: textContent(exception, "FromDate") || undefined,
+ toDate: textContent(exception, "ToDate") || undefined,
+ dayWorking: textContent(exception, "DayWorking") ? parseBoolean(textContent(exception, "DayWorking")) : undefined,
+ workingTimes: parseWorkingTimes(exception)
+ })),
+ workWeeks: Array.from(((_b = calendar.getElementsByTagName("WorkWeeks")[0]) === null || _b === void 0 ? void 0 : _b.getElementsByTagName("WorkWeek")) || []).map((workWeek) => ({
+ name: textContent(workWeek, "Name") || undefined,
+ fromDate: textContent(workWeek, "FromDate") || undefined,
+ toDate: textContent(workWeek, "ToDate") || undefined,
+ weekDays: parseWeekDays(workWeek)
+ }))
+ });
+ }),
+ tasks: tasks.map((task) => ({
+ uid: textContent(task, "UID"),
+ id: textContent(task, "ID"),
+ name: textContent(task, "Name"),
+ outlineLevel: parseNumber(textContent(task, "OutlineLevel"), 1),
+ outlineNumber: textContent(task, "OutlineNumber"),
+ wbs: textContent(task, "WBS") || undefined,
+ type: textContent(task, "Type") ? parseNumber(textContent(task, "Type"), 0) : undefined,
+ calendarUID: textContent(task, "CalendarUID") || undefined,
+ priority: textContent(task, "Priority") ? parseNumber(textContent(task, "Priority"), 0) : undefined,
+ start: textContent(task, "Start"),
+ finish: textContent(task, "Finish"),
+ duration: textContent(task, "Duration"),
+ actualStart: textContent(task, "ActualStart") || undefined,
+ actualFinish: textContent(task, "ActualFinish") || undefined,
+ deadline: textContent(task, "Deadline") || undefined,
+ startVariance: textContent(task, "StartVariance") || undefined,
+ finishVariance: textContent(task, "FinishVariance") || undefined,
+ work: textContent(task, "Work") || undefined,
+ workVariance: textContent(task, "WorkVariance") || undefined,
+ totalSlack: textContent(task, "TotalSlack") || undefined,
+ freeSlack: textContent(task, "FreeSlack") || undefined,
+ cost: textContent(task, "Cost") ? parseNumber(textContent(task, "Cost"), 0) : undefined,
+ actualCost: textContent(task, "ActualCost") ? parseNumber(textContent(task, "ActualCost"), 0) : undefined,
+ remainingCost: textContent(task, "RemainingCost") ? parseNumber(textContent(task, "RemainingCost"), 0) : undefined,
+ remainingWork: textContent(task, "RemainingWork") || undefined,
+ actualWork: textContent(task, "ActualWork") || undefined,
+ milestone: parseBoolean(textContent(task, "Milestone")),
+ summary: parseBoolean(textContent(task, "Summary")),
+ critical: textContent(task, "Critical") ? parseBoolean(textContent(task, "Critical")) : undefined,
+ percentComplete: parseNumber(textContent(task, "PercentComplete"), 0),
+ percentWorkComplete: textContent(task, "PercentWorkComplete") ? parseNumber(textContent(task, "PercentWorkComplete"), 0) : undefined,
+ notes: textContent(task, "Notes") || undefined,
+ constraintType: textContent(task, "ConstraintType") ? parseNumber(textContent(task, "ConstraintType"), 0) : undefined,
+ constraintDate: textContent(task, "ConstraintDate") || undefined,
+ extendedAttributes: Array.from(task.getElementsByTagName("ExtendedAttribute")).map((attribute) => ({
+ fieldID: textContent(attribute, "FieldID") || undefined,
+ value: textContent(attribute, "Value") || undefined
+ })),
+ baselines: Array.from(task.getElementsByTagName("Baseline")).map((baseline) => ({
+ number: textContent(baseline, "Number") ? parseNumber(textContent(baseline, "Number"), 0) : undefined,
+ start: textContent(baseline, "Start") || undefined,
+ finish: textContent(baseline, "Finish") || undefined,
+ work: textContent(baseline, "Work") || undefined,
+ cost: textContent(baseline, "Cost") ? parseNumber(textContent(baseline, "Cost"), 0) : undefined
+ })),
+ timephasedData: Array.from(task.getElementsByTagName("TimephasedData")).map((timephasedData) => ({
+ type: textContent(timephasedData, "Type") ? parseNumber(textContent(timephasedData, "Type"), 0) : undefined,
+ uid: textContent(timephasedData, "UID") || undefined,
+ start: textContent(timephasedData, "Start") || undefined,
+ finish: textContent(timephasedData, "Finish") || undefined,
+ unit: textContent(timephasedData, "Unit") ? parseNumber(textContent(timephasedData, "Unit"), 0) : undefined,
+ value: textContent(timephasedData, "Value") || undefined
+ })),
+ predecessors: Array.from(task.getElementsByTagName("PredecessorLink")).map((link) => ({
+ predecessorUid: textContent(link, "PredecessorUID"),
+ type: parseNumber(textContent(link, "Type"), 0),
+ linkLag: textContent(link, "LinkLag") || undefined
+ }))
+ })),
+ resources: resources.map((resource) => ({
+ uid: textContent(resource, "UID"),
+ id: textContent(resource, "ID"),
+ name: textContent(resource, "Name"),
+ type: parseNumber(textContent(resource, "Type"), 0),
+ initials: textContent(resource, "Initials") || undefined,
+ group: textContent(resource, "Group") || undefined,
+ workGroup: textContent(resource, "WorkGroup") ? parseNumber(textContent(resource, "WorkGroup"), 0) : undefined,
+ maxUnits: textContent(resource, "MaxUnits") ? parseNumber(textContent(resource, "MaxUnits"), 0) : undefined,
+ calendarUID: textContent(resource, "CalendarUID") || undefined,
+ standardRate: textContent(resource, "StandardRate") || undefined,
+ standardRateFormat: textContent(resource, "StandardRateFormat") ? parseNumber(textContent(resource, "StandardRateFormat"), 0) : undefined,
+ overtimeRate: textContent(resource, "OvertimeRate") || undefined,
+ overtimeRateFormat: textContent(resource, "OvertimeRateFormat") ? parseNumber(textContent(resource, "OvertimeRateFormat"), 0) : undefined,
+ costPerUse: textContent(resource, "CostPerUse") ? parseNumber(textContent(resource, "CostPerUse"), 0) : undefined,
+ work: textContent(resource, "Work") || undefined,
+ actualWork: textContent(resource, "ActualWork") || undefined,
+ remainingWork: textContent(resource, "RemainingWork") || undefined,
+ cost: textContent(resource, "Cost") ? parseNumber(textContent(resource, "Cost"), 0) : undefined,
+ actualCost: textContent(resource, "ActualCost") ? parseNumber(textContent(resource, "ActualCost"), 0) : undefined,
+ remainingCost: textContent(resource, "RemainingCost") ? parseNumber(textContent(resource, "RemainingCost"), 0) : undefined,
+ percentWorkComplete: textContent(resource, "PercentWorkComplete") ? parseNumber(textContent(resource, "PercentWorkComplete"), 0) : undefined,
+ extendedAttributes: Array.from(resource.getElementsByTagName("ExtendedAttribute")).map((attribute) => ({
+ fieldID: textContent(attribute, "FieldID") || undefined,
+ value: textContent(attribute, "Value") || undefined
+ })),
+ baselines: Array.from(resource.getElementsByTagName("Baseline")).map((baseline) => ({
+ number: textContent(baseline, "Number") ? parseNumber(textContent(baseline, "Number"), 0) : undefined,
+ start: textContent(baseline, "Start") || undefined,
+ finish: textContent(baseline, "Finish") || undefined,
+ work: textContent(baseline, "Work") || undefined,
+ cost: textContent(baseline, "Cost") ? parseNumber(textContent(baseline, "Cost"), 0) : undefined
+ })),
+ timephasedData: Array.from(resource.getElementsByTagName("TimephasedData")).map((timephasedData) => ({
+ type: textContent(timephasedData, "Type") ? parseNumber(textContent(timephasedData, "Type"), 0) : undefined,
+ uid: textContent(timephasedData, "UID") || undefined,
+ start: textContent(timephasedData, "Start") || undefined,
+ finish: textContent(timephasedData, "Finish") || undefined,
+ unit: textContent(timephasedData, "Unit") ? parseNumber(textContent(timephasedData, "Unit"), 0) : undefined,
+ value: textContent(timephasedData, "Value") || undefined
+ }))
+ })),
+ assignments: assignments.map((assignment) => ({
+ uid: textContent(assignment, "UID"),
+ taskUid: textContent(assignment, "TaskUID"),
+ resourceUid: textContent(assignment, "ResourceUID"),
+ start: textContent(assignment, "Start") || undefined,
+ finish: textContent(assignment, "Finish") || undefined,
+ startVariance: textContent(assignment, "StartVariance") || undefined,
+ finishVariance: textContent(assignment, "FinishVariance") || undefined,
+ delay: textContent(assignment, "Delay") || undefined,
+ milestone: textContent(assignment, "Milestone") ? parseBoolean(textContent(assignment, "Milestone")) : undefined,
+ workContour: textContent(assignment, "WorkContour") ? parseNumber(textContent(assignment, "WorkContour"), 0) : undefined,
+ units: parseNumber(textContent(assignment, "Units"), 0),
+ work: textContent(assignment, "Work") || undefined,
+ cost: textContent(assignment, "Cost") ? parseNumber(textContent(assignment, "Cost"), 0) : undefined,
+ actualCost: textContent(assignment, "ActualCost") ? parseNumber(textContent(assignment, "ActualCost"), 0) : undefined,
+ remainingCost: textContent(assignment, "RemainingCost") ? parseNumber(textContent(assignment, "RemainingCost"), 0) : undefined,
+ percentWorkComplete: textContent(assignment, "PercentWorkComplete") ? parseNumber(textContent(assignment, "PercentWorkComplete"), 0) : undefined,
+ overtimeWork: textContent(assignment, "OvertimeWork") || undefined,
+ actualOvertimeWork: textContent(assignment, "ActualOvertimeWork") || undefined,
+ actualWork: textContent(assignment, "ActualWork") || undefined,
+ remainingWork: textContent(assignment, "RemainingWork") || undefined,
+ extendedAttributes: Array.from(assignment.getElementsByTagName("ExtendedAttribute")).map((attribute) => ({
+ fieldID: textContent(attribute, "FieldID") || undefined,
+ value: textContent(attribute, "Value") || undefined
+ })),
+ baselines: Array.from(assignment.getElementsByTagName("Baseline")).map((baseline) => ({
+ number: textContent(baseline, "Number") ? parseNumber(textContent(baseline, "Number"), 0) : undefined,
+ start: textContent(baseline, "Start") || undefined,
+ finish: textContent(baseline, "Finish") || undefined,
+ work: textContent(baseline, "Work") || undefined,
+ cost: textContent(baseline, "Cost") ? parseNumber(textContent(baseline, "Cost"), 0) : undefined
+ })),
+ timephasedData: Array.from(assignment.getElementsByTagName("TimephasedData")).map((timephasedData) => ({
+ type: textContent(timephasedData, "Type") ? parseNumber(textContent(timephasedData, "Type"), 0) : undefined,
+ uid: textContent(timephasedData, "UID") || undefined,
+ start: textContent(timephasedData, "Start") || undefined,
+ finish: textContent(timephasedData, "Finish") || undefined,
+ unit: textContent(timephasedData, "Unit") ? parseNumber(textContent(timephasedData, "Unit"), 0) : undefined,
+ value: textContent(timephasedData, "Value") || undefined
+ }))
+ }))
+ }));
+ }
+ function appendTextElement(doc, parent, name, value) {
+ if (value === undefined || value === "") {
+ return;
+ }
+ const element = doc.createElement(name);
+ if (typeof value === "boolean") {
+ element.textContent = value ? "1" : "0";
+ }
+ else {
+ element.textContent = String(value);
+ }
+ parent.appendChild(element);
+ }
+ function formatXml(xml) {
+ const normalized = xml.replace(/>\s*<").trim();
+ const tokens = normalized.replace(/>\n<").split("\n");
+ let indentLevel = 0;
+ const formatted = [];
+ for (const rawToken of tokens) {
+ const token = rawToken.trim();
+ if (!token) {
+ continue;
+ }
+ if (/^<\//.test(token)) {
+ indentLevel = Math.max(indentLevel - 1, 0);
+ }
+ formatted.push(`${" ".repeat(indentLevel)}${token}`);
+ if (/^<[^!?/][^>]*[^/]>\s*$/.test(token) && !/<\/[^>]+>$/.test(token)) {
+ indentLevel += 1;
+ }
+ }
+ return formatted.join("\n");
+ }
+ function exportMsProjectXml(model) {
+ const normalizedModel = ensureDefaultProjectCalendar(normalizeProjectModel(model));
+ const doc = document.implementation.createDocument("", "Project", null);
+ const project = doc.documentElement;
+ project.setAttribute("xmlns", "http://schemas.microsoft.com/project");
+ appendTextElement(doc, project, "Name", normalizedModel.project.name);
+ appendTextElement(doc, project, "Title", normalizedModel.project.title);
+ appendTextElement(doc, project, "Company", normalizedModel.project.company);
+ appendTextElement(doc, project, "Author", normalizedModel.project.author);
+ appendTextElement(doc, project, "CreationDate", normalizedModel.project.creationDate);
+ appendTextElement(doc, project, "LastSaved", normalizedModel.project.lastSaved);
+ appendTextElement(doc, project, "SaveVersion", normalizedModel.project.saveVersion);
+ appendTextElement(doc, project, "CurrentDate", normalizedModel.project.currentDate);
+ appendTextElement(doc, project, "StartDate", normalizedModel.project.startDate);
+ appendTextElement(doc, project, "FinishDate", normalizedModel.project.finishDate);
+ appendTextElement(doc, project, "ScheduleFromStart", normalizedModel.project.scheduleFromStart);
+ appendTextElement(doc, project, "DefaultStartTime", normalizedModel.project.defaultStartTime);
+ appendTextElement(doc, project, "DefaultFinishTime", normalizedModel.project.defaultFinishTime);
+ appendTextElement(doc, project, "MinutesPerDay", normalizedModel.project.minutesPerDay);
+ appendTextElement(doc, project, "MinutesPerWeek", normalizedModel.project.minutesPerWeek);
+ appendTextElement(doc, project, "DaysPerMonth", normalizedModel.project.daysPerMonth);
+ appendTextElement(doc, project, "StatusDate", normalizedModel.project.statusDate);
+ appendTextElement(doc, project, "WeekStartDay", normalizedModel.project.weekStartDay);
+ appendTextElement(doc, project, "WorkFormat", normalizedModel.project.workFormat);
+ appendTextElement(doc, project, "DurationFormat", normalizedModel.project.durationFormat);
+ appendTextElement(doc, project, "CurrencyCode", normalizedModel.project.currencyCode);
+ appendTextElement(doc, project, "CurrencyDigits", normalizedModel.project.currencyDigits);
+ appendTextElement(doc, project, "CurrencySymbol", normalizedModel.project.currencySymbol);
+ appendTextElement(doc, project, "CurrencySymbolPosition", normalizedModel.project.currencySymbolPosition);
+ appendTextElement(doc, project, "FYStartDate", normalizedModel.project.fyStartDate);
+ appendTextElement(doc, project, "FiscalYearStart", normalizedModel.project.fiscalYearStart);
+ appendTextElement(doc, project, "CriticalSlackLimit", normalizedModel.project.criticalSlackLimit);
+ appendTextElement(doc, project, "DefaultTaskType", normalizedModel.project.defaultTaskType);
+ appendTextElement(doc, project, "DefaultFixedCostAccrual", normalizedModel.project.defaultFixedCostAccrual);
+ appendTextElement(doc, project, "DefaultStandardRate", normalizedModel.project.defaultStandardRate);
+ appendTextElement(doc, project, "DefaultOvertimeRate", normalizedModel.project.defaultOvertimeRate);
+ appendTextElement(doc, project, "DefaultTaskEVMethod", normalizedModel.project.defaultTaskEVMethod);
+ appendTextElement(doc, project, "NewTaskStartDate", normalizedModel.project.newTaskStartDate);
+ appendTextElement(doc, project, "NewTasksAreManual", normalizedModel.project.newTasksAreManual);
+ appendTextElement(doc, project, "NewTasksEffortDriven", normalizedModel.project.newTasksEffortDriven);
+ appendTextElement(doc, project, "NewTasksEstimated", normalizedModel.project.newTasksEstimated);
+ appendTextElement(doc, project, "ActualsInSync", normalizedModel.project.actualsInSync);
+ appendTextElement(doc, project, "EditableActualCosts", normalizedModel.project.editableActualCosts);
+ appendTextElement(doc, project, "HonorConstraints", normalizedModel.project.honorConstraints);
+ appendTextElement(doc, project, "InsertedProjectsLikeSummary", normalizedModel.project.insertedProjectsLikeSummary);
+ appendTextElement(doc, project, "MultipleCriticalPaths", normalizedModel.project.multipleCriticalPaths);
+ appendTextElement(doc, project, "TaskUpdatesResource", normalizedModel.project.taskUpdatesResource);
+ appendTextElement(doc, project, "UpdateManuallyScheduledTasksWhenEditingLinks", normalizedModel.project.updateManuallyScheduledTasksWhenEditingLinks);
+ appendTextElement(doc, project, "CalendarUID", normalizedModel.project.calendarUID);
+ if (normalizedModel.project.outlineCodes.length > 0) {
+ const outlineCodesElement = doc.createElement("OutlineCodes");
+ for (const outlineCode of normalizedModel.project.outlineCodes) {
+ const outlineCodeElement = doc.createElement("OutlineCode");
+ appendTextElement(doc, outlineCodeElement, "FieldID", outlineCode.fieldID);
+ appendTextElement(doc, outlineCodeElement, "FieldName", outlineCode.fieldName);
+ appendTextElement(doc, outlineCodeElement, "Alias", outlineCode.alias);
+ appendTextElement(doc, outlineCodeElement, "OnlyTableValues", outlineCode.onlyTableValues);
+ appendTextElement(doc, outlineCodeElement, "Enterprise", outlineCode.enterprise);
+ appendTextElement(doc, outlineCodeElement, "ResourceSubstitutionEnabled", outlineCode.resourceSubstitutionEnabled);
+ appendTextElement(doc, outlineCodeElement, "LeafOnly", outlineCode.leafOnly);
+ appendTextElement(doc, outlineCodeElement, "AllLevelsRequired", outlineCode.allLevelsRequired);
+ if (outlineCode.masks.length > 0) {
+ const masksElement = doc.createElement("Masks");
+ for (const mask of outlineCode.masks) {
+ const maskElement = doc.createElement("Mask");
+ appendTextElement(doc, maskElement, "Level", mask.level);
+ appendTextElement(doc, maskElement, "Mask", mask.mask);
+ appendTextElement(doc, maskElement, "Length", mask.length);
+ appendTextElement(doc, maskElement, "Sequence", mask.sequence);
+ masksElement.appendChild(maskElement);
+ }
+ outlineCodeElement.appendChild(masksElement);
+ }
+ if (outlineCode.values.length > 0) {
+ const valuesElement = doc.createElement("Values");
+ for (const value of outlineCode.values) {
+ const valueElement = doc.createElement("Value");
+ appendTextElement(doc, valueElement, "Value", value.value);
+ appendTextElement(doc, valueElement, "Description", value.description);
+ valuesElement.appendChild(valueElement);
+ }
+ outlineCodeElement.appendChild(valuesElement);
+ }
+ outlineCodesElement.appendChild(outlineCodeElement);
+ }
+ project.appendChild(outlineCodesElement);
+ }
+ if (normalizedModel.project.wbsMasks.length > 0) {
+ const wbsMasksElement = doc.createElement("WBSMasks");
+ for (const wbsMask of normalizedModel.project.wbsMasks) {
+ const wbsMaskElement = doc.createElement("WBSMask");
+ appendTextElement(doc, wbsMaskElement, "Level", wbsMask.level);
+ appendTextElement(doc, wbsMaskElement, "Mask", wbsMask.mask);
+ appendTextElement(doc, wbsMaskElement, "Length", wbsMask.length);
+ appendTextElement(doc, wbsMaskElement, "Sequence", wbsMask.sequence);
+ wbsMasksElement.appendChild(wbsMaskElement);
+ }
+ project.appendChild(wbsMasksElement);
+ }
+ if (normalizedModel.project.extendedAttributes.length > 0) {
+ const extendedAttributesElement = doc.createElement("ExtendedAttributes");
+ for (const attribute of normalizedModel.project.extendedAttributes) {
+ const extendedAttributeElement = doc.createElement("ExtendedAttribute");
+ appendTextElement(doc, extendedAttributeElement, "FieldID", attribute.fieldID);
+ appendTextElement(doc, extendedAttributeElement, "FieldName", attribute.fieldName);
+ appendTextElement(doc, extendedAttributeElement, "Alias", attribute.alias);
+ appendTextElement(doc, extendedAttributeElement, "CalculationType", attribute.calculationType);
+ appendTextElement(doc, extendedAttributeElement, "RestrictValues", attribute.restrictValues);
+ appendTextElement(doc, extendedAttributeElement, "AppendNewValues", attribute.appendNewValues);
+ extendedAttributesElement.appendChild(extendedAttributeElement);
+ }
+ project.appendChild(extendedAttributesElement);
+ }
+ const calendarsElement = doc.createElement("Calendars");
+ for (const calendar of normalizedModel.calendars) {
+ const calendarElement = doc.createElement("Calendar");
+ appendTextElement(doc, calendarElement, "UID", calendar.uid);
+ appendTextElement(doc, calendarElement, "Name", calendar.name);
+ appendTextElement(doc, calendarElement, "IsBaseCalendar", calendar.isBaseCalendar);
+ appendTextElement(doc, calendarElement, "IsBaselineCalendar", calendar.isBaselineCalendar);
+ appendTextElement(doc, calendarElement, "BaseCalendarUID", calendar.baseCalendarUID);
+ if (calendar.exceptions.length > 0) {
+ const exceptionsElement = doc.createElement("Exceptions");
+ for (const exception of calendar.exceptions) {
+ const exceptionElement = doc.createElement("Exception");
+ appendTextElement(doc, exceptionElement, "Name", exception.name);
+ appendTextElement(doc, exceptionElement, "FromDate", exception.fromDate);
+ appendTextElement(doc, exceptionElement, "ToDate", exception.toDate);
+ appendTextElement(doc, exceptionElement, "DayWorking", exception.dayWorking);
+ appendWorkingTimes(doc, exceptionElement, exception.workingTimes);
+ exceptionsElement.appendChild(exceptionElement);
+ }
+ calendarElement.appendChild(exceptionsElement);
+ }
+ if (calendar.workWeeks.length > 0) {
+ const workWeeksElement = doc.createElement("WorkWeeks");
+ for (const workWeek of calendar.workWeeks) {
+ const workWeekElement = doc.createElement("WorkWeek");
+ appendTextElement(doc, workWeekElement, "Name", workWeek.name);
+ appendTextElement(doc, workWeekElement, "FromDate", workWeek.fromDate);
+ appendTextElement(doc, workWeekElement, "ToDate", workWeek.toDate);
+ appendWeekDays(doc, workWeekElement, workWeek.weekDays);
+ workWeeksElement.appendChild(workWeekElement);
+ }
+ calendarElement.appendChild(workWeeksElement);
+ }
+ appendWeekDays(doc, calendarElement, calendar.weekDays);
+ calendarsElement.appendChild(calendarElement);
+ }
+ project.appendChild(calendarsElement);
+ const tasksElement = doc.createElement("Tasks");
+ for (const task of normalizedModel.tasks) {
+ const taskElement = doc.createElement("Task");
+ appendTextElement(doc, taskElement, "UID", task.uid);
+ appendTextElement(doc, taskElement, "ID", task.id);
+ appendTextElement(doc, taskElement, "Name", task.name);
+ appendTextElement(doc, taskElement, "OutlineLevel", task.outlineLevel);
+ appendTextElement(doc, taskElement, "OutlineNumber", task.outlineNumber);
+ appendTextElement(doc, taskElement, "WBS", task.wbs);
+ appendTextElement(doc, taskElement, "Type", task.type);
+ appendTextElement(doc, taskElement, "CalendarUID", task.calendarUID);
+ appendTextElement(doc, taskElement, "Priority", task.priority);
+ appendTextElement(doc, taskElement, "Start", task.start);
+ appendTextElement(doc, taskElement, "Finish", task.finish);
+ appendTextElement(doc, taskElement, "Duration", task.duration);
+ appendTextElement(doc, taskElement, "ActualStart", task.actualStart);
+ appendTextElement(doc, taskElement, "ActualFinish", task.actualFinish);
+ appendTextElement(doc, taskElement, "Deadline", task.deadline);
+ appendTextElement(doc, taskElement, "StartVariance", task.startVariance);
+ appendTextElement(doc, taskElement, "FinishVariance", task.finishVariance);
+ appendTextElement(doc, taskElement, "Work", task.work);
+ appendTextElement(doc, taskElement, "WorkVariance", task.workVariance);
+ appendTextElement(doc, taskElement, "TotalSlack", task.totalSlack);
+ appendTextElement(doc, taskElement, "FreeSlack", task.freeSlack);
+ appendTextElement(doc, taskElement, "Cost", task.cost);
+ appendTextElement(doc, taskElement, "ActualCost", task.actualCost);
+ appendTextElement(doc, taskElement, "RemainingCost", task.remainingCost);
+ appendTextElement(doc, taskElement, "RemainingWork", task.remainingWork);
+ appendTextElement(doc, taskElement, "ActualWork", task.actualWork);
+ appendTextElement(doc, taskElement, "ConstraintType", task.constraintType);
+ appendTextElement(doc, taskElement, "ConstraintDate", task.constraintDate);
+ appendTextElement(doc, taskElement, "Milestone", task.milestone);
+ appendTextElement(doc, taskElement, "Summary", task.summary);
+ appendTextElement(doc, taskElement, "Critical", task.critical);
+ appendTextElement(doc, taskElement, "PercentComplete", task.percentComplete);
+ appendTextElement(doc, taskElement, "PercentWorkComplete", task.percentWorkComplete);
+ appendTextElement(doc, taskElement, "Notes", task.notes);
+ for (const attribute of task.extendedAttributes) {
+ const extendedAttributeElement = doc.createElement("ExtendedAttribute");
+ appendTextElement(doc, extendedAttributeElement, "FieldID", attribute.fieldID);
+ appendTextElement(doc, extendedAttributeElement, "Value", attribute.value);
+ taskElement.appendChild(extendedAttributeElement);
+ }
+ for (const baseline of task.baselines) {
+ const baselineElement = doc.createElement("Baseline");
+ appendTextElement(doc, baselineElement, "Number", baseline.number);
+ appendTextElement(doc, baselineElement, "Start", baseline.start);
+ appendTextElement(doc, baselineElement, "Finish", baseline.finish);
+ appendTextElement(doc, baselineElement, "Work", baseline.work);
+ appendTextElement(doc, baselineElement, "Cost", baseline.cost);
+ taskElement.appendChild(baselineElement);
+ }
+ for (const timephasedData of task.timephasedData) {
+ const timephasedDataElement = doc.createElement("TimephasedData");
+ appendTextElement(doc, timephasedDataElement, "Type", timephasedData.type);
+ appendTextElement(doc, timephasedDataElement, "UID", timephasedData.uid);
+ appendTextElement(doc, timephasedDataElement, "Start", timephasedData.start);
+ appendTextElement(doc, timephasedDataElement, "Finish", timephasedData.finish);
+ appendTextElement(doc, timephasedDataElement, "Unit", timephasedData.unit);
+ appendTextElement(doc, timephasedDataElement, "Value", timephasedData.value);
+ taskElement.appendChild(timephasedDataElement);
+ }
+ for (const predecessor of task.predecessors) {
+ const predecessorElement = doc.createElement("PredecessorLink");
+ appendTextElement(doc, predecessorElement, "PredecessorUID", predecessor.predecessorUid);
+ appendTextElement(doc, predecessorElement, "Type", predecessor.type);
+ appendTextElement(doc, predecessorElement, "LinkLag", predecessor.linkLag);
+ taskElement.appendChild(predecessorElement);
+ }
+ tasksElement.appendChild(taskElement);
+ }
+ project.appendChild(tasksElement);
+ const resourcesElement = doc.createElement("Resources");
+ for (const resource of normalizedModel.resources) {
+ const resourceElement = doc.createElement("Resource");
+ appendTextElement(doc, resourceElement, "UID", resource.uid);
+ appendTextElement(doc, resourceElement, "ID", resource.id);
+ appendTextElement(doc, resourceElement, "Name", resource.name);
+ appendTextElement(doc, resourceElement, "Type", resource.type);
+ appendTextElement(doc, resourceElement, "Initials", resource.initials);
+ appendTextElement(doc, resourceElement, "Group", resource.group);
+ appendTextElement(doc, resourceElement, "WorkGroup", resource.workGroup);
+ appendTextElement(doc, resourceElement, "MaxUnits", resource.maxUnits);
+ appendTextElement(doc, resourceElement, "CalendarUID", resource.calendarUID);
+ appendTextElement(doc, resourceElement, "StandardRate", resource.standardRate);
+ appendTextElement(doc, resourceElement, "StandardRateFormat", resource.standardRateFormat);
+ appendTextElement(doc, resourceElement, "OvertimeRate", resource.overtimeRate);
+ appendTextElement(doc, resourceElement, "OvertimeRateFormat", resource.overtimeRateFormat);
+ appendTextElement(doc, resourceElement, "CostPerUse", resource.costPerUse);
+ appendTextElement(doc, resourceElement, "Work", resource.work);
+ appendTextElement(doc, resourceElement, "ActualWork", resource.actualWork);
+ appendTextElement(doc, resourceElement, "RemainingWork", resource.remainingWork);
+ appendTextElement(doc, resourceElement, "Cost", resource.cost);
+ appendTextElement(doc, resourceElement, "ActualCost", resource.actualCost);
+ appendTextElement(doc, resourceElement, "RemainingCost", resource.remainingCost);
+ appendTextElement(doc, resourceElement, "PercentWorkComplete", resource.percentWorkComplete);
+ for (const attribute of resource.extendedAttributes) {
+ const extendedAttributeElement = doc.createElement("ExtendedAttribute");
+ appendTextElement(doc, extendedAttributeElement, "FieldID", attribute.fieldID);
+ appendTextElement(doc, extendedAttributeElement, "Value", attribute.value);
+ resourceElement.appendChild(extendedAttributeElement);
+ }
+ for (const baseline of resource.baselines) {
+ const baselineElement = doc.createElement("Baseline");
+ appendTextElement(doc, baselineElement, "Number", baseline.number);
+ appendTextElement(doc, baselineElement, "Start", baseline.start);
+ appendTextElement(doc, baselineElement, "Finish", baseline.finish);
+ appendTextElement(doc, baselineElement, "Work", baseline.work);
+ appendTextElement(doc, baselineElement, "Cost", baseline.cost);
+ resourceElement.appendChild(baselineElement);
+ }
+ for (const timephasedData of resource.timephasedData) {
+ const timephasedDataElement = doc.createElement("TimephasedData");
+ appendTextElement(doc, timephasedDataElement, "Type", timephasedData.type);
+ appendTextElement(doc, timephasedDataElement, "UID", timephasedData.uid);
+ appendTextElement(doc, timephasedDataElement, "Start", timephasedData.start);
+ appendTextElement(doc, timephasedDataElement, "Finish", timephasedData.finish);
+ appendTextElement(doc, timephasedDataElement, "Unit", timephasedData.unit);
+ appendTextElement(doc, timephasedDataElement, "Value", timephasedData.value);
+ resourceElement.appendChild(timephasedDataElement);
+ }
+ resourcesElement.appendChild(resourceElement);
+ }
+ project.appendChild(resourcesElement);
+ const assignmentsElement = doc.createElement("Assignments");
+ for (const assignment of normalizedModel.assignments) {
+ const assignmentElement = doc.createElement("Assignment");
+ appendTextElement(doc, assignmentElement, "UID", assignment.uid);
+ appendTextElement(doc, assignmentElement, "TaskUID", assignment.taskUid);
+ appendTextElement(doc, assignmentElement, "ResourceUID", assignment.resourceUid);
+ appendTextElement(doc, assignmentElement, "Start", assignment.start);
+ appendTextElement(doc, assignmentElement, "Finish", assignment.finish);
+ appendTextElement(doc, assignmentElement, "StartVariance", assignment.startVariance);
+ appendTextElement(doc, assignmentElement, "FinishVariance", assignment.finishVariance);
+ appendTextElement(doc, assignmentElement, "Delay", assignment.delay);
+ appendTextElement(doc, assignmentElement, "Milestone", assignment.milestone);
+ appendTextElement(doc, assignmentElement, "WorkContour", assignment.workContour);
+ appendTextElement(doc, assignmentElement, "Units", assignment.units);
+ appendTextElement(doc, assignmentElement, "Work", assignment.work);
+ appendTextElement(doc, assignmentElement, "Cost", assignment.cost);
+ appendTextElement(doc, assignmentElement, "ActualCost", assignment.actualCost);
+ appendTextElement(doc, assignmentElement, "RemainingCost", assignment.remainingCost);
+ appendTextElement(doc, assignmentElement, "PercentWorkComplete", assignment.percentWorkComplete);
+ appendTextElement(doc, assignmentElement, "OvertimeWork", assignment.overtimeWork);
+ appendTextElement(doc, assignmentElement, "ActualOvertimeWork", assignment.actualOvertimeWork);
+ appendTextElement(doc, assignmentElement, "ActualWork", assignment.actualWork);
+ appendTextElement(doc, assignmentElement, "RemainingWork", assignment.remainingWork);
+ for (const attribute of assignment.extendedAttributes) {
+ const extendedAttributeElement = doc.createElement("ExtendedAttribute");
+ appendTextElement(doc, extendedAttributeElement, "FieldID", attribute.fieldID);
+ appendTextElement(doc, extendedAttributeElement, "Value", attribute.value);
+ assignmentElement.appendChild(extendedAttributeElement);
+ }
+ for (const baseline of assignment.baselines) {
+ const baselineElement = doc.createElement("Baseline");
+ appendTextElement(doc, baselineElement, "Number", baseline.number);
+ appendTextElement(doc, baselineElement, "Start", baseline.start);
+ appendTextElement(doc, baselineElement, "Finish", baseline.finish);
+ appendTextElement(doc, baselineElement, "Work", baseline.work);
+ appendTextElement(doc, baselineElement, "Cost", baseline.cost);
+ assignmentElement.appendChild(baselineElement);
+ }
+ for (const timephasedData of assignment.timephasedData) {
+ const timephasedDataElement = doc.createElement("TimephasedData");
+ appendTextElement(doc, timephasedDataElement, "Type", timephasedData.type);
+ appendTextElement(doc, timephasedDataElement, "UID", timephasedData.uid);
+ appendTextElement(doc, timephasedDataElement, "Start", timephasedData.start);
+ appendTextElement(doc, timephasedDataElement, "Finish", timephasedData.finish);
+ appendTextElement(doc, timephasedDataElement, "Unit", timephasedData.unit);
+ appendTextElement(doc, timephasedDataElement, "Value", timephasedData.value);
+ assignmentElement.appendChild(timephasedDataElement);
+ }
+ assignmentsElement.appendChild(assignmentElement);
+ }
+ project.appendChild(assignmentsElement);
+ const serializer = new XMLSerializer();
+ const serialized = serializer.serializeToString(doc);
+ return `\n${formatXml(serialized)}\n`;
+ }
+ function normalizeProjectModel(model) {
+ return JSON.parse(JSON.stringify(model));
+ }
+ function validateProjectModel(model) {
+ const issues = [];
+ const taskUidSet = new Set();
+ const taskIdSet = new Set();
+ const resourceUidSet = new Set();
+ const calendarUidSet = new Set();
+ if (!model.project.name) {
+ issues.push({ level: "warning", scope: "project", message: "Project Name が空です" });
+ }
+ if (model.project.saveVersion !== undefined && model.project.saveVersion < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project SaveVersion は 0 以上が望ましいです" });
+ }
+ if (!model.project.startDate) {
+ issues.push({ level: "warning", scope: "project", message: "Project StartDate が空です" });
+ }
+ if (!model.project.finishDate) {
+ issues.push({ level: "warning", scope: "project", message: "Project FinishDate が空です" });
+ }
+ if (model.project.minutesPerDay !== undefined && model.project.minutesPerDay <= 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project MinutesPerDay は正の値が望ましいです" });
+ }
+ if (model.project.minutesPerWeek !== undefined && model.project.minutesPerWeek <= 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project MinutesPerWeek は正の値が望ましいです" });
+ }
+ if (model.project.daysPerMonth !== undefined && model.project.daysPerMonth <= 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project DaysPerMonth は正の値が望ましいです" });
+ }
+ if (model.project.weekStartDay !== undefined && (model.project.weekStartDay < 1 || model.project.weekStartDay > 7)) {
+ issues.push({ level: "warning", scope: "project", message: "Project WeekStartDay は 1..7 が望ましいです" });
+ }
+ if (model.project.workFormat !== undefined && model.project.workFormat < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project WorkFormat は 0 以上が望ましいです" });
+ }
+ if (model.project.durationFormat !== undefined && model.project.durationFormat < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project DurationFormat は 0 以上が望ましいです" });
+ }
+ if (model.project.currencyDigits !== undefined && model.project.currencyDigits < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project CurrencyDigits は 0 以上が望ましいです" });
+ }
+ if (model.project.currencySymbolPosition !== undefined && model.project.currencySymbolPosition < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project CurrencySymbolPosition は 0 以上が望ましいです" });
+ }
+ if (model.project.fyStartDate !== undefined && !parseDateValue(model.project.fyStartDate)) {
+ issues.push({ level: "warning", scope: "project", message: "Project FYStartDate の日付形式が解釈できません" });
+ }
+ if (model.project.criticalSlackLimit !== undefined && model.project.criticalSlackLimit < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project CriticalSlackLimit は 0 以上が望ましいです" });
+ }
+ if (model.project.defaultTaskType !== undefined && model.project.defaultTaskType < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project DefaultTaskType は 0 以上が望ましいです" });
+ }
+ if (model.project.defaultFixedCostAccrual !== undefined && model.project.defaultFixedCostAccrual < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project DefaultFixedCostAccrual は 0 以上が望ましいです" });
+ }
+ if (model.project.defaultTaskEVMethod !== undefined && model.project.defaultTaskEVMethod < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project DefaultTaskEVMethod は 0 以上が望ましいです" });
+ }
+ if (model.project.newTaskStartDate !== undefined && model.project.newTaskStartDate < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project NewTaskStartDate は 0 以上が望ましいです" });
+ }
+ for (const outlineCode of model.project.outlineCodes) {
+ if (!outlineCode.fieldID && !outlineCode.fieldName) {
+ issues.push({ level: "warning", scope: "project", message: "Project OutlineCode は FieldID または FieldName を持つことが望ましいです" });
+ }
+ for (const mask of outlineCode.masks) {
+ if (mask.level < 1) {
+ issues.push({ level: "warning", scope: "project", message: "Project OutlineCode Mask Level は 1 以上が望ましいです" });
+ }
+ }
+ }
+ for (const wbsMask of model.project.wbsMasks) {
+ if (wbsMask.level < 1) {
+ issues.push({ level: "warning", scope: "project", message: "Project WBSMask Level は 1 以上が望ましいです" });
+ }
+ }
+ for (const attribute of model.project.extendedAttributes) {
+ if (!attribute.fieldID && !attribute.fieldName) {
+ issues.push({ level: "warning", scope: "project", message: "Project ExtendedAttribute は FieldID または FieldName を持つことが望ましいです" });
+ }
+ if (attribute.calculationType !== undefined && attribute.calculationType < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project ExtendedAttribute CalculationType は 0 以上が望ましいです" });
+ }
+ }
+ for (const calendar of model.calendars) {
+ if (!calendar.uid) {
+ issues.push({ level: "error", scope: "calendars", message: "Calendar UID が空です" });
+ }
+ if (calendar.isBaselineCalendar !== undefined && !calendar.isBaseCalendar && calendar.isBaselineCalendar) {
+ issues.push({
+ level: "warning",
+ scope: "calendars",
+ message: `Calendar IsBaselineCalendar は通常 BaseCalendar と整合していることが望ましいです: ${describeCalendar(calendar)}`
+ });
+ }
+ if (calendarUidSet.has(calendar.uid)) {
+ issues.push({ level: "error", scope: "calendars", message: `Calendar UID が重複しています: ${calendar.uid}` });
+ }
+ calendarUidSet.add(calendar.uid);
+ for (const weekDay of calendar.weekDays) {
+ if (weekDay.dayType < 1 || weekDay.dayType > 7) {
+ issues.push({
+ level: "warning",
+ scope: "calendars",
+ message: `Calendar WeekDay DayType が 1..7 の範囲外です: ${describeCalendar(calendar)}`
+ });
+ }
+ for (const workingTime of weekDay.workingTimes) {
+ if (!workingTime.fromTime || !workingTime.toTime) {
+ issues.push({
+ level: "warning",
+ scope: "calendars",
+ message: `Calendar WorkingTime の時刻が不足しています: ${describeCalendar(calendar)}`
+ });
+ }
+ }
+ }
+ for (const exception of calendar.exceptions) {
+ const exceptionFrom = parseDateValue(exception.fromDate);
+ const exceptionTo = parseDateValue(exception.toDate);
+ if (exceptionFrom !== null && exceptionTo !== null && exceptionFrom > exceptionTo) {
+ issues.push({
+ level: "warning",
+ scope: "calendars",
+ message: `Calendar Exception FromDate が ToDate より後です: ${describeCalendar(calendar)}`
+ });
+ }
+ for (const workingTime of exception.workingTimes) {
+ if (!workingTime.fromTime || !workingTime.toTime) {
+ issues.push({
+ level: "warning",
+ scope: "calendars",
+ message: `Calendar Exception WorkingTime の時刻が不足しています: ${describeCalendar(calendar)}`
+ });
+ }
+ }
+ }
+ for (const workWeek of calendar.workWeeks) {
+ const workWeekFrom = parseDateValue(workWeek.fromDate);
+ const workWeekTo = parseDateValue(workWeek.toDate);
+ if (workWeekFrom !== null && workWeekTo !== null && workWeekFrom > workWeekTo) {
+ issues.push({
+ level: "warning",
+ scope: "calendars",
+ message: `Calendar WorkWeek FromDate が ToDate より後です: ${describeCalendar(calendar)}`
+ });
+ }
+ for (const weekDay of workWeek.weekDays) {
+ if (weekDay.dayType < 1 || weekDay.dayType > 7) {
+ issues.push({
+ level: "warning",
+ scope: "calendars",
+ message: `Calendar WorkWeek DayType が 1..7 の範囲外です: ${describeCalendar(calendar)}`
+ });
+ }
+ }
+ }
+ }
+ if (model.project.calendarUID && !calendarUidSet.has(model.project.calendarUID)) {
+ issues.push({
+ level: "error",
+ scope: "project",
+ message: `Project CalendarUID が既存 Calendar を指していません: ${model.project.calendarUID}`
+ });
+ }
+ for (const calendar of model.calendars) {
+ if (calendar.baseCalendarUID && !calendarUidSet.has(calendar.baseCalendarUID)) {
+ issues.push({
+ level: "warning",
+ scope: "calendars",
+ message: `Calendar BaseCalendarUID が既存 Calendar を指していません: ${describeCalendar(calendar)}`
+ });
+ }
+ if (calendar.baseCalendarUID && calendar.baseCalendarUID === calendar.uid) {
+ issues.push({
+ level: "warning",
+ scope: "calendars",
+ message: `Calendar BaseCalendarUID が自身を指しています: ${describeCalendar(calendar)}`
+ });
+ }
+ }
+ for (const task of model.tasks) {
+ if (!task.uid) {
+ issues.push({ level: "error", scope: "tasks", message: "Task UID が空です" });
+ }
+ if (!task.id) {
+ issues.push({ level: "error", scope: "tasks", message: `Task ID が空です: ${task.name || "(無名)"}` });
+ }
+ if (!task.name) {
+ if (!isPlaceholderUid(task.uid)) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task Name が空です: ${describeTask(task)}` });
+ }
+ }
+ if (taskIdSet.has(task.id)) {
+ issues.push({ level: "error", scope: "tasks", message: `Task ID が重複しています: ${task.id}` });
+ }
+ taskIdSet.add(task.id);
+ if (!task.start) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task Start が空です: ${describeTask(task)}` });
+ }
+ if (!task.finish) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task Finish が空です: ${describeTask(task)}` });
+ }
+ if (task.outlineLevel < 1 && !isPlaceholderUid(task.uid)) {
+ issues.push({ level: "error", scope: "tasks", message: `Task OutlineLevel が不正です: ${describeTask(task)}` });
+ }
+ if (task.calendarUID && !calendarUidSet.has(task.calendarUID)) {
+ issues.push({
+ level: "warning",
+ scope: "tasks",
+ message: `Task CalendarUID が既存 Calendar を指していません: ${describeTask(task)}`
+ });
+ }
+ if (task.outlineNumber && !isPlaceholderUid(task.uid)) {
+ const outlineParts = task.outlineNumber.split(".").filter(Boolean);
+ if (outlineParts.length !== task.outlineLevel) {
+ issues.push({
+ level: "warning",
+ scope: "tasks",
+ message: `Task OutlineNumber と OutlineLevel の整合が取れていません: ${describeTask(task)}`
+ });
+ }
+ }
+ if (task.percentComplete < 0 || task.percentComplete > 100) {
+ issues.push({
+ level: "warning",
+ scope: "tasks",
+ message: `Task PercentComplete が 0..100 の範囲外です: ${describeTask(task)}`
+ });
+ }
+ if (task.percentWorkComplete !== undefined &&
+ (task.percentWorkComplete < 0 || task.percentWorkComplete > 100)) {
+ issues.push({
+ level: "warning",
+ scope: "tasks",
+ message: `Task PercentWorkComplete が 0..100 の範囲外です: ${describeTask(task)}`
+ });
+ }
+ const taskStart = parseDateValue(task.start);
+ const taskFinish = parseDateValue(task.finish);
+ const taskActualStart = parseDateValue(task.actualStart);
+ const taskActualFinish = parseDateValue(task.actualFinish);
+ const taskDeadline = parseDateValue(task.deadline);
+ if (taskStart !== null && taskFinish !== null && taskStart > taskFinish) {
+ issues.push({
+ level: "warning",
+ scope: "tasks",
+ message: `Task Start が Finish より後です: ${describeTask(task)}`
+ });
+ }
+ if (taskFinish !== null && taskDeadline !== null && taskFinish > taskDeadline) {
+ issues.push({
+ level: "warning",
+ scope: "tasks",
+ message: `Task Finish が Deadline より後です: ${describeTask(task)}`
+ });
+ }
+ if (taskActualStart !== null && taskActualFinish !== null && taskActualStart > taskActualFinish) {
+ issues.push({
+ level: "warning",
+ scope: "tasks",
+ message: `Task ActualStart が ActualFinish より後です: ${describeTask(task)}`
+ });
+ }
+ if (taskUidSet.has(task.uid)) {
+ issues.push({ level: "error", scope: "tasks", message: `Task UID が重複しています: ${task.uid}` });
+ }
+ for (const attribute of task.extendedAttributes) {
+ if (!attribute.fieldID) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task ExtendedAttribute に FieldID がありません: ${describeTask(task)}` });
+ }
+ }
+ for (const baseline of task.baselines) {
+ if (baseline.number !== undefined && baseline.number < 0) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task Baseline Number は 0 以上が望ましいです: ${describeTask(task)}` });
+ }
+ const baselineStart = parseDateValue(baseline.start);
+ const baselineFinish = parseDateValue(baseline.finish);
+ if (baselineStart !== null && baselineFinish !== null && baselineStart > baselineFinish) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task Baseline Start が Finish より後です: ${describeTask(task)}` });
+ }
+ }
+ for (const timephasedData of task.timephasedData) {
+ if (timephasedData.type !== undefined && timephasedData.type < 0) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task TimephasedData Type は 0 以上が望ましいです: ${describeTask(task)}` });
+ }
+ const timephasedStart = parseDateValue(timephasedData.start);
+ const timephasedFinish = parseDateValue(timephasedData.finish);
+ if (timephasedStart !== null && timephasedFinish !== null && timephasedStart > timephasedFinish) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task TimephasedData Start が Finish より後です: ${describeTask(task)}` });
+ }
+ }
+ taskUidSet.add(task.uid);
+ if (task.priority !== undefined && (task.priority < 0 || task.priority > 1000)) {
+ issues.push({
+ level: "warning",
+ scope: "tasks",
+ message: `Task Priority が 0..1000 の範囲外です: ${describeTask(task)}`
+ });
+ }
+ if (task.cost !== undefined && task.cost < 0) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task Cost が負値です: ${describeTask(task)}` });
+ }
+ if (task.actualCost !== undefined && task.actualCost < 0) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task ActualCost が負値です: ${describeTask(task)}` });
+ }
+ if (task.remainingCost !== undefined && task.remainingCost < 0) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task RemainingCost が負値です: ${describeTask(task)}` });
+ }
+ }
+ const taskOrderIssue = detectTaskOrderIssue(model.tasks);
+ if (taskOrderIssue) {
+ issues.push({
+ level: "warning",
+ scope: "tasks",
+ message: `Task の並び順が OutlineNumber 順と一致していない可能性があります: ${describeTask(taskOrderIssue.current)} (直前: ${describeTask(taskOrderIssue.previous)})`
+ });
+ }
+ for (const resource of model.resources) {
+ if (!resource.uid) {
+ issues.push({ level: "error", scope: "resources", message: "Resource UID が空です" });
+ }
+ if (!resource.name) {
+ if (!isPlaceholderUid(resource.uid)) {
+ issues.push({ level: "warning", scope: "resources", message: `Resource Name が空です: ${describeResource(resource)}` });
+ }
+ }
+ if (resourceUidSet.has(resource.uid)) {
+ issues.push({ level: "error", scope: "resources", message: `Resource UID が重複しています: ${resource.uid}` });
+ }
+ resourceUidSet.add(resource.uid);
+ if (resource.calendarUID && !calendarUidSet.has(resource.calendarUID)) {
+ issues.push({
+ level: "warning",
+ scope: "resources",
+ message: `Resource CalendarUID が既存 Calendar を指していません: ${describeResource(resource)}`
+ });
+ }
+ if (resource.workGroup !== undefined && resource.workGroup < 0) {
+ issues.push({
+ level: "warning",
+ scope: "resources",
+ message: `Resource WorkGroup は 0 以上が望ましいです: ${describeResource(resource)}`
+ });
+ }
+ if (resource.overtimeRateFormat !== undefined && resource.overtimeRateFormat < 0) {
+ issues.push({
+ level: "warning",
+ scope: "resources",
+ message: `Resource OvertimeRateFormat は 0 以上が望ましいです: ${describeResource(resource)}`
+ });
+ }
+ if (resource.cost !== undefined && resource.cost < 0) {
+ issues.push({ level: "warning", scope: "resources", message: `Resource Cost が負値です: ${describeResource(resource)}` });
+ }
+ if (resource.actualCost !== undefined && resource.actualCost < 0) {
+ issues.push({ level: "warning", scope: "resources", message: `Resource ActualCost が負値です: ${describeResource(resource)}` });
+ }
+ if (resource.remainingCost !== undefined && resource.remainingCost < 0) {
+ issues.push({ level: "warning", scope: "resources", message: `Resource RemainingCost が負値です: ${describeResource(resource)}` });
+ }
+ if (resource.percentWorkComplete !== undefined &&
+ (resource.percentWorkComplete < 0 || resource.percentWorkComplete > 100)) {
+ issues.push({
+ level: "warning",
+ scope: "resources",
+ message: `Resource PercentWorkComplete が 0..100 の範囲外です: ${describeResource(resource)}`
+ });
+ }
+ for (const attribute of resource.extendedAttributes) {
+ if (!attribute.fieldID) {
+ issues.push({ level: "warning", scope: "resources", message: `Resource ExtendedAttribute に FieldID がありません: ${describeResource(resource)}` });
+ }
+ }
+ for (const baseline of resource.baselines) {
+ if (baseline.number !== undefined && baseline.number < 0) {
+ issues.push({
+ level: "warning",
+ scope: "resources",
+ message: `Resource Baseline Number は 0 以上が望ましいです: ${describeResource(resource)}`
+ });
+ }
+ const baselineStart = parseDateValue(baseline.start);
+ const baselineFinish = parseDateValue(baseline.finish);
+ if (baselineStart !== null && baselineFinish !== null && baselineStart > baselineFinish) {
+ issues.push({
+ level: "warning",
+ scope: "resources",
+ message: `Resource Baseline Start が Finish より後です: ${describeResource(resource)}`
+ });
+ }
+ }
+ for (const timephasedData of resource.timephasedData) {
+ if (timephasedData.type !== undefined && timephasedData.type < 0) {
+ issues.push({
+ level: "warning",
+ scope: "resources",
+ message: `Resource TimephasedData Type は 0 以上が望ましいです: ${describeResource(resource)}`
+ });
+ }
+ const timephasedStart = parseDateValue(timephasedData.start);
+ const timephasedFinish = parseDateValue(timephasedData.finish);
+ if (timephasedStart !== null && timephasedFinish !== null && timephasedStart > timephasedFinish) {
+ issues.push({
+ level: "warning",
+ scope: "resources",
+ message: `Resource TimephasedData Start が Finish より後です: ${describeResource(resource)}`
+ });
+ }
+ }
+ }
+ for (const task of model.tasks) {
+ for (const predecessor of task.predecessors) {
+ if (!taskUidSet.has(predecessor.predecessorUid)) {
+ issues.push({
+ level: "error",
+ scope: "tasks",
+ message: `PredecessorUID が既存 Task を指していません: ${describeTask(task)}, ${describeTaskRef(model, predecessor.predecessorUid)}`
+ });
+ }
+ }
+ }
+ for (const assignment of model.assignments) {
+ if (!assignment.uid) {
+ issues.push({ level: "warning", scope: "assignments", message: "Assignment UID が空です" });
+ }
+ if (!taskUidSet.has(assignment.taskUid)) {
+ issues.push({
+ level: "error",
+ scope: "assignments",
+ message: `Assignment TaskUID が既存 Task を指していません: ${describeAssignment(assignment)}, ${describeTaskRef(model, assignment.taskUid)}`
+ });
+ }
+ if (!resourceUidSet.has(assignment.resourceUid) && !isUnassignedResourceUid(assignment.resourceUid)) {
+ issues.push({
+ level: "error",
+ scope: "assignments",
+ message: `Assignment ResourceUID が既存 Resource を指していません: ${describeAssignment(assignment)}, ${describeTaskRef(model, assignment.taskUid)}, ${describeResourceRef(model, assignment.resourceUid)}`
+ });
+ }
+ if (!assignment.start) {
+ issues.push({ level: "warning", scope: "assignments", message: `Assignment Start が空です: ${describeAssignment(assignment)}` });
+ }
+ if (!assignment.finish) {
+ issues.push({ level: "warning", scope: "assignments", message: `Assignment Finish が空です: ${describeAssignment(assignment)}` });
+ }
+ const assignmentStart = parseDateValue(assignment.start);
+ const assignmentFinish = parseDateValue(assignment.finish);
+ if (assignmentStart !== null && assignmentFinish !== null && assignmentStart > assignmentFinish) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment Start が Finish より後です: ${describeAssignment(assignment)}`
+ });
+ }
+ if (assignment.units !== undefined && assignment.units < 0) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment Units が負値です: ${describeAssignment(assignment)}`
+ });
+ }
+ if (assignment.cost !== undefined && assignment.cost < 0) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment Cost が負値です: ${describeAssignment(assignment)}`
+ });
+ }
+ if (assignment.actualCost !== undefined && assignment.actualCost < 0) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment ActualCost が負値です: ${describeAssignment(assignment)}`
+ });
+ }
+ if (assignment.remainingCost !== undefined && assignment.remainingCost < 0) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment RemainingCost が負値です: ${describeAssignment(assignment)}`
+ });
+ }
+ if (assignment.percentWorkComplete !== undefined &&
+ (assignment.percentWorkComplete < 0 || assignment.percentWorkComplete > 100)) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment PercentWorkComplete が 0..100 の範囲外です: ${describeAssignment(assignment)}`
+ });
+ }
+ if (assignment.overtimeWork !== undefined && !assignment.overtimeWork) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment OvertimeWork が空です: ${describeAssignment(assignment)}`
+ });
+ }
+ if (assignment.actualOvertimeWork !== undefined && !assignment.actualOvertimeWork) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment ActualOvertimeWork が空です: ${describeAssignment(assignment)}`
+ });
+ }
+ if (assignment.workContour !== undefined && assignment.workContour < 0) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment WorkContour は 0 以上が望ましいです: ${describeAssignment(assignment)}`
+ });
+ }
+ if (assignment.startVariance !== undefined && !assignment.startVariance) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment StartVariance が空です: ${describeAssignment(assignment)}`
+ });
+ }
+ for (const attribute of assignment.extendedAttributes) {
+ if (!attribute.fieldID) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment ExtendedAttribute に FieldID がありません: ${describeAssignment(assignment)}`
+ });
+ }
+ }
+ for (const baseline of assignment.baselines) {
+ if (baseline.number !== undefined && baseline.number < 0) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment Baseline Number は 0 以上が望ましいです: ${describeAssignment(assignment)}`
+ });
+ }
+ const baselineStart = parseDateValue(baseline.start);
+ const baselineFinish = parseDateValue(baseline.finish);
+ if (baselineStart !== null && baselineFinish !== null && baselineStart > baselineFinish) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment Baseline Start が Finish より後です: ${describeAssignment(assignment)}`
+ });
+ }
+ }
+ for (const timephasedData of assignment.timephasedData) {
+ if (timephasedData.type !== undefined && timephasedData.type < 0) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment TimephasedData Type は 0 以上が望ましいです: ${describeAssignment(assignment)}`
+ });
+ }
+ const timephasedStart = parseDateValue(timephasedData.start);
+ const timephasedFinish = parseDateValue(timephasedData.finish);
+ if (timephasedStart !== null && timephasedFinish !== null && timephasedStart > timephasedFinish) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment TimephasedData Start が Finish より後です: ${describeAssignment(assignment)}`
+ });
+ }
+ }
+ if (assignment.finishVariance !== undefined && !assignment.finishVariance) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment FinishVariance が空です: ${describeAssignment(assignment)}`
+ });
+ }
+ }
+ return issues;
+ }
+ globalThis.__mikuprojectXml = {
+ SAMPLE_XML,
+ SAMPLE_PROJECT_DRAFT_VIEW,
+ parseXmlDocument,
+ importMsProjectXml,
+ importCsvParentId,
+ exportMsProjectXml,
+ exportMermaidGantt,
+ buildProjectDraftRequest,
+ importProjectDraftView,
+ exportProjectOverviewView,
+ exportPhaseDetailView,
+ exportCsvParentId,
+ normalizeProjectModel,
+ validateProjectModel
+ };
+})();
diff --git a/src/js/native-svg.js b/src/js/native-svg.js
new file mode 100644
index 0000000..80f2303
--- /dev/null
+++ b/src/js/native-svg.js
@@ -0,0 +1,335 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ const DAY_WIDTH = 56;
+ const LIST_LABEL_WIDTH = 360;
+ const NEAR_LEFT_LABEL_WIDTH = 0;
+ const NEAR_RIGHT_LABEL_WIDTH = 0;
+ const HEADER_HEIGHT = 82;
+ const ROW_HEIGHT = 38;
+ const LEFT_PADDING = 0;
+ const TOP_PADDING = 22;
+ const RIGHT_PADDING = 0;
+ const BOTTOM_PADDING = 28;
+ function exportNativeSvg(model, options = {}) {
+ const labelMode = options.labelMode || "near";
+ const holidaySet = new Set([
+ ...collectWbsHolidayDates(model),
+ ...(options.holidayDates || []).map((day) => String(day || "").slice(0, 10)).filter(Boolean)
+ ]);
+ const nonWorkingDayTypes = collectProjectNonWorkingDayTypes(model);
+ const dateBand = buildDisplayDateBand(model.project.startDate, model.project.finishDate, model.project.currentDate, options.displayDaysBeforeBaseDate, options.displayDaysAfterBaseDate, holidaySet, nonWorkingDayTypes, options.useBusinessDaysForDisplayRange);
+ const rows = buildTaskRows(model.tasks, dateBand);
+ const chartWidth = dateBand.length * DAY_WIDTH;
+ const leftLabelWidth = labelMode === "list" ? LIST_LABEL_WIDTH : NEAR_LEFT_LABEL_WIDTH;
+ const rightLabelWidth = labelMode === "list" ? 0 : NEAR_RIGHT_LABEL_WIDTH;
+ const chartOriginXBase = LEFT_PADDING + leftLabelWidth;
+ const labelPlacements = rows.map((row) => resolveLabelPlacement(row, chartOriginXBase, chartWidth, labelMode));
+ const labelMinX = labelPlacements.reduce((min, placement) => {
+ const placementMinX = placement.anchor === "start" ? placement.x : placement.x - placement.width;
+ return Math.min(min, placementMinX);
+ }, chartOriginXBase);
+ const labelMaxX = labelPlacements.reduce((max, placement) => {
+ const placementMaxX = placement.anchor === "start" ? placement.x + placement.width : placement.x;
+ return Math.max(max, placementMaxX);
+ }, chartOriginXBase + chartWidth + rightLabelWidth);
+ const shiftX = labelMode === "near" ? Math.max(0, -labelMinX) : 0;
+ const chartOriginX = chartOriginXBase + shiftX;
+ const svgWidth = (labelMaxX + shiftX) + RIGHT_PADDING;
+ const svgHeight = TOP_PADDING + HEADER_HEIGHT + (rows.length * ROW_HEIGHT) + BOTTOM_PADDING;
+ const todayIndex = indexOfDate(dateBand, model.project.currentDate);
+ const parts = [
+ ``,
+ "",
+ ` `,
+ `${escapeXml(model.project.name || "-")} `
+ ];
+ const chartOriginY = TOP_PADDING + HEADER_HEIGHT;
+ for (let index = 0; index < dateBand.length; index += 1) {
+ const day = dateBand[index];
+ const x = chartOriginX + (index * DAY_WIDTH);
+ const isHoliday = holidaySet.has(day);
+ const isWeekend = isWeeklyNonWorkingDay(day, nonWorkingDayTypes);
+ const fill = isHoliday ? "#fce4ec" : (isWeekend ? "#eef3f8" : "#ffffff");
+ parts.push(` `);
+ parts.push(` `);
+ parts.push(`${escapeXml(formatSvgAxisDate(day))} `);
+ }
+ parts.push(` `);
+ if (todayIndex >= 0) {
+ const todayX = chartOriginX + (todayIndex * DAY_WIDTH) + (DAY_WIDTH / 2);
+ parts.push(` `);
+ }
+ for (const row of rows) {
+ const rowY = chartOriginY + row.y;
+ if (row.startIndex !== null && row.endIndex !== null) {
+ const barX = chartOriginX + (row.startIndex * DAY_WIDTH) + 6;
+ const barWidth = Math.max(12, ((row.endIndex - row.startIndex + 1) * DAY_WIDTH) - 12);
+ const barY = rowY + 8;
+ if (row.kind === "milestone") {
+ const centerX = chartOriginX + (row.startIndex * DAY_WIDTH) + (DAY_WIDTH / 2);
+ const centerY = rowY + (ROW_HEIGHT / 2);
+ const isCompleted = (row.task.percentComplete || 0) >= 100;
+ const fill = isCompleted ? "#d9efff" : "#ffffff";
+ parts.push(` `);
+ }
+ else if (row.kind === "phase") {
+ const lineY = rowY + (ROW_HEIGHT / 2);
+ const startX = barX;
+ const endX = barX + barWidth;
+ const trackStroke = "#8eb9ea";
+ const progressStroke = "#2f79d0";
+ const phaseStrokeWidth = 3;
+ const progressEndX = startX + Math.max(0, Math.min(barWidth, Math.round(barWidth * (Math.max(0, Math.min(100, row.task.percentComplete || 0)) / 100))));
+ parts.push(` `);
+ if (progressEndX > startX) {
+ parts.push(` `);
+ }
+ }
+ else {
+ const trackFill = "#d9efff";
+ const trackStroke = "#4f95d6";
+ const progressFill = "#3f86d8";
+ parts.push(` `);
+ const progressWidth = Math.max(0, Math.min(barWidth, Math.round(barWidth * (Math.max(0, Math.min(100, row.task.percentComplete || 0)) / 100))));
+ if (progressWidth > 0) {
+ parts.push(` `);
+ }
+ }
+ }
+ const labelPlacement = labelPlacements[rows.indexOf(row)];
+ parts.push(`${escapeXml(formatTaskLabel(row.task, labelMode))} `);
+ }
+ parts.push(" ");
+ return parts.join("");
+ }
+ function buildTaskRows(tasks, dateBand) {
+ return tasks.map((task, index) => ({
+ task,
+ label: task.name || "-",
+ kind: task.summary ? "phase" : (task.milestone ? "milestone" : "task"),
+ startIndex: indexOfDate(dateBand, task.start),
+ endIndex: indexOfDate(dateBand, task.finish),
+ y: index * ROW_HEIGHT
+ }));
+ }
+ function formatTaskLabel(task, labelMode) {
+ if (labelMode === "list") {
+ return `${" ".repeat(Math.max(0, task.outlineLevel - 1))}${task.name || "-"}`;
+ }
+ return task.name || "-";
+ }
+ function resolveLabelPlacement(row, chartOriginX, chartWidth, labelMode) {
+ if (labelMode === "list" || row.startIndex === null || row.endIndex === null) {
+ return { x: LEFT_PADDING + 10, anchor: "start", width: estimateLabelWidth(row.label, row.kind === "phase") };
+ }
+ const textWidth = estimateLabelWidth(row.label, row.kind === "phase");
+ const gap = 12;
+ const shapeStartX = row.kind === "milestone"
+ ? chartOriginX + (row.startIndex * DAY_WIDTH) + (DAY_WIDTH / 2) - 13
+ : chartOriginX + (row.startIndex * DAY_WIDTH) + 6;
+ const shapeEndX = row.kind === "milestone"
+ ? chartOriginX + (row.startIndex * DAY_WIDTH) + (DAY_WIDTH / 2) + 13
+ : chartOriginX + (row.endIndex * DAY_WIDTH) + DAY_WIDTH - 6;
+ const chartMidX = chartOriginX + (chartWidth / 2);
+ if (shapeEndX <= chartMidX) {
+ return { x: shapeEndX + gap, anchor: "start", width: textWidth };
+ }
+ return { x: shapeStartX - gap, anchor: "end", width: textWidth };
+ }
+ function estimateLabelWidth(label, isPhase) {
+ const basePerChar = isPhase ? 14 : 13;
+ return Math.max(48, Math.ceil(String(label || "").length * basePerChar));
+ }
+ function formatSvgAxisDate(day) {
+ const match = day.match(/^(\d{4})-(\d{2})-(\d{2})$/);
+ if (!match) {
+ return day;
+ }
+ return `${Number(match[2])}/${Number(match[3])}`;
+ }
+ function indexOfDate(dateBand, value) {
+ const key = String(value || "").slice(0, 10);
+ if (!key) {
+ return null;
+ }
+ const index = dateBand.indexOf(key);
+ return index >= 0 ? index : null;
+ }
+ function collectWbsHolidayDates(model) {
+ const holidaySet = new Set();
+ for (const calendar of model.calendars) {
+ for (const exception of calendar.exceptions || []) {
+ if (exception.dayWorking !== false && (exception.workingTimes || []).length > 0) {
+ continue;
+ }
+ for (const day of expandExceptionDays(exception)) {
+ holidaySet.add(day);
+ }
+ }
+ }
+ return Array.from(holidaySet).sort();
+ }
+ function expandExceptionDays(exception) {
+ const singleDay = exception.fromDate ? formatDateOnly(parseDateOnly(exception.fromDate)) : "";
+ if (!exception.fromDate || !exception.toDate) {
+ return singleDay ? [singleDay] : [];
+ }
+ return buildDateBand(exception.fromDate, exception.toDate);
+ }
+ function resolveProjectCalendar(model) {
+ if (model.project.calendarUID) {
+ const projectCalendar = model.calendars.find((calendar) => calendar.uid === model.project.calendarUID);
+ if (projectCalendar) {
+ return projectCalendar;
+ }
+ }
+ return model.calendars.find((calendar) => calendar.isBaseCalendar) || model.calendars[0];
+ }
+ function resolveCalendarDayWorking(calendarByUid, calendar, dayType, visiting = new Set()) {
+ if (!calendar) {
+ return undefined;
+ }
+ if (visiting.has(calendar.uid)) {
+ return undefined;
+ }
+ visiting.add(calendar.uid);
+ const weekDay = calendar.weekDays.find((item) => item.dayType === dayType);
+ if (weekDay) {
+ return weekDay.dayWorking;
+ }
+ if (calendar.baseCalendarUID) {
+ return resolveCalendarDayWorking(calendarByUid, calendarByUid.get(calendar.baseCalendarUID), dayType, visiting);
+ }
+ return undefined;
+ }
+ function collectProjectNonWorkingDayTypes(model) {
+ const calendarByUid = new Map(model.calendars.map((calendar) => [calendar.uid, calendar]));
+ const projectCalendar = resolveProjectCalendar(model);
+ const nonWorkingDayTypes = new Set();
+ for (let dayType = 1; dayType <= 7; dayType += 1) {
+ const dayWorking = resolveCalendarDayWorking(calendarByUid, projectCalendar, dayType);
+ if (dayWorking === false) {
+ nonWorkingDayTypes.add(dayType);
+ continue;
+ }
+ if (dayWorking === undefined && (dayType === 1 || dayType === 7)) {
+ nonWorkingDayTypes.add(dayType);
+ }
+ }
+ return nonWorkingDayTypes;
+ }
+ function buildDateBand(startDate, finishDate) {
+ const start = parseDateOnly(startDate);
+ const finish = parseDateOnly(finishDate);
+ if (!start || !finish || start.getTime() > finish.getTime()) {
+ return [];
+ }
+ const days = [];
+ const cursor = new Date(start.getTime());
+ while (cursor.getTime() <= finish.getTime()) {
+ days.push(formatDateOnly(cursor));
+ cursor.setDate(cursor.getDate() + 1);
+ }
+ return days;
+ }
+ function buildDisplayDateBand(startDate, finishDate, baseDate, displayDaysBeforeBaseDate, displayDaysAfterBaseDate, holidaySet, nonWorkingDayTypes, useBusinessDaysForDisplayRange) {
+ const fullBand = buildDateBand(startDate, finishDate);
+ const before = normalizeDisplayDayCount(displayDaysBeforeBaseDate);
+ const after = normalizeDisplayDayCount(displayDaysAfterBaseDate);
+ if (before === null && after === null) {
+ return fullBand;
+ }
+ const base = parseDateOnly(baseDate);
+ if (!base || fullBand.length === 0) {
+ return fullBand;
+ }
+ const projectStart = parseDateOnly(startDate);
+ const projectFinish = parseDateOnly(finishDate);
+ if (!projectStart || !projectFinish) {
+ return fullBand;
+ }
+ const from = useBusinessDaysForDisplayRange
+ ? shiftBusinessDays(base, -(before || 0), holidaySet, nonWorkingDayTypes)
+ : shiftCalendarDays(base, -(before || 0));
+ const to = useBusinessDaysForDisplayRange
+ ? shiftBusinessDays(base, after || 0, holidaySet, nonWorkingDayTypes)
+ : shiftCalendarDays(base, after || 0);
+ const clampedStart = from.getTime() < projectStart.getTime() ? projectStart : from;
+ const clampedFinish = to.getTime() > projectFinish.getTime() ? projectFinish : to;
+ if (clampedStart.getTime() > clampedFinish.getTime()) {
+ return fullBand;
+ }
+ return buildDateBand(formatDateOnly(clampedStart), formatDateOnly(clampedFinish));
+ }
+ function normalizeDisplayDayCount(value) {
+ if (value === undefined || value === null || !Number.isFinite(value)) {
+ return null;
+ }
+ return Math.max(0, Math.floor(value));
+ }
+ function shiftCalendarDays(base, offset) {
+ const result = new Date(base.getTime());
+ result.setDate(result.getDate() + offset);
+ return result;
+ }
+ function shiftBusinessDays(base, offset, holidaySet, nonWorkingDayTypes) {
+ const result = new Date(base.getTime());
+ const direction = offset < 0 ? -1 : 1;
+ let remaining = Math.abs(offset);
+ while (remaining > 0) {
+ result.setDate(result.getDate() + direction);
+ const day = formatDateOnly(result);
+ if (isWeeklyNonWorkingDay(day, nonWorkingDayTypes) || holidaySet.has(day)) {
+ continue;
+ }
+ remaining -= 1;
+ }
+ return result;
+ }
+ function isWeeklyNonWorkingDay(day, nonWorkingDayTypes) {
+ const date = parseDateOnly(day);
+ if (!date) {
+ return false;
+ }
+ const dayType = date.getDay() === 0 ? 1 : date.getDay() + 1;
+ return nonWorkingDayTypes.has(dayType);
+ }
+ function parseDateOnly(value) {
+ const text = String(value || "").trim().slice(0, 10);
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) {
+ return null;
+ }
+ const parsed = new Date(`${text}T00:00:00`);
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+ }
+ function formatDateOnly(value) {
+ if (!value) {
+ return "";
+ }
+ const year = value.getFullYear();
+ const month = String(value.getMonth() + 1).padStart(2, "0");
+ const day = String(value.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+ }
+ function escapeXml(value) {
+ return String(value || "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll("\"", """);
+ }
+ globalThis.__mikuprojectNativeSvg = {
+ exportNativeSvg
+ };
+})();
diff --git a/src/js/project-workbook-json.js b/src/js/project-workbook-json.js
new file mode 100644
index 0000000..350df48
--- /dev/null
+++ b/src/js/project-workbook-json.js
@@ -0,0 +1,190 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ const workbookSchema = globalThis.__mikuprojectProjectWorkbookSchema;
+ if (!workbookSchema) {
+ throw new Error("mikuproject Project Workbook Schema module is not loaded");
+ }
+ const { SHEET_NAMES, HEADER_ROW_INDEX, DATA_ROW_START_INDEX, PROJECT_FIELD_ORDER, SHEET_HEADERS } = workbookSchema;
+ const projectXlsx = globalThis.__mikuprojectProjectXlsx;
+ if (!projectXlsx) {
+ throw new Error("mikuproject Project XLSX module is not loaded");
+ }
+ function exportProjectWorkbookJson(model) {
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ return {
+ format: "mikuproject_workbook_json",
+ version: 1,
+ sheets: {
+ Project: exportProjectSheetRows(workbook),
+ Tasks: exportTabularSheetRows(workbook, "Tasks"),
+ Resources: exportTabularSheetRows(workbook, "Resources"),
+ Assignments: exportTabularSheetRows(workbook, "Assignments"),
+ Calendars: exportTabularSheetRows(workbook, "Calendars"),
+ NonWorkingDays: exportTabularSheetRows(workbook, "NonWorkingDays")
+ }
+ };
+ }
+ function importProjectWorkbookJson(documentLike, baseModel) {
+ const validation = validateWorkbookJsonDocument(documentLike);
+ const document = validation.document;
+ const workbook = {
+ sheets: [
+ buildProjectSheet(document.sheets.Project || []),
+ buildTabularSheet("Tasks", document.sheets.Tasks || [], SHEET_HEADERS.Tasks),
+ buildTabularSheet("Resources", document.sheets.Resources || [], SHEET_HEADERS.Resources),
+ buildTabularSheet("Assignments", document.sheets.Assignments || [], SHEET_HEADERS.Assignments),
+ buildTabularSheet("Calendars", document.sheets.Calendars || [], SHEET_HEADERS.Calendars),
+ buildTabularSheet("NonWorkingDays", document.sheets.NonWorkingDays || [], SHEET_HEADERS.NonWorkingDays)
+ ]
+ };
+ const result = projectXlsx.importProjectWorkbookDetailed(workbook, baseModel);
+ return {
+ ...result,
+ warnings: validation.warnings
+ };
+ }
+ function validateWorkbookJsonDocument(documentLike) {
+ if (!documentLike || typeof documentLike !== "object") {
+ throw new Error("workbook JSON がオブジェクトではありません");
+ }
+ const document = documentLike;
+ if (document.format !== "mikuproject_workbook_json") {
+ throw new Error("format が mikuproject_workbook_json ではありません");
+ }
+ if (document.version !== 1) {
+ throw new Error("version は 1 である必要があります");
+ }
+ if (!document.sheets || typeof document.sheets !== "object") {
+ throw new Error("sheets がありません");
+ }
+ const warnings = [];
+ for (const [sheetName, rows] of Object.entries(document.sheets)) {
+ if (!SHEET_NAMES.includes(sheetName)) {
+ warnings.push({ message: `未知の sheet は無視します: ${sheetName}` });
+ continue;
+ }
+ if (!Array.isArray(rows)) {
+ throw new Error(`sheets.${sheetName} は配列である必要があります`);
+ }
+ for (const [rowIndex, row] of rows.entries()) {
+ if (!row || typeof row !== "object" || Array.isArray(row)) {
+ throw new Error(`sheets.${sheetName} にオブジェクトではない行があります`);
+ }
+ for (const key of Object.keys(row)) {
+ if (!isKnownColumn(sheetName, key)) {
+ warnings.push({ message: `未知の列は無視します: ${sheetName}[${rowIndex}].${key}` });
+ }
+ }
+ }
+ }
+ return {
+ document: document,
+ warnings
+ };
+ }
+ function exportProjectSheetRows(workbook) {
+ var _a, _b;
+ const sheet = workbook.sheets.find((item) => item.name === "Project");
+ if (!sheet) {
+ return [];
+ }
+ const rows = [];
+ for (const row of sheet.rows.slice(DATA_ROW_START_INDEX)) {
+ const field = toJsonScalar((_a = row.cells[0]) === null || _a === void 0 ? void 0 : _a.value);
+ if (typeof field !== "string" || !PROJECT_FIELD_ORDER.includes(field)) {
+ continue;
+ }
+ rows.push({
+ Field: field,
+ Value: toJsonScalar((_b = row.cells[1]) === null || _b === void 0 ? void 0 : _b.value)
+ });
+ }
+ return rows;
+ }
+ function exportTabularSheetRows(workbook, sheetName) {
+ const sheet = workbook.sheets.find((item) => item.name === sheetName);
+ if (!sheet) {
+ return [];
+ }
+ const headers = readHeaderRow(sheet);
+ return sheet.rows.slice(DATA_ROW_START_INDEX).map((row) => {
+ const item = {};
+ headers.forEach((header, index) => {
+ var _a;
+ item[header] = toJsonScalar((_a = row.cells[index]) === null || _a === void 0 ? void 0 : _a.value);
+ });
+ return item;
+ });
+ }
+ function buildProjectSheet(rows) {
+ const valueByField = new Map();
+ for (const row of rows) {
+ const field = typeof row.Field === "string" ? row.Field : "";
+ if (!PROJECT_FIELD_ORDER.includes(field)) {
+ continue;
+ }
+ valueByField.set(field, toWorkbookScalar(row.Value));
+ }
+ return {
+ name: "Project",
+ rows: [
+ { cells: [{ value: "Project" }, {}] },
+ { cells: [{ value: "Basic Info" }, {}] },
+ { cells: [{ value: "Field" }, { value: "Value" }] },
+ ...PROJECT_FIELD_ORDER.map((field) => ({
+ cells: [
+ { value: field },
+ { value: valueByField.get(field) }
+ ]
+ }))
+ ]
+ };
+ }
+ function buildTabularSheet(sheetName, rows, headers) {
+ return {
+ name: sheetName,
+ rows: [
+ { cells: [{ value: sheetName }] },
+ { cells: [{ value: `${sheetName} List` }] },
+ { cells: headers.map((header) => ({ value: header })) },
+ ...rows.map((row) => ({
+ cells: headers.map((header) => ({
+ value: toWorkbookScalar(row[header])
+ }))
+ }))
+ ]
+ };
+ }
+ function readHeaderRow(sheet) {
+ var _a;
+ return (((_a = sheet.rows[HEADER_ROW_INDEX]) === null || _a === void 0 ? void 0 : _a.cells) || [])
+ .map((cell) => (typeof cell.value === "string" ? cell.value : ""))
+ .filter((value) => value !== "");
+ }
+ function toJsonScalar(value) {
+ if (value === undefined) {
+ return null;
+ }
+ return value;
+ }
+ function toWorkbookScalar(value) {
+ if (value === null || value === undefined) {
+ return undefined;
+ }
+ return value;
+ }
+ function isKnownColumn(sheetName, key) {
+ if (sheetName === "Project") {
+ return key === "Field" || key === "Value";
+ }
+ return (SHEET_HEADERS[sheetName] || []).includes(key);
+ }
+ globalThis.__mikuprojectProjectWorkbookJson = {
+ exportProjectWorkbookJson,
+ importProjectWorkbookJson,
+ validateWorkbookJsonDocument
+ };
+})();
diff --git a/src/js/project-workbook-schema.js b/src/js/project-workbook-schema.js
new file mode 100644
index 0000000..3476c2a
--- /dev/null
+++ b/src/js/project-workbook-schema.js
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ const SHEET_NAMES = [
+ "Project",
+ "Tasks",
+ "Resources",
+ "Assignments",
+ "Calendars",
+ "NonWorkingDays"
+ ];
+ const HEADER_ROW_INDEX = 2;
+ const DATA_ROW_START_INDEX = 3;
+ const PROJECT_FIELD_ORDER = [
+ "Name",
+ "Title",
+ "Author",
+ "Company",
+ "StartDate",
+ "FinishDate",
+ "CurrentDate",
+ "StatusDate",
+ "CalendarUID",
+ "MinutesPerDay",
+ "MinutesPerWeek",
+ "DaysPerMonth",
+ "ScheduleFromStart",
+ "OutlineCodes",
+ "WBSMasks",
+ "ExtendedAttributes"
+ ];
+ const PROJECT_EDITABLE_FIELDS = [
+ "Name",
+ "Title",
+ "Author",
+ "Company",
+ "StartDate",
+ "FinishDate",
+ "CurrentDate",
+ "StatusDate",
+ "CalendarUID",
+ "MinutesPerDay",
+ "MinutesPerWeek",
+ "DaysPerMonth",
+ "ScheduleFromStart"
+ ];
+ const SHEET_HEADERS = {
+ Tasks: [
+ "UID", "ID", "Name", "OutlineLevel", "OutlineNumber", "WBS",
+ "Start", "Finish", "Duration", "PercentComplete", "PercentWorkComplete",
+ "Milestone", "Summary", "Critical", "CalendarUID", "Predecessors", "Notes"
+ ],
+ Resources: [
+ "UID", "ID", "Name", "Type", "Initials", "Group", "MaxUnits",
+ "CalendarUID", "StandardRate", "OvertimeRate", "CostPerUse",
+ "Work", "ActualWork", "RemainingWork"
+ ],
+ Assignments: [
+ "UID", "TaskUID", "TaskName", "ResourceUID", "ResourceName", "Start",
+ "Finish", "Units", "Work", "ActualWork", "RemainingWork", "PercentWorkComplete"
+ ],
+ Calendars: [
+ "UID", "Name", "IsBaseCalendar", "BaseCalendarUID", "WeekDays", "Exceptions", "WorkWeeks"
+ ],
+ NonWorkingDays: [
+ "CalendarUID", "Index", "CalendarName", "Name", "Date", "FromDate", "ToDate", "DayWorking"
+ ]
+ };
+ const IMPORTABLE_FIELDS = {
+ Project: PROJECT_EDITABLE_FIELDS,
+ Tasks: ["Name", "Start", "Finish", "PercentComplete", "PercentWorkComplete", "Notes"],
+ Resources: ["Name", "Group", "MaxUnits"],
+ Assignments: ["Units", "Work", "PercentWorkComplete"],
+ Calendars: ["Name", "IsBaseCalendar", "BaseCalendarUID"],
+ NonWorkingDays: ["Name", "Date", "FromDate", "ToDate", "DayWorking"]
+ };
+ globalThis.__mikuprojectProjectWorkbookSchema = {
+ SHEET_NAMES,
+ HEADER_ROW_INDEX,
+ DATA_ROW_START_INDEX,
+ PROJECT_FIELD_ORDER,
+ PROJECT_EDITABLE_FIELDS,
+ SHEET_HEADERS,
+ IMPORTABLE_FIELDS
+ };
+})();
diff --git a/src/js/project-xlsx.js b/src/js/project-xlsx.js
new file mode 100644
index 0000000..fc8a9ea
--- /dev/null
+++ b/src/js/project-xlsx.js
@@ -0,0 +1,855 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ const workbookSchema = globalThis.__mikuprojectProjectWorkbookSchema;
+ if (!workbookSchema) {
+ throw new Error("mikuproject Project Workbook Schema module is not loaded");
+ }
+ const { HEADER_ROW_INDEX, DATA_ROW_START_INDEX, PROJECT_FIELD_ORDER, PROJECT_EDITABLE_FIELDS, SHEET_HEADERS } = workbookSchema;
+ const HEADER_FILL = "#D9EAF7";
+ const SECTION_FILL = "#BFD7EA";
+ const LABEL_FILL = "#EDF5FB";
+ const ALT_ROW_FILL = "#F9FBFD";
+ const DATE_FILL = "#FFF4E8";
+ const PERCENT_FILL = "#FCECF3";
+ const REFERENCE_FILL = "#EEF7F4";
+ const COUNT_FILL = "#F2F5F8";
+ const EDITABLE_FILL = "#FDE7C7";
+ const DURATION_FILL = "#FBF6ED";
+ const NOTES_FILL = "#FFFBEA";
+ const NAME_FILL = "#FAF6FF";
+ const WORK_FILL = "#F1F8FD";
+ const SUMMARY_FILL = "#E6F2E0";
+ const MILESTONE_FILL = "#FFF0CF";
+ const CRITICAL_FILL = "#F8DDE6";
+ const SHEET_THEMES = {
+ project: { section: "#BFD7EA", header: "#D9EAF7", label: "#EDF5FB" },
+ tasks: { section: "#D4E0EC", header: "#E6EDF4", label: "#F2F6FA" },
+ resources: { section: "#C8E3D8", header: "#DDF0E8", label: "#EFF8F4" },
+ assignments: { section: "#D7D2EC", header: "#E7E3F5", label: "#F2F0FA" },
+ calendars: { section: "#D7E3C4", header: "#E7F0DA", label: "#F2F7EA" },
+ nonWorkingDays: { section: "#E9C7D5", header: "#F4DDE6", label: "#FBEEF3" }
+ };
+ function exportProjectWorkbook(model) {
+ return {
+ sheets: [
+ buildProjectSheet(model),
+ buildTasksSheet(model),
+ buildResourcesSheet(model),
+ buildAssignmentsSheet(model),
+ buildCalendarsSheet(model),
+ buildNonWorkingDaysSheet(model)
+ ]
+ };
+ }
+ function importProjectWorkbook(workbook, baseModel) {
+ return importProjectWorkbookDetailed(workbook, baseModel).model;
+ }
+ function importProjectWorkbookDetailed(workbook, baseModel) {
+ const nextModel = cloneProjectModel(baseModel);
+ const changes = [];
+ importProjectSheet(workbook, nextModel, changes);
+ importTasksSheet(workbook, nextModel, changes);
+ importResourcesSheet(workbook, nextModel, changes);
+ importAssignmentsSheet(workbook, nextModel, changes);
+ importCalendarsSheet(workbook, nextModel, changes);
+ importNonWorkingDaysSheet(workbook, nextModel, changes);
+ return {
+ model: nextModel,
+ changes
+ };
+ }
+ function importProjectSheet(workbook, model, changes) {
+ const projectSheet = workbook.sheets.find((sheet) => sheet.name === "Project");
+ if (!projectSheet) {
+ return;
+ }
+ const valueByField = new Map();
+ for (const row of projectSheet.rows.slice(DATA_ROW_START_INDEX)) {
+ const field = readStringCell(row.cells[0]);
+ if (!field) {
+ continue;
+ }
+ valueByField.set(field, row.cells[1]);
+ }
+ const projectLabel = model.project.name;
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "name", "Name", readStringCell(valueByField.get("Name")));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "title", "Title", readStringCell(valueByField.get("Title")));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "author", "Author", readStringCell(valueByField.get("Author")));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "company", "Company", readStringCell(valueByField.get("Company")));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "startDate", "StartDate", normalizeDateTimeInput(readStringCell(valueByField.get("StartDate"))));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "finishDate", "FinishDate", normalizeDateTimeInput(readStringCell(valueByField.get("FinishDate"))));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "currentDate", "CurrentDate", normalizeDateTimeInput(readStringCell(valueByField.get("CurrentDate"))));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "statusDate", "StatusDate", normalizeDateTimeInput(readStringCell(valueByField.get("StatusDate"))));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "calendarUID", "CalendarUID", readStringCell(valueByField.get("CalendarUID")));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "minutesPerDay", "MinutesPerDay", readNumberCell(valueByField.get("MinutesPerDay")));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "minutesPerWeek", "MinutesPerWeek", readNumberCell(valueByField.get("MinutesPerWeek")));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "daysPerMonth", "DaysPerMonth", readNumberCell(valueByField.get("DaysPerMonth")));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "scheduleFromStart", "ScheduleFromStart", readBooleanCell(valueByField.get("ScheduleFromStart")));
+ }
+ function buildProjectSheet(model) {
+ const project = model.project;
+ const rows = [
+ titleRow("Project", SHEET_THEMES.project.section),
+ titleRow("Basic Info", SHEET_THEMES.project.section),
+ headerRow(["Field", "Value"], SHEET_THEMES.project.header),
+ ...PROJECT_FIELD_ORDER.slice(0, 8).map((field) => keyValueRow(field, readProjectFieldValue(project, field), SHEET_THEMES.project.label)),
+ titleRow("Settings", SHEET_THEMES.project.section),
+ ...PROJECT_FIELD_ORDER.slice(8).map((field) => keyValueRow(field, readProjectFieldValue(project, field), SHEET_THEMES.project.label))
+ ];
+ return {
+ name: "Project",
+ columns: [{ width: 26 }, { width: 42 }],
+ mergedRanges: ["A11:B11"],
+ rows
+ };
+ }
+ function buildTasksSheet(model) {
+ return {
+ name: "Tasks",
+ columns: [
+ { width: 10 }, { width: 8 }, { width: 28 }, { width: 12 },
+ { width: 14 }, { width: 12 }, { width: 20 }, { width: 20 },
+ { width: 14 }, { width: 16 }, { width: 18 }, { width: 12 },
+ { width: 12 }, { width: 12 }, { width: 12 }, { width: 20 },
+ { width: 34 }
+ ],
+ mergedRanges: [],
+ rows: [
+ sectionTitleRow("Tasks", 17, SHEET_THEMES.tasks.section),
+ sectionTitleRow("Task List", 17, SHEET_THEMES.tasks.section),
+ headerRow([...SHEET_HEADERS.Tasks], SHEET_THEMES.tasks.header),
+ ...model.tasks.map((task, index) => ({
+ cells: [
+ countCell(task.uid, index),
+ countCell(task.id, index),
+ editableCell(taskNameCell(task, index)),
+ countCell(task.outlineLevel, index),
+ textCell(task.outlineNumber, index),
+ textCell(task.wbs, index),
+ editableCell(dateTimeCell(task.start, index)),
+ editableCell(dateTimeCell(task.finish, index)),
+ durationCell(task.duration, index),
+ editableCell(percentCell(task.percentComplete, index)),
+ editableCell(percentCell(task.percentWorkComplete, index)),
+ taskFlagCell(task.milestone, index, MILESTONE_FILL),
+ taskFlagCell(task.summary, index, SUMMARY_FILL),
+ taskFlagCell(task.critical, index, CRITICAL_FILL),
+ referenceCell(task.calendarUID, index),
+ predecessorCell(task.predecessors.map((item) => item.predecessorUid).join(", "), index),
+ editableCell(notesCell(task.notes, index))
+ ]
+ }))
+ ]
+ };
+ }
+ function buildResourcesSheet(model) {
+ const resourceRows = model.resources.length > 0
+ ? model.resources.map((resource, index) => ({
+ cells: [
+ countCell(resource.uid, index),
+ countCell(resource.id, index),
+ editableCell(entityNameCell(resource.name, index)),
+ countCell(resource.type, index),
+ textCell(resource.initials, index),
+ editableCell(textCell(resource.group, index)),
+ editableCell(countCell(resource.maxUnits, index)),
+ referenceCell(resource.calendarUID, index),
+ textCell(resource.standardRate, index),
+ textCell(resource.overtimeRate, index),
+ countCell(resource.costPerUse, index),
+ workCell(resource.work, index),
+ workCell(resource.actualWork, index),
+ workCell(resource.remainingWork, index)
+ ]
+ }))
+ : [{
+ cells: [
+ countCell(undefined, 0),
+ countCell(undefined, 0),
+ editableCell(entityNameCell(undefined, 0)),
+ countCell(undefined, 0),
+ textCell(undefined, 0),
+ editableCell(textCell(undefined, 0)),
+ editableCell(countCell(undefined, 0)),
+ referenceCell(undefined, 0),
+ textCell(undefined, 0),
+ textCell(undefined, 0),
+ countCell(undefined, 0),
+ workCell(undefined, 0),
+ workCell(undefined, 0),
+ workCell(undefined, 0)
+ ]
+ }];
+ return {
+ name: "Resources",
+ columns: [
+ { width: 10 }, { width: 8 }, { width: 24 }, { width: 10 },
+ { width: 12 }, { width: 18 }, { width: 12 }, { width: 12 },
+ { width: 14 }, { width: 14 }, { width: 12 }, { width: 14 },
+ { width: 14 }, { width: 14 }
+ ],
+ mergedRanges: [],
+ rows: [
+ sectionTitleRow("Resources", 14, SHEET_THEMES.resources.section),
+ sectionTitleRow("Resource List", 14, SHEET_THEMES.resources.section),
+ headerRow([...SHEET_HEADERS.Resources], SHEET_THEMES.resources.header),
+ ...resourceRows
+ ]
+ };
+ }
+ function buildAssignmentsSheet(model) {
+ const taskNameByUid = new Map(model.tasks.map((task) => [task.uid, task.name]));
+ const resourceNameByUid = new Map(model.resources.map((resource) => [resource.uid, resource.name]));
+ const assignmentRows = model.assignments.length > 0
+ ? model.assignments.map((assignment, index) => ({
+ cells: [
+ countCell(assignment.uid, index),
+ referenceCell(assignment.taskUid, index),
+ entityNameCell(taskNameByUid.get(assignment.taskUid), index),
+ referenceCell(assignment.resourceUid, index),
+ entityNameCell(resourceNameByUid.get(assignment.resourceUid), index),
+ dateTimeCell(assignment.start, index),
+ dateTimeCell(assignment.finish, index),
+ editableCell(countCell(assignment.units, index)),
+ editableCell(workCell(assignment.work, index)),
+ workCell(assignment.actualWork, index),
+ workCell(assignment.remainingWork, index),
+ editableCell(percentCell(assignment.percentWorkComplete, index))
+ ]
+ }))
+ : [{
+ cells: [
+ countCell(undefined, 0),
+ referenceCell(undefined, 0),
+ entityNameCell(undefined, 0),
+ referenceCell(undefined, 0),
+ entityNameCell(undefined, 0),
+ dateTimeCell(undefined, 0),
+ dateTimeCell(undefined, 0),
+ editableCell(countCell(undefined, 0)),
+ editableCell(workCell(undefined, 0)),
+ workCell(undefined, 0),
+ workCell(undefined, 0),
+ editableCell(percentCell(undefined, 0))
+ ]
+ }];
+ return {
+ name: "Assignments",
+ columns: [
+ { width: 10 }, { width: 10 }, { width: 24 }, { width: 12 },
+ { width: 24 }, { width: 20 }, { width: 20 }, { width: 10 },
+ { width: 14 }, { width: 14 }, { width: 14 }, { width: 18 }
+ ],
+ mergedRanges: [],
+ rows: [
+ sectionTitleRow("Assignments", 12, SHEET_THEMES.assignments.section),
+ sectionTitleRow("Assignment List", 12, SHEET_THEMES.assignments.section),
+ headerRow([...SHEET_HEADERS.Assignments], SHEET_THEMES.assignments.header),
+ ...assignmentRows
+ ]
+ };
+ }
+ function buildCalendarsSheet(model) {
+ return {
+ name: "Calendars",
+ columns: [
+ { width: 10 }, { width: 24 }, { width: 14 }, { width: 16 },
+ { width: 10 }, { width: 12 }, { width: 10 }
+ ],
+ mergedRanges: [],
+ rows: [
+ sectionTitleRow("Calendars", 7, SHEET_THEMES.calendars.section),
+ sectionTitleRow("Calendar List", 7, SHEET_THEMES.calendars.section),
+ headerRow([...SHEET_HEADERS.Calendars], SHEET_THEMES.calendars.header),
+ ...model.calendars.map((calendar, index) => ({
+ cells: [
+ countCell(calendar.uid, index),
+ editableCell(entityNameCell(calendar.name, index)),
+ editableCell(countCell(calendar.isBaseCalendar, index)),
+ editableCell(referenceCell(calendar.baseCalendarUID, index)),
+ countCell(calendar.weekDays.length, index),
+ countCell(calendar.exceptions.length, index),
+ countCell(calendar.workWeeks.length, index)
+ ]
+ }))
+ ]
+ };
+ }
+ function buildNonWorkingDaysSheet(model) {
+ const rows = model.calendars.flatMap((calendar) => calendar.exceptions.map((exception, index) => ({
+ cells: [
+ countCell(calendar.uid, index),
+ countCell(index + 1, index),
+ textCell(calendar.name, index),
+ editableCell(entityNameCell(exception.name, index)),
+ editableCell(dateOnlyCell(formatExceptionDate(exception), index)),
+ editableCell(dateOnlyCell(formatExceptionBoundaryDate(exception.fromDate), index)),
+ editableCell(dateOnlyCell(formatExceptionBoundaryDate(exception.toDate), index)),
+ editableCell(countCell(exception.dayWorking, index))
+ ]
+ })));
+ return {
+ name: "NonWorkingDays",
+ columns: [
+ { width: 12 }, { width: 10 }, { width: 22 }, { width: 24 },
+ { width: 14 }, { width: 22 }, { width: 22 }, { width: 12 }
+ ],
+ mergedRanges: [],
+ rows: [
+ sectionTitleRow("NonWorkingDays", 8, SHEET_THEMES.nonWorkingDays.section),
+ sectionTitleRow("Calendar Exceptions", 8, SHEET_THEMES.nonWorkingDays.section),
+ headerRow([...SHEET_HEADERS.NonWorkingDays], SHEET_THEMES.nonWorkingDays.header),
+ ...rows
+ ]
+ };
+ }
+ function headerRow(labels, fillColor = HEADER_FILL) {
+ return {
+ height: 24,
+ cells: labels.map((label) => ({
+ value: label,
+ bold: true,
+ fillColor,
+ border: "thin",
+ horizontalAlign: "center"
+ }))
+ };
+ }
+ function titleRow(title, fillColor = SECTION_FILL) {
+ return {
+ height: 28,
+ cells: [
+ {
+ value: title,
+ bold: true,
+ fontSize: 16,
+ fillColor,
+ horizontalAlign: "left"
+ },
+ {
+ fillColor
+ }
+ ]
+ };
+ }
+ function sectionTitleRow(title, columnCount, fillColor = SECTION_FILL) {
+ return {
+ height: 26,
+ cells: [
+ {
+ value: title,
+ bold: true,
+ fontSize: 14,
+ fillColor,
+ horizontalAlign: "left"
+ },
+ ...Array.from({ length: Math.max(0, columnCount - 1) }, () => ({
+ fillColor
+ }))
+ ]
+ };
+ }
+ function keyValueRow(label, value, labelFill = LABEL_FILL) {
+ return {
+ cells: [
+ {
+ value: label,
+ bold: true,
+ fillColor: labelFill,
+ border: "thin"
+ },
+ keyValueCell(label, value)
+ ]
+ };
+ }
+ function cell(value) {
+ if (value === undefined) {
+ return {};
+ }
+ return {
+ value: stringifyCellValue(value),
+ border: "thin"
+ };
+ }
+ function stringifyCellValue(value) {
+ return typeof value === "string" ? value : String(value);
+ }
+ function keyValueCell(label, value) {
+ if (isEditableProjectLabel(label)) {
+ return editableCell(buildProjectValueCell(label, value));
+ }
+ return buildProjectValueCell(label, value);
+ }
+ function buildProjectValueCell(label, value) {
+ if (isDateTimeLabel(label)) {
+ return {
+ ...cell(formatDateTimeDisplay(value)),
+ fillColor: DATE_FILL
+ };
+ }
+ if (label === "Name" || label === "Title") {
+ return {
+ ...cell(value),
+ fillColor: NAME_FILL,
+ bold: true
+ };
+ }
+ if (label === "Author" || label === "Company") {
+ return {
+ ...cell(value),
+ fillColor: NAME_FILL
+ };
+ }
+ if (label === "CalendarUID") {
+ return {
+ ...cell(value),
+ fillColor: REFERENCE_FILL,
+ horizontalAlign: "center"
+ };
+ }
+ if (label === "ScheduleFromStart") {
+ return {
+ ...cell(value),
+ fillColor: COUNT_FILL,
+ horizontalAlign: "center"
+ };
+ }
+ return {
+ ...cell(value),
+ fillColor: isNumericSummaryLabel(label) ? COUNT_FILL : undefined
+ };
+ }
+ function textCell(value, rowIndex) {
+ return styledCell(value, rowIndex);
+ }
+ function taskNameCell(task, rowIndex) {
+ const fillColor = task.summary ? SUMMARY_FILL : (task.critical ? CRITICAL_FILL : undefined);
+ return {
+ ...styledCell(task.name, rowIndex, { fillColor }),
+ bold: task.summary || task.milestone
+ };
+ }
+ function taskFlagCell(value, rowIndex, activeFillColor) {
+ const isActive = value === true || value === 1 || value === "1";
+ return styledCell(value, rowIndex, {
+ fillColor: isActive ? activeFillColor : COUNT_FILL,
+ horizontalAlign: "center"
+ });
+ }
+ function referenceCell(value, rowIndex) {
+ return styledCell(value, rowIndex, {
+ fillColor: REFERENCE_FILL,
+ horizontalAlign: "center"
+ });
+ }
+ function countCell(value, rowIndex) {
+ return styledCell(value, rowIndex, {
+ fillColor: COUNT_FILL,
+ horizontalAlign: "center"
+ });
+ }
+ function percentCell(value, rowIndex) {
+ return styledCell(value, rowIndex, {
+ fillColor: PERCENT_FILL,
+ horizontalAlign: "center"
+ });
+ }
+ function durationCell(value, rowIndex) {
+ return styledCell(value, rowIndex, {
+ fillColor: DURATION_FILL,
+ horizontalAlign: "center"
+ });
+ }
+ function predecessorCell(value, rowIndex) {
+ return styledCell(value, rowIndex, {
+ fillColor: REFERENCE_FILL
+ });
+ }
+ function notesCell(value, rowIndex) {
+ return styledCell(value, rowIndex, {
+ fillColor: NOTES_FILL
+ });
+ }
+ function entityNameCell(value, rowIndex) {
+ return styledCell(value, rowIndex, {
+ fillColor: NAME_FILL
+ });
+ }
+ function workCell(value, rowIndex) {
+ return styledCell(value, rowIndex, {
+ fillColor: WORK_FILL
+ });
+ }
+ function editableCell(cellLike) {
+ return {
+ ...cellLike,
+ border: cellLike.border || "thin",
+ fillColor: EDITABLE_FILL
+ };
+ }
+ function dateTimeCell(value, rowIndex) {
+ return styledCell(formatDateTimeDisplay(value), rowIndex, {
+ fillColor: DATE_FILL
+ });
+ }
+ function dateOnlyCell(value, rowIndex) {
+ return styledCell(value, rowIndex, {
+ fillColor: DATE_FILL,
+ horizontalAlign: "center"
+ });
+ }
+ function styledCell(value, rowIndex, overrides = {}) {
+ const base = cell(value);
+ if (base.value === undefined) {
+ return base;
+ }
+ return {
+ ...base,
+ fillColor: overrides.fillColor || (rowIndex % 2 === 0 ? ALT_ROW_FILL : undefined),
+ horizontalAlign: overrides.horizontalAlign,
+ numberFormat: overrides.numberFormat
+ };
+ }
+ function formatDateTimeDisplay(value) {
+ if (typeof value !== "string") {
+ return value;
+ }
+ return value.replace("T", " ");
+ }
+ function isDateTimeLabel(label) {
+ return ["StartDate", "FinishDate", "CurrentDate", "StatusDate"].includes(label);
+ }
+ function isNumericSummaryLabel(label) {
+ return ["OutlineCodes", "WBSMasks", "ExtendedAttributes", "MinutesPerDay", "MinutesPerWeek", "DaysPerMonth"].includes(label);
+ }
+ function isEditableProjectLabel(label) {
+ return PROJECT_EDITABLE_FIELDS.includes(label);
+ }
+ function readProjectFieldValue(project, field) {
+ switch (field) {
+ case "Name":
+ return project.name;
+ case "Title":
+ return project.title;
+ case "Author":
+ return project.author;
+ case "Company":
+ return project.company;
+ case "StartDate":
+ return project.startDate;
+ case "FinishDate":
+ return project.finishDate;
+ case "CurrentDate":
+ return project.currentDate;
+ case "StatusDate":
+ return project.statusDate;
+ case "CalendarUID":
+ return project.calendarUID;
+ case "MinutesPerDay":
+ return project.minutesPerDay;
+ case "MinutesPerWeek":
+ return project.minutesPerWeek;
+ case "DaysPerMonth":
+ return project.daysPerMonth;
+ case "ScheduleFromStart":
+ return project.scheduleFromStart;
+ case "OutlineCodes":
+ return project.outlineCodes.length;
+ case "WBSMasks":
+ return project.wbsMasks.length;
+ case "ExtendedAttributes":
+ return project.extendedAttributes.length;
+ default:
+ return undefined;
+ }
+ }
+ function cloneProjectModel(model) {
+ return JSON.parse(JSON.stringify(model));
+ }
+ function importTasksSheet(workbook, model, changes) {
+ const tasksSheet = workbook.sheets.find((sheet) => sheet.name === "Tasks");
+ if (!tasksSheet) {
+ return;
+ }
+ const columnIndexByLabel = readHeaderMap(tasksSheet, HEADER_ROW_INDEX);
+ const uidColumnIndex = columnIndexByLabel.get("UID");
+ if (uidColumnIndex === undefined) {
+ return;
+ }
+ const taskByUid = new Map(model.tasks.map((task) => [task.uid, task]));
+ for (const row of tasksSheet.rows.slice(DATA_ROW_START_INDEX)) {
+ const uid = readStringCell(row.cells[uidColumnIndex]);
+ if (!uid) {
+ continue;
+ }
+ const task = taskByUid.get(uid);
+ if (!task) {
+ continue;
+ }
+ const taskLabel = task.name;
+ assignIfChanged(changes, "tasks", task.uid, taskLabel, task, "name", "Name", readStringCellAt(row.cells, columnIndexByLabel.get("Name")));
+ assignIfChanged(changes, "tasks", task.uid, taskLabel, task, "start", "Start", normalizeDateTimeInput(readStringCellAt(row.cells, columnIndexByLabel.get("Start"))));
+ assignIfChanged(changes, "tasks", task.uid, taskLabel, task, "finish", "Finish", normalizeDateTimeInput(readStringCellAt(row.cells, columnIndexByLabel.get("Finish"))));
+ assignIfChanged(changes, "tasks", task.uid, taskLabel, task, "percentComplete", "PercentComplete", readNumberCellAt(row.cells, columnIndexByLabel.get("PercentComplete")));
+ assignIfChanged(changes, "tasks", task.uid, taskLabel, task, "percentWorkComplete", "PercentWorkComplete", readNumberCellAt(row.cells, columnIndexByLabel.get("PercentWorkComplete")));
+ assignIfChanged(changes, "tasks", task.uid, taskLabel, task, "notes", "Notes", readStringCellAt(row.cells, columnIndexByLabel.get("Notes")));
+ }
+ }
+ function importResourcesSheet(workbook, model, changes) {
+ const resourcesSheet = workbook.sheets.find((sheet) => sheet.name === "Resources");
+ if (!resourcesSheet) {
+ return;
+ }
+ const columnIndexByLabel = readHeaderMap(resourcesSheet, HEADER_ROW_INDEX);
+ const uidColumnIndex = columnIndexByLabel.get("UID");
+ if (uidColumnIndex === undefined) {
+ return;
+ }
+ const resourceByUid = new Map(model.resources.map((resource) => [resource.uid, resource]));
+ for (const row of resourcesSheet.rows.slice(DATA_ROW_START_INDEX)) {
+ const uid = readStringCell(row.cells[uidColumnIndex]);
+ if (!uid) {
+ continue;
+ }
+ const resource = resourceByUid.get(uid);
+ if (!resource) {
+ continue;
+ }
+ const resourceLabel = resource.name;
+ assignIfChanged(changes, "resources", resource.uid, resourceLabel, resource, "name", "Name", readStringCellAt(row.cells, columnIndexByLabel.get("Name")));
+ assignIfChanged(changes, "resources", resource.uid, resourceLabel, resource, "group", "Group", readStringCellAt(row.cells, columnIndexByLabel.get("Group")));
+ assignIfChanged(changes, "resources", resource.uid, resourceLabel, resource, "maxUnits", "MaxUnits", readNumberCellAt(row.cells, columnIndexByLabel.get("MaxUnits")));
+ }
+ }
+ function importAssignmentsSheet(workbook, model, changes) {
+ const assignmentsSheet = workbook.sheets.find((sheet) => sheet.name === "Assignments");
+ if (!assignmentsSheet) {
+ return;
+ }
+ const columnIndexByLabel = readHeaderMap(assignmentsSheet, HEADER_ROW_INDEX);
+ const uidColumnIndex = columnIndexByLabel.get("UID");
+ if (uidColumnIndex === undefined) {
+ return;
+ }
+ const assignmentByUid = new Map(model.assignments.map((assignment) => [assignment.uid, assignment]));
+ for (const row of assignmentsSheet.rows.slice(DATA_ROW_START_INDEX)) {
+ const uid = readStringCell(row.cells[uidColumnIndex]);
+ if (!uid) {
+ continue;
+ }
+ const assignment = assignmentByUid.get(uid);
+ if (!assignment) {
+ continue;
+ }
+ const assignmentLabel = `TaskUID=${assignment.taskUid}`;
+ assignIfChanged(changes, "assignments", assignment.uid, assignmentLabel, assignment, "units", "Units", readNumberCellAt(row.cells, columnIndexByLabel.get("Units")));
+ assignIfChanged(changes, "assignments", assignment.uid, assignmentLabel, assignment, "work", "Work", readStringCellAt(row.cells, columnIndexByLabel.get("Work")));
+ assignIfChanged(changes, "assignments", assignment.uid, assignmentLabel, assignment, "percentWorkComplete", "PercentWorkComplete", readNumberCellAt(row.cells, columnIndexByLabel.get("PercentWorkComplete")));
+ }
+ }
+ function importCalendarsSheet(workbook, model, changes) {
+ const calendarsSheet = workbook.sheets.find((sheet) => sheet.name === "Calendars");
+ if (!calendarsSheet) {
+ return;
+ }
+ const columnIndexByLabel = readHeaderMap(calendarsSheet, HEADER_ROW_INDEX);
+ const uidColumnIndex = columnIndexByLabel.get("UID");
+ if (uidColumnIndex === undefined) {
+ return;
+ }
+ const calendarByUid = new Map(model.calendars.map((calendar) => [calendar.uid, calendar]));
+ for (const row of calendarsSheet.rows.slice(DATA_ROW_START_INDEX)) {
+ const uid = readStringCell(row.cells[uidColumnIndex]);
+ if (!uid) {
+ continue;
+ }
+ const calendar = calendarByUid.get(uid);
+ if (!calendar) {
+ continue;
+ }
+ const calendarLabel = calendar.name;
+ assignIfChanged(changes, "calendars", calendar.uid, calendarLabel, calendar, "name", "Name", readStringCellAt(row.cells, columnIndexByLabel.get("Name")));
+ assignIfChanged(changes, "calendars", calendar.uid, calendarLabel, calendar, "isBaseCalendar", "IsBaseCalendar", readBooleanCellAt(row.cells, columnIndexByLabel.get("IsBaseCalendar")));
+ assignIfChanged(changes, "calendars", calendar.uid, calendarLabel, calendar, "baseCalendarUID", "BaseCalendarUID", readStringCellAt(row.cells, columnIndexByLabel.get("BaseCalendarUID")));
+ }
+ }
+ function importNonWorkingDaysSheet(workbook, model, changes) {
+ const nonWorkingDaysSheet = workbook.sheets.find((sheet) => sheet.name === "NonWorkingDays");
+ if (!nonWorkingDaysSheet) {
+ return;
+ }
+ const columnIndexByLabel = readHeaderMap(nonWorkingDaysSheet, HEADER_ROW_INDEX);
+ const calendarUidColumnIndex = columnIndexByLabel.get("CalendarUID");
+ const indexColumnIndex = columnIndexByLabel.get("Index");
+ if (calendarUidColumnIndex === undefined || indexColumnIndex === undefined) {
+ return;
+ }
+ const calendarByUid = new Map(model.calendars.map((calendar) => [calendar.uid, calendar]));
+ for (const row of nonWorkingDaysSheet.rows.slice(DATA_ROW_START_INDEX)) {
+ const calendarUid = readStringCell(row.cells[calendarUidColumnIndex]);
+ const indexValue = readNumberCell(row.cells[indexColumnIndex]);
+ if (!calendarUid || !indexValue) {
+ continue;
+ }
+ const calendar = calendarByUid.get(calendarUid);
+ if (!calendar) {
+ continue;
+ }
+ const exception = calendar.exceptions[indexValue - 1];
+ if (!exception) {
+ continue;
+ }
+ const exceptionLabel = exception.name || `Exception ${indexValue}`;
+ assignIfChanged(changes, "calendars", calendar.uid, calendar.name, exception, "name", `Exception${indexValue}.Name`, readStringCellAt(row.cells, columnIndexByLabel.get("Name")));
+ const dateValue = readStringCellAt(row.cells, columnIndexByLabel.get("Date"));
+ if (dateValue) {
+ const normalizedDate = normalizeDateOnly(dateValue);
+ if (normalizedDate) {
+ assignIfChanged(changes, "calendars", calendar.uid, calendar.name, exception, "fromDate", `Exception${indexValue}.FromDate`, `${normalizedDate}T00:00:00`);
+ assignIfChanged(changes, "calendars", calendar.uid, calendar.name, exception, "toDate", `Exception${indexValue}.ToDate`, `${normalizedDate}T23:59:59`);
+ }
+ }
+ else {
+ assignIfChanged(changes, "calendars", calendar.uid, calendar.name, exception, "fromDate", `Exception${indexValue}.FromDate`, normalizeExceptionBoundaryInput(readStringCellAt(row.cells, columnIndexByLabel.get("FromDate")), false));
+ assignIfChanged(changes, "calendars", calendar.uid, calendar.name, exception, "toDate", `Exception${indexValue}.ToDate`, normalizeExceptionBoundaryInput(readStringCellAt(row.cells, columnIndexByLabel.get("ToDate")), true));
+ }
+ assignIfChanged(changes, "calendars", calendar.uid, calendar.name, exception, "dayWorking", `Exception${indexValue}.DayWorking`, readBooleanCellAt(row.cells, columnIndexByLabel.get("DayWorking")));
+ }
+ }
+ function formatExceptionDate(exception) {
+ var _a, _b;
+ const fromDate = (_a = exception.fromDate) === null || _a === void 0 ? void 0 : _a.slice(0, 10);
+ const toDate = (_b = exception.toDate) === null || _b === void 0 ? void 0 : _b.slice(0, 10);
+ if (!fromDate || !toDate) {
+ return undefined;
+ }
+ return fromDate === toDate ? fromDate : undefined;
+ }
+ function formatExceptionBoundaryDate(value) {
+ return value === null || value === void 0 ? void 0 : value.slice(0, 10);
+ }
+ function normalizeDateOnly(value) {
+ const trimmed = value.trim();
+ const match = trimmed.match(/^(\d{4}-\d{2}-\d{2})/);
+ return match ? match[1] : undefined;
+ }
+ function normalizeDateTimeInput(value) {
+ if (!value) {
+ return value;
+ }
+ const trimmed = value.trim();
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(trimmed)) {
+ return trimmed.replace(" ", "T");
+ }
+ return trimmed;
+ }
+ function normalizeExceptionBoundaryInput(value, isEndOfDay) {
+ const normalizedDate = value ? normalizeDateOnly(value) : undefined;
+ if (normalizedDate && normalizedDate === (value === null || value === void 0 ? void 0 : value.trim())) {
+ return `${normalizedDate}${isEndOfDay ? "T23:59:59" : "T00:00:00"}`;
+ }
+ return normalizeDateTimeInput(value);
+ }
+ function readHeaderMap(sheet, headerRowIndex) {
+ const headerRow = sheet.rows[headerRowIndex];
+ const columnIndexByLabel = new Map();
+ if (!headerRow) {
+ return columnIndexByLabel;
+ }
+ headerRow.cells.forEach((cell, index) => {
+ if (typeof cell.value === "string" && cell.value) {
+ columnIndexByLabel.set(cell.value, index);
+ }
+ });
+ return columnIndexByLabel;
+ }
+ function readStringCellAt(cells, index) {
+ if (index === undefined) {
+ return undefined;
+ }
+ return readStringCell(cells[index]);
+ }
+ function readNumberCellAt(cells, index) {
+ if (index === undefined) {
+ return undefined;
+ }
+ return readNumberCell(cells[index]);
+ }
+ function readBooleanCellAt(cells, index) {
+ if (index === undefined) {
+ return undefined;
+ }
+ return readBooleanCell(cells[index]);
+ }
+ function readStringCell(cell) {
+ if (!cell || cell.value === undefined) {
+ return undefined;
+ }
+ if (typeof cell.value === "string") {
+ return cell.value;
+ }
+ if (typeof cell.value === "number" || typeof cell.value === "boolean") {
+ return String(cell.value);
+ }
+ return undefined;
+ }
+ function readNumberCell(cell) {
+ if (!cell || cell.value === undefined) {
+ return undefined;
+ }
+ if (typeof cell.value === "number" && Number.isFinite(cell.value)) {
+ return cell.value;
+ }
+ if (typeof cell.value === "string" && cell.value.trim() !== "") {
+ const parsed = Number(cell.value);
+ return Number.isFinite(parsed) ? parsed : undefined;
+ }
+ return undefined;
+ }
+ function readBooleanCell(cell) {
+ if (!cell || cell.value === undefined) {
+ return undefined;
+ }
+ if (typeof cell.value === "boolean") {
+ return cell.value;
+ }
+ if (typeof cell.value === "number") {
+ return cell.value !== 0;
+ }
+ if (typeof cell.value === "string") {
+ if (cell.value === "true" || cell.value === "TRUE" || cell.value === "1") {
+ return true;
+ }
+ if (cell.value === "false" || cell.value === "FALSE" || cell.value === "0") {
+ return false;
+ }
+ }
+ return undefined;
+ }
+ function assignIfChanged(changes, scope, uid, label, target, key, field, value) {
+ if (value === undefined) {
+ return;
+ }
+ const before = target[key];
+ if (before === value) {
+ return;
+ }
+ target[key] = value;
+ changes.push({
+ scope,
+ uid,
+ label,
+ field,
+ before: before,
+ after: value
+ });
+ }
+ globalThis.__mikuprojectProjectXlsx = {
+ exportProjectWorkbook,
+ importProjectWorkbook,
+ importProjectWorkbookDetailed
+ };
+})();
diff --git a/src/js/types.js b/src/js/types.js
new file mode 100644
index 0000000..49573bd
--- /dev/null
+++ b/src/js/types.js
@@ -0,0 +1,9 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ globalThis.__mikuprojectTypes = {
+ __ready: true
+ };
+})();
diff --git a/src/js/wbs-markdown.js b/src/js/wbs-markdown.js
new file mode 100644
index 0000000..6aa5eb9
--- /dev/null
+++ b/src/js/wbs-markdown.js
@@ -0,0 +1,434 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ const markdownEscape = globalThis.__mikuprojectMarkdownEscape;
+ if (!markdownEscape) {
+ throw new Error("mikuproject markdown escape module is not loaded");
+ }
+ function exportWbsMarkdown(model, options = {}) {
+ const nonWorkingDayTypes = collectProjectNonWorkingDayTypes(model);
+ const holidaySet = new Set([
+ ...collectWbsHolidayDates(model),
+ ...(options.holidayDates || []).map((day) => String(day || "").slice(0, 10)).filter(Boolean)
+ ]);
+ const dateBand = buildDisplayDateBand(model.project.startDate, model.project.finishDate, model.project.currentDate, options.displayDaysBeforeBaseDate, options.displayDaysAfterBaseDate, holidaySet, nonWorkingDayTypes, options.useBusinessDaysForDisplayRange);
+ const calendarNameByUid = new Map(model.calendars.map((calendar) => [calendar.uid, calendar.name]));
+ const resourceNameByUid = new Map(model.resources.map((resource) => [resource.uid, resource.name]));
+ const predecessorNameByUid = new Map(model.tasks.map((task) => [task.uid, task.name]));
+ const resourceNamesByTaskUid = new Map();
+ for (const assignment of model.assignments) {
+ const resourceName = resourceNameByUid.get(assignment.resourceUid);
+ if (!resourceName) {
+ continue;
+ }
+ const resourceNames = resourceNamesByTaskUid.get(assignment.taskUid) || [];
+ if (!resourceNames.includes(resourceName)) {
+ resourceNames.push(resourceName);
+ }
+ resourceNamesByTaskUid.set(assignment.taskUid, resourceNames);
+ }
+ const sections = [
+ "# プロジェクト情報",
+ "",
+ ...buildKeyValueTable([
+ ["プロジェクト名", model.project.name || "-"],
+ ["カレンダ", formatCalendarLabel(model.project.calendarUID, calendarNameByUid)],
+ ["開始日", formatWbsDate(model.project.startDate)],
+ ["終了日", formatWbsDate(model.project.finishDate)],
+ ["現在日", formatWbsDate(model.project.currentDate)],
+ ["祝日", String(holidaySet.size)]
+ ]),
+ "",
+ "# WBS ツリー",
+ "",
+ ...wrapFenceBlock(buildTreeLines(model.tasks, holidaySet, nonWorkingDayTypes, options.useBusinessDaysForProgressBand), "text"),
+ "",
+ "---",
+ "",
+ "# WBS テーブル",
+ "",
+ ...buildWbsTable(model.tasks, holidaySet, nonWorkingDayTypes, resourceNamesByTaskUid, predecessorNameByUid, options.useBusinessDaysForProgressBand),
+ "",
+ "---",
+ "",
+ "# サマリ",
+ "",
+ ...buildKeyValueTable([
+ ["表示日", String(dateBand.length)],
+ ["表示週", String(dateBand.length > 0 ? Math.ceil(dateBand.length / 7) : 0)],
+ ["営業日", String(countBusinessDays(dateBand, holidaySet, nonWorkingDayTypes))],
+ ["前日数", formatOptionalCount(options.displayDaysBeforeBaseDate)],
+ ["後日数", formatOptionalCount(options.displayDaysAfterBaseDate)],
+ ["表示", options.useBusinessDaysForDisplayRange ? "営業日" : "暦日"],
+ ["進捗", options.useBusinessDaysForProgressBand ? "営業日" : "暦日"],
+ ["基準日", formatWbsDate(model.project.currentDate)],
+ ["タスク", String(model.tasks.length)],
+ ["リソース", String(model.resources.length)],
+ ["割当", String(model.assignments.length)],
+ ["カレンダ", String(model.calendars.length)]
+ ]),
+ ""
+ ];
+ return sections.join("\n");
+ }
+ function buildKeyValueTable(rows) {
+ return [
+ "| 項目 | 値 |",
+ "| --- | --- |",
+ ...rows.map(([label, value]) => `| ${escapeMarkdownCell(label)} | ${escapeMarkdownCell(value)} |`)
+ ];
+ }
+ function buildWbsTable(tasks, holidaySet, nonWorkingDayTypes, resourceNamesByTaskUid, predecessorNameByUid, useBusinessDaysForProgressBand) {
+ const header = [
+ "WBS",
+ "種別",
+ "階層",
+ "名称",
+ "開始",
+ "終了",
+ "期間",
+ "タスク詳細",
+ "進捗",
+ "担当",
+ "リソース",
+ "先行"
+ ];
+ const lines = [
+ `| ${header.join(" | ")} |`,
+ `| ${header.map(() => "---").join(" | ")} |`
+ ];
+ for (const task of tasks) {
+ const resourceNames = resourceNamesByTaskUid.get(task.uid) || [];
+ lines.push([
+ task.wbs || task.outlineNumber || "-",
+ classifyTaskKind(task),
+ String(task.outlineLevel || "-"),
+ formatTableTaskLabel(task),
+ formatWbsDate(task.start),
+ formatWbsDate(task.finish),
+ formatDurationLabel(task, holidaySet, nonWorkingDayTypes, useBusinessDaysForProgressBand),
+ formatNoteCell(task.notes),
+ formatPercentCell(task.percentComplete),
+ firstResourceName(resourceNames) || "-",
+ resourceNames.join(", ") || "-",
+ task.predecessors.map((item) => predecessorNameByUid.get(item.predecessorUid) || item.predecessorUid).join(", ") || "-"
+ ].map((value) => escapeMarkdownCell(value)).join(" | ").replace(/^/, "| ").concat(" |"));
+ }
+ return lines;
+ }
+ function buildTreeLines(tasks, holidaySet, nonWorkingDayTypes, useBusinessDaysForProgressBand) {
+ const lines = [];
+ for (const task of tasks) {
+ const indent = task.outlineLevel > 1 ? `${" ".repeat(Math.max(0, task.outlineLevel - 2))}┗ ` : "";
+ const taskLine = `${indent}${formatTreeInlineText(task.wbs || task.outlineNumber || "-")} ${formatTreeInlineText(task.name || "-")} (${formatTreeInlineText(formatTreeDateRange(task.start, task.finish))}): ${formatTreeInlineText(formatPercentCell(task.percentComplete))}`;
+ lines.push(taskLine);
+ if (task.notes && task.notes.trim()) {
+ const noteIndent = `${" ".repeat(Math.max(0, task.outlineLevel - 1))} `;
+ const noteLines = normalizeFenceText(task.notes)
+ .split("\n")
+ .filter(Boolean);
+ if (noteLines.length > 0) {
+ lines.push(`${noteIndent}詳細: ${noteLines[0]}`);
+ for (const line of noteLines.slice(1)) {
+ lines.push(`${noteIndent} ${line}`);
+ }
+ }
+ }
+ }
+ return lines.length > 0 ? lines : ["(task なし)"];
+ }
+ function classifyTaskKind(task) {
+ if (task.summary) {
+ return "フェーズ";
+ }
+ if (task.milestone) {
+ return "マイル";
+ }
+ return "タスク";
+ }
+ function formatTableTaskLabel(task) {
+ return task.name || "-";
+ }
+ function formatNoteCell(value) {
+ const normalized = normalizeTextBlock(value);
+ return normalized ? normalized : "-";
+ }
+ function formatPercentCell(value) {
+ if (value === undefined || value === null || !Number.isFinite(value)) {
+ return "-";
+ }
+ return `${Math.max(0, Math.min(100, Math.round(value)))}%`;
+ }
+ function formatFlagCell(flag, label) {
+ return flag ? label : "-";
+ }
+ function formatOptionalCount(value) {
+ if (value === undefined || value === null || !Number.isFinite(value)) {
+ return "-";
+ }
+ return String(Math.max(0, Math.floor(value)));
+ }
+ function escapeMarkdownCell(value) {
+ return markdownEscape.escapeMarkdownTableCell(String(value || ""));
+ }
+ function formatTreeInlineText(value) {
+ return normalizeFenceText(value).replace(/\n+/g, " / ");
+ }
+ // normalizeTextBlock only performs text normalization for export:
+ // newline normalization, control-character removal, tab expansion,
+ // trailing-space trimming, and blank-line compaction. It does not apply
+ // Markdown-specific escaping.
+ function normalizeTextBlock(value) {
+ return String(value || "").trim()
+ .replace(/\r\n?/g, "\n")
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
+ .replace(/\t/g, " ")
+ .split("\n")
+ .map((line) => line.trimEnd())
+ .filter((line, index, lines) => !(line === "" && lines[index - 1] === ""))
+ .join("\n");
+ }
+ // Code-fence text does not need normal Markdown escaping, but it does need
+ // normalized content so fence selection and indentation stay predictable.
+ function normalizeFenceText(value) {
+ return normalizeTextBlock(value);
+ }
+ function wrapFenceBlock(lines, infoString) {
+ const content = lines.join("\n");
+ const backtickRun = longestFenceRun(content, "`");
+ const tildeRun = longestFenceRun(content, "~");
+ const fenceChar = tildeRun <= backtickRun ? "~" : "`";
+ const fenceLength = Math.max(3, (fenceChar === "~" ? tildeRun : backtickRun) + 1);
+ const fence = fenceChar.repeat(fenceLength);
+ return [`${fence}${infoString}`, ...lines, fence];
+ }
+ function longestFenceRun(text, char) {
+ let maxRun = 0;
+ let currentRun = 0;
+ for (const currentChar of text) {
+ if (currentChar === char) {
+ currentRun += 1;
+ if (currentRun > maxRun) {
+ maxRun = currentRun;
+ }
+ }
+ else {
+ currentRun = 0;
+ }
+ }
+ return maxRun;
+ }
+ function collectWbsHolidayDates(model) {
+ const holidaySet = new Set();
+ for (const calendar of model.calendars) {
+ for (const exception of calendar.exceptions || []) {
+ if (exception.dayWorking !== false && (exception.workingTimes || []).length > 0) {
+ continue;
+ }
+ for (const day of expandExceptionDays(exception)) {
+ holidaySet.add(day);
+ }
+ }
+ }
+ return Array.from(holidaySet).sort();
+ }
+ function expandExceptionDays(exception) {
+ const singleDay = exception.fromDate ? formatDateOnly(parseDateOnly(exception.fromDate)) : "";
+ if (!exception.fromDate || !exception.toDate) {
+ return singleDay ? [singleDay] : [];
+ }
+ return buildDateBand(exception.fromDate, exception.toDate);
+ }
+ function resolveProjectCalendar(model) {
+ if (model.project.calendarUID) {
+ const projectCalendar = model.calendars.find((calendar) => calendar.uid === model.project.calendarUID);
+ if (projectCalendar) {
+ return projectCalendar;
+ }
+ }
+ return model.calendars.find((calendar) => calendar.isBaseCalendar) || model.calendars[0];
+ }
+ function resolveCalendarDayWorking(calendarByUid, calendar, dayType, visiting = new Set()) {
+ if (!calendar) {
+ return undefined;
+ }
+ if (visiting.has(calendar.uid)) {
+ return undefined;
+ }
+ visiting.add(calendar.uid);
+ const weekDay = calendar.weekDays.find((item) => item.dayType === dayType);
+ if (weekDay) {
+ return weekDay.dayWorking;
+ }
+ if (calendar.baseCalendarUID) {
+ return resolveCalendarDayWorking(calendarByUid, calendarByUid.get(calendar.baseCalendarUID), dayType, visiting);
+ }
+ return undefined;
+ }
+ function collectProjectNonWorkingDayTypes(model) {
+ const calendarByUid = new Map(model.calendars.map((calendar) => [calendar.uid, calendar]));
+ const projectCalendar = resolveProjectCalendar(model);
+ const nonWorkingDayTypes = new Set();
+ for (let dayType = 1; dayType <= 7; dayType += 1) {
+ const dayWorking = resolveCalendarDayWorking(calendarByUid, projectCalendar, dayType);
+ if (dayWorking === false) {
+ nonWorkingDayTypes.add(dayType);
+ continue;
+ }
+ if (dayWorking === undefined && (dayType === 1 || dayType === 7)) {
+ nonWorkingDayTypes.add(dayType);
+ }
+ }
+ return nonWorkingDayTypes;
+ }
+ function buildDateBand(startDate, finishDate) {
+ const start = parseDateOnly(startDate);
+ const finish = parseDateOnly(finishDate);
+ if (!start || !finish || start.getTime() > finish.getTime()) {
+ return [];
+ }
+ const days = [];
+ const cursor = new Date(start.getTime());
+ while (cursor.getTime() <= finish.getTime()) {
+ days.push(formatDateOnly(cursor));
+ cursor.setDate(cursor.getDate() + 1);
+ }
+ return days;
+ }
+ function buildDisplayDateBand(startDate, finishDate, baseDate, displayDaysBeforeBaseDate, displayDaysAfterBaseDate, holidaySet, nonWorkingDayTypes, useBusinessDaysForDisplayRange) {
+ const fullBand = buildDateBand(startDate, finishDate);
+ const before = normalizeDisplayDayCount(displayDaysBeforeBaseDate);
+ const after = normalizeDisplayDayCount(displayDaysAfterBaseDate);
+ if (before === null && after === null) {
+ return fullBand;
+ }
+ const base = parseDateOnly(baseDate);
+ if (!base || fullBand.length === 0) {
+ return fullBand;
+ }
+ const projectStart = parseDateOnly(startDate);
+ const projectFinish = parseDateOnly(finishDate);
+ if (!projectStart || !projectFinish) {
+ return fullBand;
+ }
+ const from = useBusinessDaysForDisplayRange
+ ? shiftBusinessDays(base, -(before || 0), holidaySet, nonWorkingDayTypes)
+ : shiftCalendarDays(base, -(before || 0));
+ const to = useBusinessDaysForDisplayRange
+ ? shiftBusinessDays(base, after || 0, holidaySet, nonWorkingDayTypes)
+ : shiftCalendarDays(base, after || 0);
+ const clampedStart = from.getTime() < projectStart.getTime() ? projectStart : from;
+ const clampedFinish = to.getTime() > projectFinish.getTime() ? projectFinish : to;
+ if (clampedStart.getTime() > clampedFinish.getTime()) {
+ return fullBand;
+ }
+ return buildDateBand(formatDateOnly(clampedStart), formatDateOnly(clampedFinish));
+ }
+ function normalizeDisplayDayCount(value) {
+ if (value === undefined || value === null || !Number.isFinite(value)) {
+ return null;
+ }
+ return Math.max(0, Math.floor(value));
+ }
+ function shiftCalendarDays(base, offset) {
+ const result = new Date(base.getTime());
+ result.setDate(result.getDate() + offset);
+ return result;
+ }
+ function shiftBusinessDays(base, offset, holidaySet, nonWorkingDayTypes) {
+ const result = new Date(base.getTime());
+ const direction = offset < 0 ? -1 : 1;
+ let remaining = Math.abs(offset);
+ while (remaining > 0) {
+ result.setDate(result.getDate() + direction);
+ const day = formatDateOnly(result);
+ if (isWeeklyNonWorkingDay(day, nonWorkingDayTypes) || holidaySet.has(day)) {
+ continue;
+ }
+ remaining -= 1;
+ }
+ return result;
+ }
+ function countBusinessDays(dateBand, holidaySet, nonWorkingDayTypes) {
+ return dateBand.filter((day) => !isWeeklyNonWorkingDay(day, nonWorkingDayTypes) && !holidaySet.has(day)).length;
+ }
+ function enumerateBusinessDays(startDate, finishDate, holidaySet, nonWorkingDayTypes) {
+ return buildDateBand(startDate, finishDate).filter((day) => !isWeeklyNonWorkingDay(day, nonWorkingDayTypes) && !holidaySet.has(day));
+ }
+ function formatDurationLabel(task, holidaySet, nonWorkingDayTypes, useBusinessDaysForProgressBand) {
+ if (useBusinessDaysForProgressBand) {
+ const businessDays = enumerateBusinessDays(task.start, task.finish, holidaySet, nonWorkingDayTypes).length;
+ return businessDays > 0 ? `${businessDays}営業日` : "-";
+ }
+ const calendarDays = buildDateBand(task.start, task.finish).length;
+ return calendarDays > 0 ? `${calendarDays}日` : "-";
+ }
+ function formatWbsDate(value) {
+ return value ? value.slice(0, 10) : "-";
+ }
+ function formatTreeDate(value) {
+ if (!value) {
+ return "-";
+ }
+ const match = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
+ if (!match) {
+ return String(value).slice(0, 10) || "-";
+ }
+ const [, _year, month, day] = match;
+ return `${Number(month)}/${Number(day)}`;
+ }
+ function formatTreeDateRange(start, finish) {
+ const startLabel = formatTreeDate(start);
+ const finishLabel = formatTreeDate(finish);
+ if (startLabel === finishLabel) {
+ return startLabel;
+ }
+ return `${startLabel} - ${finishLabel}`;
+ }
+ function parseDateOnly(value) {
+ if (!value) {
+ return null;
+ }
+ const match = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
+ if (!match) {
+ return null;
+ }
+ const [, year, month, day] = match;
+ return new Date(Number(year), Number(month) - 1, Number(day));
+ }
+ function formatDateOnly(value) {
+ if (!value) {
+ return "";
+ }
+ const year = value.getFullYear();
+ const month = String(value.getMonth() + 1).padStart(2, "0");
+ const day = String(value.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+ }
+ function isWeeklyNonWorkingDay(day, nonWorkingDayTypes) {
+ const target = parseDateOnly(day);
+ if (!target) {
+ return false;
+ }
+ const dayType = target.getDay() === 0 ? 1 : target.getDay() + 1;
+ return nonWorkingDayTypes.has(dayType);
+ }
+ function firstResourceName(resourceNames) {
+ if (!resourceNames || resourceNames.length === 0) {
+ return "";
+ }
+ return resourceNames[0];
+ }
+ function formatCalendarLabel(calendarUID, calendarNameByUid) {
+ if (!calendarUID) {
+ return "-";
+ }
+ const calendarName = calendarNameByUid.get(calendarUID);
+ return calendarName ? `${calendarUID} ${calendarName}` : calendarUID;
+ }
+ globalThis.__mikuprojectWbsMarkdown = {
+ exportWbsMarkdown
+ };
+})();
diff --git a/src/js/wbs-xlsx.js b/src/js/wbs-xlsx.js
new file mode 100644
index 0000000..67830e1
--- /dev/null
+++ b/src/js/wbs-xlsx.js
@@ -0,0 +1,1177 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ const HEADER_FILL = "#D9EAF7";
+ const HEADER_ID_FILL = "#E1EDF8";
+ const HEADER_STRUCTURE_FILL = "#E6F0DF";
+ const HEADER_SCHEDULE_FILL = "#FDE7D3";
+ const HEADER_STATUS_FILL = "#FBE4EC";
+ const HEADER_ASSIGNMENT_FILL = "#E2F1EF";
+ const SUMMARY_SCHEDULE_FILL = "#FDF1E4";
+ const SUMMARY_ASSIGNMENT_FILL = "#E8F4F1";
+ const PHASE_FILL = "#EEF7E8";
+ const TASK_KIND_FILL = "#EEF2F6";
+ const MILESTONE_FILL = "#FFF4E0";
+ const IDENTIFIER_FILL = "#F7F9FC";
+ const PLACEHOLDER_FILL = "#F5F7FA";
+ const BAND_FILL = "#F4F7FB";
+ const ACTIVE_BAND_FILL = "#9FD5C9";
+ const PROGRESS_BAND_FILL = "#5BAE9C";
+ const WEEKEND_BAND_FILL = "#C9D3E1";
+ const WEEK_START_BAND_FILL = "#E3EEF9";
+ const MONTH_BOUNDARY_WEEK_FILL = "#D6E7F8";
+ const MONTH_START_HEADER_FILL = "#DCEAF7";
+ const SATURDAY_HEADER_FILL = "#8EA9DB";
+ const SUNDAY_HEADER_FILL = "#E6B8AF";
+ const TODAY_BAND_FILL = "#FFE6A7";
+ const TODAY_ACTIVE_BAND_FILL = "#F3C96B";
+ const TODAY_PROGRESS_BAND_FILL = "#D89A2B";
+ const HOLIDAY_BAND_FILL = "#FCE4EC";
+ const DIVIDER_FILL = "#D9E2EA";
+ const BASEDATE_GUIDE_TAIL_FILL = "#FFF8E1";
+ const NAME_COLUMN_FILL = "#FBFCFE";
+ const SCHEDULE_COLUMN_FILL = "#FCFAF7";
+ const PROGRESS_COLUMN_FILL = "#FCF8FB";
+ const REFERENCE_COLUMN_FILL = "#F8FBFB";
+ const WBS_LAYOUT = createWbsSheetLayoutHelper();
+ const pxWidth = (pixels) => Math.round((pixels / 7) * 100) / 100;
+ function collectWbsHolidayDates(model) {
+ const holidaySet = new Set();
+ for (const calendar of model.calendars) {
+ for (const exception of calendar.exceptions || []) {
+ if (exception.dayWorking !== false && (exception.workingTimes || []).length > 0) {
+ continue;
+ }
+ for (const day of expandExceptionDays(exception)) {
+ holidaySet.add(day);
+ }
+ }
+ }
+ return Array.from(holidaySet).sort();
+ }
+ function resolveProjectCalendar(model) {
+ if (model.project.calendarUID) {
+ const projectCalendar = model.calendars.find((calendar) => calendar.uid === model.project.calendarUID);
+ if (projectCalendar) {
+ return projectCalendar;
+ }
+ }
+ return model.calendars.find((calendar) => calendar.isBaseCalendar) || model.calendars[0];
+ }
+ function resolveCalendarDayWorking(calendarByUid, calendar, dayType, visiting = new Set()) {
+ if (!calendar) {
+ return undefined;
+ }
+ if (visiting.has(calendar.uid)) {
+ return undefined;
+ }
+ visiting.add(calendar.uid);
+ const weekDay = calendar.weekDays.find((item) => item.dayType === dayType);
+ if (weekDay) {
+ return weekDay.dayWorking;
+ }
+ if (calendar.baseCalendarUID) {
+ return resolveCalendarDayWorking(calendarByUid, calendarByUid.get(calendar.baseCalendarUID), dayType, visiting);
+ }
+ return undefined;
+ }
+ function collectProjectNonWorkingDayTypes(model) {
+ const calendarByUid = new Map(model.calendars.map((calendar) => [calendar.uid, calendar]));
+ const projectCalendar = resolveProjectCalendar(model);
+ const nonWorkingDayTypes = new Set();
+ for (let dayType = 1; dayType <= 7; dayType += 1) {
+ const dayWorking = resolveCalendarDayWorking(calendarByUid, projectCalendar, dayType);
+ if (dayWorking === false) {
+ nonWorkingDayTypes.add(dayType);
+ continue;
+ }
+ if (dayWorking === undefined && (dayType === 1 || dayType === 7)) {
+ nonWorkingDayTypes.add(dayType);
+ }
+ }
+ return nonWorkingDayTypes;
+ }
+ function exportWbsWorkbook(model, options = {}) {
+ const nonWorkingDayTypes = collectProjectNonWorkingDayTypes(model);
+ const resourceNameByUid = new Map(model.resources.map((resource) => [resource.uid, resource.name]));
+ const predecessorNameByUid = new Map(model.tasks.map((task) => [task.uid, task.name]));
+ const calendarNameByUid = new Map(model.calendars.map((calendar) => [calendar.uid, calendar.name]));
+ const resourceNamesByTaskUid = new Map();
+ const holidaySet = new Set([
+ ...collectWbsHolidayDates(model),
+ ...(options.holidayDates || []).map((day) => day.slice(0, 10))
+ ]);
+ for (const assignment of model.assignments) {
+ const resourceName = resourceNameByUid.get(assignment.resourceUid);
+ if (!resourceName) {
+ continue;
+ }
+ const resourceNames = resourceNamesByTaskUid.get(assignment.taskUid) || [];
+ if (!resourceNames.includes(resourceName)) {
+ resourceNames.push(resourceName);
+ }
+ resourceNamesByTaskUid.set(assignment.taskUid, resourceNames);
+ }
+ const dateBand = buildDisplayDateBand(model.project.startDate, model.project.finishDate, model.project.currentDate, options.displayDaysBeforeBaseDate, options.displayDaysAfterBaseDate, holidaySet, nonWorkingDayTypes, options.useBusinessDaysForDisplayRange);
+ const fixedHeaders = [
+ "UID",
+ "ID",
+ "WBS",
+ "種別",
+ "階層",
+ "名称",
+ "開始",
+ "終了",
+ "期間",
+ "タスク詳細",
+ "進捗",
+ "作業進捗",
+ "マイル",
+ "サマリ",
+ "クリティカル",
+ "担当",
+ "カレンダ",
+ "リソース",
+ "先行"
+ ];
+ const dividerColumnIndex = fixedHeaders.length + 1;
+ const dateBandStartColumnIndex = dividerColumnIndex;
+ const totalColumns = fixedHeaders.length + 1 + dateBand.length;
+ const rows = [];
+ const mergedRanges = [];
+ const projectInfoBlock = projectInfoRows(model.project, calendarNameByUid, holidaySet.size, totalColumns, 0, rows.length + 1);
+ overlayRows(rows, 0, projectInfoBlock.rows, totalColumns);
+ const exportTimestampRow = rows[1] || (rows[1] = emptyRow(totalColumns));
+ exportTimestampRow.cells[9] = {
+ value: formatWbsExportTimestamp(new Date()),
+ horizontalAlign: "left",
+ verticalAlign: "center"
+ };
+ mergedRanges.push(...projectInfoBlock.mergedRanges);
+ rows.push(dateBandHeaderRow(fixedHeaders.length + 1, dateBand, model.project.currentDate, holidaySet, nonWorkingDayTypes));
+ rows.push(weekdayHeaderRow(fixedHeaders, dateBand, model.project.currentDate, holidaySet, nonWorkingDayTypes));
+ rows.push(...model.tasks.map((task) => ({
+ height: taskRowHeight(task),
+ cells: [
+ identifierCell(task, task.uid),
+ identifierCell(task, task.id),
+ identifierCell(task, task.wbs || task.outlineNumber),
+ kindCell(task),
+ identifierCell(task, task.outlineLevel),
+ taskCell(task, formatTaskLabel(task), "left"),
+ taskCell(task, formatWbsDate(task.start), "center"),
+ taskCell(task, formatWbsDate(task.finish), "center"),
+ taskCell(task, formatDurationLabel(task, holidaySet, nonWorkingDayTypes, options.useBusinessDaysForProgressBand), "center"),
+ detailCell(task, task.notes),
+ progressCell(task, task.percentComplete),
+ progressCell(task, task.percentWorkComplete),
+ flagCell(task, task.milestone, "Mil"),
+ flagCell(task, task.summary, "Sum"),
+ flagCell(task, task.critical, "Crit"),
+ referenceCell(task, truncateWbsReference(firstResourceName(resourceNamesByTaskUid.get(task.uid)), 14), "center"),
+ referenceCell(task, formatCalendarLabel(task.calendarUID || model.project.calendarUID, calendarNameByUid), "center"),
+ referenceCell(task, truncateWbsReference((resourceNamesByTaskUid.get(task.uid) || []).join(", "), 18)),
+ referenceCell(task, truncateWbsReference(task.predecessors.map((item) => predecessorNameByUid.get(item.predecessorUid) || item.predecessorUid).join(", "), 18)),
+ dividerCell(),
+ ...dateBand.map((day) => dateBandCell(task, day, model.project.currentDate, holidaySet, nonWorkingDayTypes, options.useBusinessDaysForProgressBand))
+ ]
+ })));
+ rows.push(emptyRow(totalColumns, 28));
+ const legendBlock = legendRows(totalColumns, rows.length + 1);
+ rows.push(...legendBlock.rows);
+ mergedRanges.push(...legendBlock.mergedRanges);
+ rows.push(emptyRow(totalColumns, 28));
+ const summaryBlock = displaySummaryRows(dateBand.length, countBusinessDays(dateBand, holidaySet, nonWorkingDayTypes), model.project.currentDate, model.tasks.length, model.resources.length, model.assignments.length, model.calendars.length, totalColumns, 0, rows.length + 1, options.displayDaysBeforeBaseDate, options.displayDaysAfterBaseDate, options.useBusinessDaysForDisplayRange, options.useBusinessDaysForProgressBand);
+ rows.push(...summaryBlock.rows);
+ mergedRanges.push(...summaryBlock.mergedRanges);
+ return {
+ sheets: [
+ {
+ name: "WBS",
+ columns: [
+ { width: pxWidth(45) }, { width: pxWidth(45) }, { width: pxWidth(65) }, { width: pxWidth(60) }, { width: pxWidth(45) }, { width: 42 },
+ { width: pxWidth(85) }, { width: pxWidth(85) }, { width: pxWidth(65) }, { width: 28 }, { width: 14 },
+ { width: 18, hidden: true }, { width: 12, hidden: true }, { width: 12, hidden: true }, { width: 12, hidden: true },
+ { width: pxWidth(85) }, { width: 12, hidden: true }, { width: 20, hidden: true }, { width: 18, hidden: true }, { width: 3 },
+ ...dateBand.map(() => ({ width: 6 }))
+ ],
+ mergedRanges,
+ rows
+ }
+ ]
+ };
+ }
+ function emptyRow(columnCount, height = 22) {
+ return {
+ height,
+ cells: Array.from({ length: columnCount }, () => ({}))
+ };
+ }
+ function overlayRows(rows, startIndex, blockRows, columnCount) {
+ blockRows.forEach((blockRow, offset) => {
+ const rowIndex = startIndex + offset;
+ if (!rows[rowIndex]) {
+ rows[rowIndex] = emptyRow(columnCount);
+ }
+ const targetRow = rows[rowIndex];
+ if ((blockRow.height || 0) > (targetRow.height || 0)) {
+ targetRow.height = blockRow.height;
+ }
+ blockRow.cells.forEach((cell, cellIndex) => {
+ if (hasCellContent(cell)) {
+ targetRow.cells[cellIndex] = cell;
+ }
+ });
+ });
+ }
+ function hasCellContent(cell) {
+ return !!cell && Object.keys(cell).length > 0;
+ }
+ function formatTaskLabel(task) {
+ const prefix = task.summary ? "> " : (task.milestone ? "* " : "- ");
+ return `${" ".repeat(Math.max(0, task.outlineLevel - 1))}${prefix}${task.name}`;
+ }
+ function classifyTaskKind(task) {
+ if (task.summary) {
+ return "フェーズ";
+ }
+ if (task.milestone) {
+ return "マイル";
+ }
+ return "タスク";
+ }
+ function firstResourceName(resourceNames) {
+ if (!resourceNames || resourceNames.length === 0) {
+ return "";
+ }
+ return resourceNames[0];
+ }
+ function formatCalendarLabel(calendarUID, calendarNameByUid) {
+ if (!calendarUID) {
+ return "-";
+ }
+ const calendarName = calendarNameByUid.get(calendarUID);
+ return calendarName ? `${calendarUID} ${truncateWbsReference(calendarName, 9)}` : calendarUID;
+ }
+ function displayReferenceValue(value) {
+ return value && value.trim() ? value : "-";
+ }
+ function truncateWbsReference(value, maxLength) {
+ const normalized = (value === null || value === void 0 ? void 0 : value.trim()) || "";
+ if (!normalized) {
+ return "";
+ }
+ if (normalized.length <= maxLength) {
+ return normalized;
+ }
+ return `${normalized.slice(0, Math.max(1, maxLength - 3))}...`;
+ }
+ function referenceCell(task, value, horizontalAlign = "center") {
+ const displayValue = displayReferenceValue(value);
+ const placeholder = displayValue === "-";
+ return {
+ value: displayValue,
+ border: "thin",
+ horizontalAlign: placeholder ? "center" : horizontalAlign,
+ verticalAlign: "center",
+ bold: task.summary || task.milestone || false,
+ fillColor: placeholder
+ ? PLACEHOLDER_FILL
+ : (task.summary
+ ? PHASE_FILL
+ : (task.milestone ? MILESTONE_FILL : REFERENCE_COLUMN_FILL))
+ };
+ }
+ function sheetTitleRow(title, columnCount) {
+ return {
+ height: 24,
+ cells: [
+ {
+ value: title,
+ bold: true,
+ fontSize: 16,
+ horizontalAlign: "left"
+ },
+ ...Array.from({ length: Math.max(0, columnCount - 1) }, () => ({
+ fillColor: "#EEF4FA"
+ }))
+ ]
+ };
+ }
+ function infoRow(text, columnCount) {
+ return {
+ height: 24,
+ cells: [
+ {
+ value: text,
+ border: "thin",
+ horizontalAlign: "left"
+ },
+ ...Array.from({ length: Math.max(0, columnCount - 1) }, () => ({}))
+ ]
+ };
+ }
+ function projectInfoRows(project, calendarNameByUid, holidayCount, columnCount, startColumnIndex, startRowNumber) {
+ const items = [
+ { label: "プロジェクト名", value: truncateWbsReference(project.name || "-", 18) || "-", fillColor: SUMMARY_ASSIGNMENT_FILL },
+ { label: "カレンダ", value: formatCalendarLabel(project.calendarUID, calendarNameByUid), fillColor: SUMMARY_ASSIGNMENT_FILL },
+ { label: "開始日", value: formatWbsDate(project.startDate), fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "終了日", value: formatWbsDate(project.finishDate), fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "現在日", value: formatWbsDate(project.currentDate), fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "祝日", value: holidayCount, fillColor: SUMMARY_SCHEDULE_FILL }
+ ];
+ return {
+ mergedRanges: [
+ WBS_LAYOUT.range(WBS_LAYOUT.reference(startRowNumber, startColumnIndex), WBS_LAYOUT.reference(startRowNumber, startColumnIndex + 4)),
+ ...items.map((_, index) => {
+ const rowNumber = startRowNumber + index + 1;
+ return [
+ WBS_LAYOUT.range(WBS_LAYOUT.reference(rowNumber, startColumnIndex), WBS_LAYOUT.reference(rowNumber, startColumnIndex + 1)),
+ WBS_LAYOUT.range(WBS_LAYOUT.reference(rowNumber, startColumnIndex + 2), WBS_LAYOUT.reference(rowNumber, startColumnIndex + 4))
+ ];
+ }).flat()
+ ],
+ rows: [
+ projectBlockHeaderRow(columnCount, startColumnIndex, "プロジェクト情報"),
+ ...items.map((item) => projectPairRow(columnCount, startColumnIndex, item.label, item.value, item.fillColor))
+ ]
+ };
+ }
+ function legendRows(columnCount, startRowNumber) {
+ const items = [
+ { value: "進捗済み", fillColor: PROGRESS_BAND_FILL },
+ { value: "予定帯", fillColor: ACTIVE_BAND_FILL },
+ { value: "当日", fillColor: TODAY_BAND_FILL },
+ { value: "週頭", fillColor: WEEK_START_BAND_FILL },
+ { value: "週末", fillColor: WEEKEND_BAND_FILL },
+ { value: "祝日", fillColor: HOLIDAY_BAND_FILL },
+ { value: "━:フェーズ", fillColor: PHASE_FILL },
+ { value: "■:進捗済みタスク", fillColor: PROGRESS_BAND_FILL },
+ { value: "□:予定タスク", fillColor: ACTIVE_BAND_FILL },
+ { value: "◆:マイルストーン", fillColor: MILESTONE_FILL },
+ { value: "Mil:マイルストーン", fillColor: "#FBE4EC" },
+ { value: "Sum:サマリ", fillColor: "#F7EAF0" },
+ { value: "Crit:クリティカル", fillColor: "#F3E1E9" },
+ { value: "-:未設定", fillColor: PLACEHOLDER_FILL }
+ ];
+ const startColumnRef = WBS_LAYOUT.reference(startRowNumber, WBS_LAYOUT.columnIndex("A"));
+ const endColumnRef = WBS_LAYOUT.reference(startRowNumber, WBS_LAYOUT.columnIndex("C"));
+ return {
+ mergedRanges: [
+ WBS_LAYOUT.range(startColumnRef, endColumnRef),
+ ...items.map((_, index) => WBS_LAYOUT.range(WBS_LAYOUT.reference(startRowNumber + index + 1, WBS_LAYOUT.columnIndex("A")), WBS_LAYOUT.reference(startRowNumber + index + 1, WBS_LAYOUT.columnIndex("C"))))
+ ],
+ rows: [
+ blockHeaderRow(columnCount, 0, "凡例"),
+ ...items.map((item) => mergedLabelRow(columnCount, 0, item.value, item.fillColor))
+ ]
+ };
+ }
+ function weekBandRow(fixedColumnCount, weekBandRanges, dateBandLength) {
+ const weekLabelColumnIndex = WBS_LAYOUT.columnIndex("S");
+ const dividerColumnIndex = WBS_LAYOUT.columnIndex("T");
+ const bandCells = Array.from({ length: dateBandLength }, () => ({}));
+ weekBandRanges.forEach((item, index) => {
+ bandCells[item.startIndex] = {
+ value: item.label,
+ bold: true,
+ fontSize: 14,
+ border: "thin",
+ horizontalAlign: "center",
+ fillColor: item.hasMonthBoundary ? MONTH_BOUNDARY_WEEK_FILL : (index % 2 === 0 ? "#EDF4FB" : "#EAF1F9")
+ };
+ });
+ return {
+ height: 24,
+ cells: [
+ ...Array.from({ length: fixedColumnCount }, (_, index) => {
+ if (index === weekLabelColumnIndex) {
+ return {
+ value: "週",
+ bold: true,
+ fontSize: 14,
+ border: "thin",
+ horizontalAlign: "center",
+ fillColor: "#E3EEF9"
+ };
+ }
+ if (index === dividerColumnIndex) {
+ return dividerCell();
+ }
+ if (index < weekLabelColumnIndex) {
+ return {};
+ }
+ return {};
+ }),
+ ...bandCells
+ ]
+ };
+ }
+ function displaySummaryRows(displayDays, businessDays, baseDate, taskCount, resourceCount, assignmentCount, calendarCount, columnCount, startColumnIndex = 5, startRowNumber = 5, displayDaysBeforeBaseDate, displayDaysAfterBaseDate, useBusinessDaysForDisplayRange, useBusinessDaysForProgressBand) {
+ const displayWeeks = displayDays > 0 ? Math.ceil(displayDays / 7) : 0;
+ const scheduleItems = [
+ { label: "表示日", value: displayDays, fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "表示週", value: displayWeeks, fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "営業日", value: businessDays, fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "前日数", value: displayDaysBeforeBaseDate !== null && displayDaysBeforeBaseDate !== void 0 ? displayDaysBeforeBaseDate : "-", fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "後日数", value: displayDaysAfterBaseDate !== null && displayDaysAfterBaseDate !== void 0 ? displayDaysAfterBaseDate : "-", fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "表示", value: useBusinessDaysForDisplayRange ? "営業日" : "暦日", fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "進捗", value: useBusinessDaysForProgressBand ? "営業日" : "暦日", fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "基準日", value: (baseDate || "-").slice(0, 10), fillColor: SUMMARY_SCHEDULE_FILL }
+ ];
+ const countItems = [
+ { label: "タスク", value: taskCount, fillColor: SUMMARY_ASSIGNMENT_FILL },
+ { label: "リソース", value: resourceCount, fillColor: SUMMARY_ASSIGNMENT_FILL },
+ { label: "割当", value: assignmentCount, fillColor: SUMMARY_ASSIGNMENT_FILL },
+ { label: "カレンダ", value: calendarCount, fillColor: SUMMARY_ASSIGNMENT_FILL }
+ ];
+ const blockRows = [blockHeaderRow(columnCount, startColumnIndex, "サマリ")];
+ for (const item of scheduleItems) {
+ blockRows.push(summaryPairRow(columnCount, startColumnIndex, item.label, item.value, item.fillColor));
+ }
+ for (const item of countItems) {
+ blockRows.push(summaryPairRow(columnCount, startColumnIndex, item.label, item.value, item.fillColor));
+ }
+ const mergedRanges = [
+ WBS_LAYOUT.range(WBS_LAYOUT.reference(startRowNumber, startColumnIndex), WBS_LAYOUT.reference(startRowNumber, startColumnIndex + 2))
+ ];
+ for (let index = 1; index < blockRows.length; index += 1) {
+ const rowNumber = startRowNumber + index;
+ mergedRanges.push(WBS_LAYOUT.range(WBS_LAYOUT.reference(rowNumber, startColumnIndex + 1), WBS_LAYOUT.reference(rowNumber, startColumnIndex + 2)));
+ }
+ return {
+ mergedRanges,
+ rows: blockRows
+ };
+ }
+ function blockHeaderRow(columnCount, startColumnIndex, title) {
+ const cells = Array.from({ length: columnCount }, () => ({}));
+ cells[startColumnIndex] = {
+ value: title,
+ border: "thin",
+ horizontalAlign: "left",
+ bold: true,
+ fontSize: 14,
+ fillColor: HEADER_ID_FILL
+ };
+ cells[startColumnIndex + 1] = {
+ value: "",
+ border: "thin",
+ fillColor: HEADER_ID_FILL
+ };
+ cells[startColumnIndex + 2] = {
+ value: "",
+ border: "thin",
+ fillColor: HEADER_ID_FILL
+ };
+ return { height: 24, cells };
+ }
+ function projectBlockHeaderRow(columnCount, startColumnIndex, title) {
+ const cells = Array.from({ length: columnCount }, () => ({}));
+ cells[startColumnIndex] = {
+ value: title,
+ border: "thin",
+ horizontalAlign: "left",
+ bold: true,
+ fontSize: 14,
+ fillColor: HEADER_ID_FILL
+ };
+ for (let offset = 1; offset < 5; offset += 1) {
+ cells[startColumnIndex + offset] = {
+ value: "",
+ border: "thin",
+ fillColor: HEADER_ID_FILL
+ };
+ }
+ return { height: 24, cells };
+ }
+ function projectPairRow(columnCount, startColumnIndex, label, value, fillColor) {
+ const cells = Array.from({ length: columnCount }, () => ({}));
+ cells[startColumnIndex] = {
+ value: label,
+ border: "thin",
+ horizontalAlign: "right",
+ bold: true,
+ fillColor
+ };
+ cells[startColumnIndex + 1] = {
+ value: "",
+ border: "thin",
+ fillColor
+ };
+ cells[startColumnIndex + 2] = {
+ value: stringifyCellValue(value),
+ border: "thin",
+ horizontalAlign: typeof value === "number" ? "center" : "left",
+ bold: true,
+ fillColor
+ };
+ cells[startColumnIndex + 3] = {
+ value: "",
+ border: "thin",
+ fillColor
+ };
+ cells[startColumnIndex + 4] = {
+ value: "",
+ border: "thin",
+ fillColor
+ };
+ return { height: 22, cells };
+ }
+ function summaryPairRow(columnCount, startColumnIndex, label, value, fillColor) {
+ const cells = Array.from({ length: columnCount }, () => ({}));
+ cells[startColumnIndex] = summaryStatCell(label, fillColor, false);
+ cells[startColumnIndex + 1] = summaryStatCell(value, fillColor, true);
+ cells[startColumnIndex + 2] = {
+ value: "",
+ border: "thin",
+ fillColor
+ };
+ return { height: 22, cells };
+ }
+ function overlaySummaryPair(row, startColumnIndex, label, value, fillColor) {
+ row.cells[startColumnIndex] = summaryStatCell(label, fillColor, false);
+ row.cells[startColumnIndex + 1] = summaryStatCell(value, fillColor, true);
+ row.height = Math.max(row.height || 22, 22);
+ }
+ function mergedLabelRow(columnCount, startColumnIndex, value, fillColor) {
+ const cells = Array.from({ length: columnCount }, () => ({}));
+ cells[startColumnIndex] = {
+ value,
+ border: "thin",
+ horizontalAlign: "center",
+ bold: true,
+ fillColor
+ };
+ cells[startColumnIndex + 1] = {
+ value: "",
+ border: "thin",
+ fillColor
+ };
+ cells[startColumnIndex + 2] = {
+ value: "",
+ border: "thin",
+ fillColor
+ };
+ return { height: 24, cells };
+ }
+ function summaryStatCell(value, fillColor, isValueCell) {
+ const valueAlign = typeof value === "number" ? "center" : "left";
+ return {
+ value: stringifyCellValue(value),
+ border: "thin",
+ horizontalAlign: isValueCell ? valueAlign : "right",
+ bold: true,
+ fillColor
+ };
+ }
+ function headerRow(labels) {
+ return {
+ height: 24,
+ cells: labels.map((label) => {
+ if (typeof label === "string") {
+ return {
+ value: label,
+ bold: true,
+ fillColor: headerFillForLabel(label),
+ border: "thin",
+ horizontalAlign: "center",
+ verticalAlign: "center"
+ };
+ }
+ return {
+ border: "thin",
+ horizontalAlign: "center",
+ verticalAlign: "center",
+ ...label
+ };
+ })
+ };
+ }
+ function weekdayRow(fixedColumnCount, dateBand, currentDate, holidaySet, nonWorkingDayTypes) {
+ return {
+ height: 24,
+ cells: [
+ ...Array.from({ length: fixedColumnCount }, () => ({})),
+ ...dateBand.map((day) => weekdayCell(day, currentDate, holidaySet, nonWorkingDayTypes))
+ ]
+ };
+ }
+ function dateBandHeaderRow(fixedColumnCount, dateBand, currentDate, holidaySet, nonWorkingDayTypes) {
+ return {
+ height: 24,
+ cells: [
+ ...Array.from({ length: fixedColumnCount }, () => ({})),
+ ...dateBand.map((day) => dateNumberCell(day, currentDate, holidaySet, nonWorkingDayTypes))
+ ]
+ };
+ }
+ function weekdayHeaderRow(fixedHeaders, dateBand, currentDate, holidaySet, nonWorkingDayTypes) {
+ return headerRow([
+ ...fixedHeaders,
+ dividerCell(),
+ ...dateBand.map((day) => weekdayCell(day, currentDate, holidaySet, nonWorkingDayTypes))
+ ]);
+ }
+ function dividerCell() {
+ return {
+ value: "",
+ fillColor: DIVIDER_FILL,
+ border: "thin",
+ horizontalAlign: "center",
+ verticalAlign: "center"
+ };
+ }
+ function headerFillForLabel(label) {
+ if (label === "UID" || label === "ID") {
+ return HEADER_ID_FILL;
+ }
+ if (label === "WBS" || label === "種別" || label === "階層" || label === "名称") {
+ return HEADER_STRUCTURE_FILL;
+ }
+ if (label === "開始" || label === "終了" || label === "期間") {
+ return HEADER_SCHEDULE_FILL;
+ }
+ if (label === "タスク詳細") {
+ return HEADER_FILL;
+ }
+ if (label === "進捗" || label === "作業進捗" || label === "マイル" || label === "サマリ" || label === "クリティカル") {
+ return HEADER_STATUS_FILL;
+ }
+ if (label === "担当" || label === "カレンダ" || label === "リソース" || label === "先行") {
+ return HEADER_ASSIGNMENT_FILL;
+ }
+ return HEADER_FILL;
+ }
+ function cell(value) {
+ if (value === undefined || value === "") {
+ return {};
+ }
+ return {
+ value: stringifyCellValue(value),
+ border: "thin"
+ };
+ }
+ function stringifyCellValue(value) {
+ return typeof value === "string" ? value : String(value);
+ }
+ function taskCell(task, value, horizontalAlign = "left") {
+ if (value === undefined || value === "") {
+ return {};
+ }
+ return {
+ value: stringifyCellValue(value),
+ border: "thin",
+ horizontalAlign,
+ verticalAlign: "center",
+ wrapText: typeof value === "string" ? true : undefined,
+ bold: task.summary || task.milestone || false,
+ fillColor: task.summary
+ ? PHASE_FILL
+ : (task.milestone
+ ? MILESTONE_FILL
+ : (horizontalAlign === "left"
+ ? NAME_COLUMN_FILL
+ : (horizontalAlign === "center" ? SCHEDULE_COLUMN_FILL : undefined)))
+ };
+ }
+ function detailCell(task, value) {
+ const normalized = (value === null || value === void 0 ? void 0 : value.trim()) || "";
+ const placeholder = !normalized;
+ return {
+ value: placeholder ? "-" : normalized,
+ border: "thin",
+ horizontalAlign: "left",
+ verticalAlign: "center",
+ wrapText: placeholder ? undefined : true,
+ bold: task.summary || task.milestone || false,
+ fillColor: placeholder
+ ? PLACEHOLDER_FILL
+ : (task.summary
+ ? PHASE_FILL
+ : (task.milestone ? MILESTONE_FILL : NAME_COLUMN_FILL))
+ };
+ }
+ function taskRowHeight(task) {
+ const labelLineCount = estimateWrappedLineCount(formatTaskLabel(task), 22);
+ const notesLineCount = estimateWrappedLineCount((task.notes || "").trim(), 18);
+ const maxLineCount = Math.max(labelLineCount, notesLineCount, 1);
+ if (maxLineCount >= 5) {
+ return 82;
+ }
+ if (maxLineCount === 4) {
+ return 70;
+ }
+ if (maxLineCount === 3) {
+ return 58;
+ }
+ if (maxLineCount === 2) {
+ return 46;
+ }
+ return 34;
+ }
+ function estimateWrappedLineCount(value, charactersPerLine) {
+ const normalized = value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
+ if (!normalized) {
+ return 1;
+ }
+ return normalized
+ .split("\n")
+ .reduce((count, line) => count + Math.max(1, Math.ceil(line.length / charactersPerLine)), 0);
+ }
+ function kindCell(task) {
+ return {
+ value: classifyTaskKind(task),
+ border: "thin",
+ horizontalAlign: "center",
+ verticalAlign: "center",
+ bold: true,
+ fillColor: task.summary ? PHASE_FILL : (task.milestone ? MILESTONE_FILL : TASK_KIND_FILL)
+ };
+ }
+ function identifierCell(task, value) {
+ if (value === undefined || value === "") {
+ return {};
+ }
+ return {
+ value: stringifyCellValue(value),
+ border: "thin",
+ horizontalAlign: "center",
+ verticalAlign: "center",
+ bold: task.summary || task.milestone || false,
+ fillColor: task.summary ? PHASE_FILL : (task.milestone ? MILESTONE_FILL : IDENTIFIER_FILL)
+ };
+ }
+ function flagCell(task, enabled, marker) {
+ return {
+ value: enabled ? marker : "",
+ border: "thin",
+ horizontalAlign: "center",
+ verticalAlign: "center",
+ bold: !!enabled,
+ fillColor: task.summary ? PHASE_FILL : (task.milestone ? MILESTONE_FILL : undefined)
+ };
+ }
+ function progressCell(task, value) {
+ const progressValue = formatProgressLabel(value);
+ return {
+ value: progressValue,
+ border: "thin",
+ horizontalAlign: "center",
+ verticalAlign: "center",
+ wrapText: true,
+ bold: task.summary || task.milestone || false,
+ fillColor: task.summary ? PHASE_FILL : (task.milestone ? MILESTONE_FILL : PROGRESS_COLUMN_FILL)
+ };
+ }
+ function formatProgressLabel(value) {
+ if (value === undefined || value === null || !Number.isFinite(value)) {
+ return "";
+ }
+ const clamped = Math.max(0, Math.min(100, Math.round(value)));
+ const filled = Math.round(clamped / 10);
+ const bar = `${"#".repeat(filled)}${"-".repeat(10 - filled)}`;
+ return `${String(clamped).padStart(3, " ")}%\n[${bar}]`;
+ }
+ function formatDurationLabel(task, holidaySet, nonWorkingDayTypes, useBusinessDaysForProgressBand) {
+ if (useBusinessDaysForProgressBand) {
+ const businessDays = enumerateBusinessDays(task.start, task.finish, holidaySet, nonWorkingDayTypes).length;
+ return businessDays > 0 ? `${businessDays}営業日` : "-";
+ }
+ const calendarDays = buildDateBand(task.start, task.finish).length;
+ return calendarDays > 0 ? `${calendarDays}日` : "-";
+ }
+ function formatWbsDate(value) {
+ return value ? value.slice(0, 10) : "-";
+ }
+ function formatWbsExportTimestamp(value) {
+ const year = value.getFullYear();
+ const month = String(value.getMonth() + 1).padStart(2, "0");
+ const day = String(value.getDate()).padStart(2, "0");
+ const hours = String(value.getHours()).padStart(2, "0");
+ const minutes = String(value.getMinutes()).padStart(2, "0");
+ return `出力日時 ${year}-${month}-${day} ${hours}:${minutes}`;
+ }
+ function dateNumberCell(day, currentDate, holidaySet, nonWorkingDayTypes) {
+ const isToday = isSameDay(day, currentDate);
+ const isWeekendDay = isWeeklyNonWorkingDay(day, nonWorkingDayTypes);
+ const isHoliday = holidaySet.has(day);
+ const weekStart = isWeekStart(day);
+ const monthStart = isMonthStart(day);
+ return {
+ value: formatDateValue(day),
+ bold: true,
+ border: "thin",
+ horizontalAlign: "center",
+ verticalAlign: "center",
+ fillColor: isToday ? TODAY_BAND_FILL : (isHoliday ? HOLIDAY_BAND_FILL : (isWeekendDay ? WEEKEND_BAND_FILL : (monthStart ? MONTH_START_HEADER_FILL : (weekStart ? WEEK_START_BAND_FILL : HEADER_FILL))))
+ };
+ }
+ function weekdayCell(day, currentDate, holidaySet, nonWorkingDayTypes) {
+ const isToday = isSameDay(day, currentDate);
+ const isWeekendDay = isWeeklyNonWorkingDay(day, nonWorkingDayTypes);
+ const isHoliday = holidaySet.has(day);
+ const weekStart = isWeekStart(day);
+ const monthStart = isMonthStart(day);
+ const target = parseDateOnly(day);
+ const dayType = target ? (target.getDay() === 0 ? 1 : target.getDay() + 1) : 0;
+ const weekendHeaderFill = dayType === 7 ? SATURDAY_HEADER_FILL : (dayType === 1 ? SUNDAY_HEADER_FILL : WEEKEND_BAND_FILL);
+ return {
+ value: formatWeekdayValue(day),
+ bold: true,
+ border: "thin",
+ horizontalAlign: "center",
+ verticalAlign: "center",
+ fillColor: isHoliday ? HOLIDAY_BAND_FILL : (isWeekendDay ? weekendHeaderFill : (isToday ? TODAY_BAND_FILL : (monthStart ? MONTH_START_HEADER_FILL : (weekStart ? WEEK_START_BAND_FILL : HEADER_FILL))))
+ };
+ }
+ function dateBandCell(task, day, currentDate, holidaySet, nonWorkingDayTypes, useBusinessDaysForProgressBand) {
+ const isWeekendDay = isWeeklyNonWorkingDay(day, nonWorkingDayTypes);
+ const isHoliday = holidaySet.has(day);
+ const isNonWorkingDay = isWeekendDay || isHoliday;
+ const isTaskStart = isSameDay(day, task.start);
+ const isTaskFinish = isSameDay(day, task.finish);
+ const active = includesDay(task.start, task.finish, day) && (!isNonWorkingDay || isTaskStart || isTaskFinish);
+ const isToday = isSameDay(day, currentDate);
+ const weekStart = isWeekStart(day);
+ const complete = active && isCompletedDay(task, day, holidaySet, nonWorkingDayTypes, useBusinessDaysForProgressBand);
+ return {
+ value: active ? activeBandMarker(task, complete) : "",
+ border: "thin",
+ horizontalAlign: "center",
+ verticalAlign: "center",
+ fillColor: active
+ ? (complete
+ ? (isToday ? TODAY_PROGRESS_BAND_FILL : PROGRESS_BAND_FILL)
+ : (isToday ? TODAY_ACTIVE_BAND_FILL : ACTIVE_BAND_FILL))
+ : (isToday ? TODAY_BAND_FILL : (isHoliday ? HOLIDAY_BAND_FILL : (isWeekendDay ? WEEKEND_BAND_FILL : (weekStart ? WEEK_START_BAND_FILL : BAND_FILL))))
+ };
+ }
+ function activeBandMarker(task, complete) {
+ if (task.summary) {
+ return "━";
+ }
+ if (task.milestone) {
+ return "◆";
+ }
+ return complete ? "■" : "□";
+ }
+ function buildDateBand(startDate, finishDate) {
+ const start = parseDateOnly(startDate);
+ const finish = parseDateOnly(finishDate);
+ if (!start || !finish || start.getTime() > finish.getTime()) {
+ return [];
+ }
+ const days = [];
+ const cursor = new Date(start.getTime());
+ while (cursor.getTime() <= finish.getTime()) {
+ days.push(formatDateOnly(cursor));
+ cursor.setDate(cursor.getDate() + 1);
+ }
+ return days;
+ }
+ function buildDisplayDateBand(startDate, finishDate, baseDate, displayDaysBeforeBaseDate, displayDaysAfterBaseDate, holidaySet, nonWorkingDayTypes, useBusinessDaysForDisplayRange) {
+ const fullBand = buildDateBand(startDate, finishDate);
+ const before = normalizeDisplayDayCount(displayDaysBeforeBaseDate);
+ const after = normalizeDisplayDayCount(displayDaysAfterBaseDate);
+ if (before === null && after === null) {
+ return fullBand;
+ }
+ const base = parseDateOnly(baseDate);
+ if (!base || fullBand.length === 0) {
+ return fullBand;
+ }
+ const projectStart = parseDateOnly(startDate);
+ const projectFinish = parseDateOnly(finishDate);
+ if (!projectStart || !projectFinish) {
+ return fullBand;
+ }
+ const from = useBusinessDaysForDisplayRange
+ ? shiftBusinessDays(base, -(before || 0), holidaySet, nonWorkingDayTypes)
+ : shiftCalendarDays(base, -(before || 0));
+ const to = useBusinessDaysForDisplayRange
+ ? shiftBusinessDays(base, after || 0, holidaySet, nonWorkingDayTypes)
+ : shiftCalendarDays(base, after || 0);
+ const clampedStart = from.getTime() < projectStart.getTime() ? projectStart : from;
+ const clampedFinish = to.getTime() > projectFinish.getTime() ? projectFinish : to;
+ if (clampedStart.getTime() > clampedFinish.getTime()) {
+ return fullBand;
+ }
+ return buildDateBand(formatDateOnly(clampedStart), formatDateOnly(clampedFinish));
+ }
+ function normalizeDisplayDayCount(value) {
+ if (value === undefined || value === null || !Number.isFinite(value)) {
+ return null;
+ }
+ return Math.max(0, Math.floor(value));
+ }
+ function countBusinessDays(dateBand, holidaySet, nonWorkingDayTypes) {
+ return dateBand.filter((day) => !isWeeklyNonWorkingDay(day, nonWorkingDayTypes) && !holidaySet.has(day)).length;
+ }
+ function shiftCalendarDays(base, offset) {
+ const result = new Date(base.getTime());
+ result.setDate(result.getDate() + offset);
+ return result;
+ }
+ function shiftBusinessDays(base, offset, holidaySet, nonWorkingDayTypes) {
+ const result = new Date(base.getTime());
+ const direction = offset < 0 ? -1 : 1;
+ let remaining = Math.abs(offset);
+ while (remaining > 0) {
+ result.setDate(result.getDate() + direction);
+ const day = formatDateOnly(result);
+ if (isWeeklyNonWorkingDay(day, nonWorkingDayTypes) || holidaySet.has(day)) {
+ continue;
+ }
+ remaining -= 1;
+ }
+ return result;
+ }
+ function buildWeekBandRanges(dateBand, startColumnIndex, rowNumber) {
+ const ranges = [];
+ if (dateBand.length === 0) {
+ return ranges;
+ }
+ let chunkStart = 0;
+ while (chunkStart < dateBand.length) {
+ const weekStart = formatWeekKey(dateBand[chunkStart]);
+ let chunkEnd = chunkStart;
+ while (chunkEnd + 1 < dateBand.length && formatWeekKey(dateBand[chunkEnd + 1]) === weekStart) {
+ chunkEnd += 1;
+ }
+ const chunkDays = dateBand.slice(chunkStart, chunkEnd + 1);
+ ranges.push({
+ range: WBS_LAYOUT.range(WBS_LAYOUT.reference(rowNumber, startColumnIndex + chunkStart), WBS_LAYOUT.reference(rowNumber, startColumnIndex + chunkEnd)),
+ startIndex: chunkStart,
+ label: formatWeekLabel(weekStart, chunkDays),
+ hasMonthBoundary: chunkDays.some((day) => isMonthStart(day))
+ });
+ chunkStart = chunkEnd + 1;
+ }
+ return ranges;
+ }
+ function includesDay(startDate, finishDate, day) {
+ const start = parseDateOnly(startDate);
+ const finish = parseDateOnly(finishDate);
+ const target = parseDateOnly(day);
+ if (!start || !finish || !target) {
+ return false;
+ }
+ return start.getTime() <= target.getTime() && target.getTime() <= finish.getTime();
+ }
+ function isCompletedDay(task, day, holidaySet, nonWorkingDayTypes, useBusinessDaysForProgressBand) {
+ const start = parseDateOnly(task.start);
+ const finish = parseDateOnly(task.finish);
+ const target = parseDateOnly(day);
+ if (!start || !finish || !target) {
+ return false;
+ }
+ if (useBusinessDaysForProgressBand) {
+ const activeBusinessDays = enumerateBusinessDays(task.start, task.finish, holidaySet, nonWorkingDayTypes);
+ if (activeBusinessDays.length === 0) {
+ return false;
+ }
+ const percent = Math.max(0, Math.min(100, task.percentComplete || 0));
+ const completedDays = Math.floor(activeBusinessDays.length * (percent / 100));
+ const dayKey = formatDateOnly(target);
+ const dayIndex = activeBusinessDays.indexOf(dayKey);
+ return dayIndex >= 0 && dayIndex < completedDays;
+ }
+ const totalDays = Math.floor((finish.getTime() - start.getTime()) / 86400000) + 1;
+ if (totalDays <= 0) {
+ return false;
+ }
+ const percent = Math.max(0, Math.min(100, task.percentComplete || 0));
+ const completedDays = Math.floor(totalDays * (percent / 100));
+ const dayIndex = Math.floor((target.getTime() - start.getTime()) / 86400000);
+ return dayIndex >= 0 && dayIndex < completedDays;
+ }
+ function enumerateBusinessDays(startDate, finishDate, holidaySet, nonWorkingDayTypes) {
+ return buildDateBand(startDate, finishDate).filter((day) => !isWeeklyNonWorkingDay(day, nonWorkingDayTypes) && !holidaySet.has(day));
+ }
+ function isSameDay(day, other) {
+ return day === (other || "").slice(0, 10);
+ }
+ function isWeeklyNonWorkingDay(day, nonWorkingDayTypes) {
+ const target = parseDateOnly(day);
+ if (!target) {
+ return false;
+ }
+ const dayType = target.getDay() === 0 ? 1 : target.getDay() + 1;
+ return nonWorkingDayTypes.has(dayType);
+ }
+ function isWeekStart(day) {
+ const target = parseDateOnly(day);
+ if (!target) {
+ return false;
+ }
+ return target.getDay() === 0;
+ }
+ function isMonthStart(day) {
+ const target = parseDateOnly(day);
+ if (!target) {
+ return false;
+ }
+ return target.getDate() === 1;
+ }
+ function parseDateOnly(value) {
+ if (!value || value.length < 10) {
+ return null;
+ }
+ const dateOnly = value.slice(0, 10);
+ const [yearText, monthText, dayText] = dateOnly.split("-");
+ const year = Number(yearText);
+ const month = Number(monthText);
+ const day = Number(dayText);
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
+ return null;
+ }
+ return new Date(year, month - 1, day);
+ }
+ function expandExceptionDays(exception) {
+ const from = (exception.fromDate || "").slice(0, 10);
+ const to = (exception.toDate || "").slice(0, 10);
+ if (!from) {
+ return [];
+ }
+ if (!to || to === from) {
+ return [from];
+ }
+ const start = parseDateOnly(from);
+ const finish = parseDateOnly(to);
+ if (!start || !finish || start.getTime() > finish.getTime()) {
+ return [from];
+ }
+ const days = [];
+ const cursor = new Date(start.getTime());
+ while (cursor.getTime() <= finish.getTime()) {
+ days.push(formatDateOnly(cursor));
+ cursor.setDate(cursor.getDate() + 1);
+ }
+ return days;
+ }
+ function formatDateOnly(value) {
+ return [
+ value.getFullYear(),
+ String(value.getMonth() + 1).padStart(2, "0"),
+ String(value.getDate()).padStart(2, "0")
+ ].join("-");
+ }
+ function formatDateValue(day) {
+ const target = parseDateOnly(day);
+ if (!target) {
+ return day;
+ }
+ return `${target.getMonth() + 1}/${target.getDate()}`;
+ }
+ function formatWeekdayValue(day) {
+ const target = parseDateOnly(day);
+ if (!target) {
+ return day;
+ }
+ const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+ return weekdays[target.getDay()];
+ }
+ function formatWeekKey(day) {
+ const target = parseDateOnly(day);
+ if (!target) {
+ return day;
+ }
+ const sunday = new Date(target.getTime());
+ const offset = sunday.getDay();
+ sunday.setDate(sunday.getDate() - offset);
+ return formatDateOnly(sunday);
+ }
+ function formatWeekLabel(weekKey, days) {
+ if (days.length === 0) {
+ return "週";
+ }
+ const start = parseDateOnly(weekKey);
+ if (!start) {
+ return weekKey;
+ }
+ const monthSet = new Set(days.map((day) => {
+ const target = parseDateOnly(day);
+ return target ? target.getMonth() : -1;
+ }));
+ const startLabel = `${String(start.getMonth() + 1).padStart(2, "0")}/${String(start.getDate()).padStart(2, "0")}`;
+ if (monthSet.size <= 1) {
+ return `週 ${startLabel}`;
+ }
+ const tailMonths = Array.from(monthSet)
+ .filter((monthIndex) => monthIndex >= 0 && monthIndex !== start.getMonth())
+ .map((monthIndex) => String(monthIndex + 1).padStart(2, "0"));
+ return `週 ${startLabel} / ${tailMonths.join(" / ")}`;
+ }
+ function createWbsSheetLayoutHelper() {
+ return {
+ columnName(columnIndex) {
+ let current = columnIndex + 1;
+ let name = "";
+ while (current > 0) {
+ const remainder = (current - 1) % 26;
+ name = String.fromCharCode(65 + remainder) + name;
+ current = Math.floor((current - 1) / 26);
+ }
+ return name;
+ },
+ columnIndex(columnReference) {
+ const normalized = (columnReference || "").trim().toUpperCase();
+ if (!/^[A-Z]+$/.test(normalized)) {
+ throw new Error(`Invalid column reference: ${columnReference}`);
+ }
+ let value = 0;
+ for (const character of normalized) {
+ value = (value * 26) + (character.charCodeAt(0) - 64);
+ }
+ return value - 1;
+ },
+ reference(rowNumber, columnIndex) {
+ if (!Number.isInteger(rowNumber) || rowNumber <= 0) {
+ throw new Error(`Invalid row number: ${rowNumber}`);
+ }
+ if (!Number.isInteger(columnIndex) || columnIndex < 0) {
+ throw new Error(`Invalid column index: ${columnIndex}`);
+ }
+ return `${this.columnName(columnIndex)}${rowNumber}`;
+ },
+ parseCellReference(reference) {
+ const match = /^([A-Z]+)(\d+)$/i.exec((reference || "").trim());
+ if (!match) {
+ throw new Error(`Invalid cell reference: ${reference}`);
+ }
+ const rowNumber = Number(match[2]);
+ const columnName = match[1].toUpperCase();
+ const columnIndex = this.columnIndex(columnName);
+ return {
+ reference: `${columnName}${rowNumber}`,
+ rowNumber,
+ rowIndex: rowNumber - 1,
+ columnName,
+ columnIndex
+ };
+ },
+ range(startReference, endReference) {
+ return `${startReference}:${endReference}`;
+ },
+ describeCell(reference) {
+ const cell = this.parseCellReference(reference);
+ return `${cell.reference} (row ${cell.rowNumber}, rowIndex ${cell.rowIndex}, column ${cell.columnName}, columnIndex ${cell.columnIndex})`;
+ },
+ logCell(reference, label, logger) {
+ const message = label
+ ? `${label}: ${this.describeCell(reference)}`
+ : this.describeCell(reference);
+ (logger || console.log)(message);
+ return message;
+ }
+ };
+ }
+ globalThis.__mikuprojectWbsXlsx = {
+ collectWbsHolidayDates,
+ exportWbsWorkbook,
+ layout: WBS_LAYOUT
+ };
+})();
diff --git a/src/ts/excel-io.ts b/src/ts/excel-io.ts
new file mode 100644
index 0000000..c02fb0a
--- /dev/null
+++ b/src/ts/excel-io.ts
@@ -0,0 +1,1431 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ type XlsxPrimitiveValue = string | number | boolean;
+ type XlsxNumberFormat = "general" | "integer" | "decimal" | "date" | "datetime" | "percent" | "text";
+ type XlsxHorizontalAlign = "left" | "center" | "right";
+ type XlsxVerticalAlign = "top" | "center" | "bottom";
+ type XlsxBorderStyle = "thin";
+
+ type XlsxCellModel = {
+ value?: XlsxPrimitiveValue;
+ formula?: string;
+ numberFormat?: XlsxNumberFormat;
+ horizontalAlign?: XlsxHorizontalAlign;
+ verticalAlign?: XlsxVerticalAlign;
+ wrapText?: boolean;
+ bold?: boolean;
+ fontSize?: number;
+ fillColor?: string;
+ border?: XlsxBorderStyle;
+ };
+
+ type XlsxRowModel = {
+ height?: number;
+ cells: XlsxCellModel[];
+ };
+
+ type XlsxColumnModel = {
+ width?: number;
+ hidden?: boolean;
+ };
+
+ type XlsxFreezePaneModel = {
+ rowSplit?: number;
+ colSplit?: number;
+ };
+
+ type XlsxSheetModel = {
+ name: string;
+ columns?: XlsxColumnModel[];
+ freezePane?: XlsxFreezePaneModel;
+ mergedRanges?: string[];
+ rows: XlsxRowModel[];
+ };
+
+ type XlsxWorkbookModel = {
+ sheets: XlsxSheetModel[];
+ };
+
+ type ZipEntry = {
+ name: string;
+ data: Uint8Array;
+ };
+
+ type StyleDescriptor = {
+ numberFormat: XlsxNumberFormat;
+ horizontalAlign?: XlsxHorizontalAlign;
+ verticalAlign?: XlsxVerticalAlign;
+ wrapText?: boolean;
+ bold?: boolean;
+ fontSize?: number;
+ fillColor?: string;
+ border?: XlsxBorderStyle;
+ };
+
+ type StyleBook = {
+ styles: StyleDescriptor[];
+ styleIndexByKey: Map;
+ };
+
+ type FontDescriptor = {
+ bold?: boolean;
+ fontSize?: number;
+ };
+
+ type FillDescriptor = {
+ patternType?: "none" | "gray125" | "solid";
+ fillColor?: string;
+ };
+
+ type BorderDescriptor = {
+ border?: XlsxBorderStyle;
+ };
+
+ const TEXT_ENCODER = new TextEncoder();
+ const TEXT_DECODER = new TextDecoder();
+ const INVALID_SHEET_NAME_PATTERN = /[:\\/?*\[\]]/;
+ const CRC32_TABLE = buildCrc32Table();
+ const NUMBER_FORMATS: XlsxNumberFormat[] = ["general", "integer", "decimal", "date", "datetime", "percent", "text"];
+ const HORIZONTAL_ALIGNS: XlsxHorizontalAlign[] = ["left", "center", "right"];
+ const VERTICAL_ALIGNS: XlsxVerticalAlign[] = ["top", "center", "bottom"];
+ const BORDER_STYLES: XlsxBorderStyle[] = ["thin"];
+ const STYLE_KEY_DELIMITER = "::";
+ const DEFAULT_STYLE: StyleDescriptor = { numberFormat: "general" };
+ const DEFAULT_FILL_NONE: FillDescriptor = { patternType: "none", fillColor: undefined };
+ const DEFAULT_FILL_GRAY125: FillDescriptor = { patternType: "gray125", fillColor: undefined };
+
+ class XlsxWorkbookCodec {
+ exportWorkbook(workbook: XlsxWorkbookModel): Uint8Array {
+ const normalizedWorkbook = normalizeWorkbook(workbook);
+ const entries = this.createWorkbookEntries(normalizedWorkbook);
+ return packZip(entries);
+ }
+
+ importWorkbook(bytes: Uint8Array): XlsxWorkbookModel {
+ const entries = this.unpackEntries(bytes);
+ return parseWorkbookEntries(entries);
+ }
+
+ async importWorkbookAsync(bytes: Uint8Array): Promise {
+ const entries = await this.unpackEntriesAsync(bytes);
+ return parseWorkbookEntries(entries);
+ }
+
+ listEntries(bytes: Uint8Array): string[] {
+ return Object.keys(this.unpackEntries(bytes)).sort();
+ }
+
+ async listEntriesAsync(bytes: Uint8Array): Promise {
+ const entries = await this.unpackEntriesAsync(bytes);
+ return Object.keys(entries).sort();
+ }
+
+ unpackEntries(bytes: Uint8Array): Record {
+ return unpackZip(bytes);
+ }
+
+ async unpackEntriesAsync(bytes: Uint8Array): Promise> {
+ return unpackZipAsync(bytes);
+ }
+
+ private createWorkbookEntries(workbook: XlsxWorkbookModel): ZipEntry[] {
+ const styleBook = createStyleBook(workbook);
+ const worksheetRelationships = workbook.sheets.map((sheet, index) => ({
+ relationshipId: `rId${index + 1}`,
+ target: `worksheets/sheet${index + 1}.xml`,
+ name: sheet.name
+ }));
+ const worksheetEntries = workbook.sheets.map((sheet, index) => ({
+ name: `xl/worksheets/sheet${index + 1}.xml`,
+ data: encodeUtf8(buildWorksheetXml(sheet, styleBook))
+ }));
+
+ const entries: ZipEntry[] = [
+ {
+ name: "[Content_Types].xml",
+ data: encodeUtf8(buildContentTypesXml(workbook.sheets.length, styleBook.styles.length > 1))
+ },
+ {
+ name: "_rels/.rels",
+ data: encodeUtf8(buildRootRelationshipsXml())
+ },
+ {
+ name: "xl/_rels/workbook.xml.rels",
+ data: encodeUtf8(buildWorkbookRelationshipsXml(worksheetRelationships, styleBook.styles.length > 1))
+ },
+ {
+ name: "xl/workbook.xml",
+ data: encodeUtf8(buildWorkbookXml(worksheetRelationships))
+ }
+ ];
+
+ if (styleBook.styles.length > 1) {
+ entries.push({
+ name: "xl/styles.xml",
+ data: encodeUtf8(buildStylesXml(styleBook.styles))
+ });
+ }
+
+ entries.push(...worksheetEntries);
+ return entries;
+ }
+ }
+
+ function normalizeWorkbook(workbook: XlsxWorkbookModel): XlsxWorkbookModel {
+ if (!workbook || !Array.isArray(workbook.sheets) || workbook.sheets.length === 0) {
+ throw new Error("Workbook must contain at least one sheet");
+ }
+
+ const seenNames = new Set();
+ return {
+ sheets: workbook.sheets.map((sheet) => {
+ if (!sheet || typeof sheet.name !== "string") {
+ throw new Error("Each sheet must have a valid sheet name");
+ }
+ validateSheetName(sheet.name);
+ const canonicalName = sheet.name.toLocaleLowerCase();
+ if (seenNames.has(canonicalName)) {
+ throw new Error(`Duplicate sheet name is not allowed: ${sheet.name}`);
+ }
+ seenNames.add(canonicalName);
+ return {
+ name: sheet.name,
+ columns: Array.isArray(sheet.columns) ? sheet.columns.map((column) => normalizeColumn(column)) : undefined,
+ freezePane: normalizeFreezePane(sheet.freezePane),
+ mergedRanges: Array.isArray(sheet.mergedRanges) ? sheet.mergedRanges.map((range) => normalizeMergedRange(range)) : undefined,
+ rows: Array.isArray(sheet.rows)
+ ? sheet.rows.map((row) => ({
+ height: normalizeOptionalPositiveNumber(row?.height, "Row height"),
+ cells: Array.isArray(row?.cells)
+ ? row.cells.map((cell) => normalizeCell(cell))
+ : []
+ }))
+ : []
+ };
+ })
+ };
+ }
+
+ function normalizeColumn(column: XlsxColumnModel | undefined): XlsxColumnModel {
+ if (!column) {
+ return {};
+ }
+ if (column.hidden !== undefined && typeof column.hidden !== "boolean") {
+ throw new Error("Column hidden must be boolean");
+ }
+ return {
+ width: normalizeOptionalPositiveNumber(column.width, "Column width"),
+ hidden: column.hidden === true ? true : undefined
+ };
+ }
+
+ function normalizeFreezePane(freezePane: XlsxFreezePaneModel | undefined): XlsxFreezePaneModel | undefined {
+ if (!freezePane) {
+ return undefined;
+ }
+ const rowSplit = normalizeOptionalPositiveInteger(freezePane.rowSplit, "Freeze pane rowSplit");
+ const colSplit = normalizeOptionalPositiveInteger(freezePane.colSplit, "Freeze pane colSplit");
+ if (rowSplit === undefined && colSplit === undefined) {
+ return undefined;
+ }
+ return {
+ rowSplit,
+ colSplit
+ };
+ }
+
+ function normalizeCell(cell: XlsxCellModel | undefined): XlsxCellModel {
+ if (!cell) {
+ return {};
+ }
+ if (cell.value !== undefined && typeof cell.value !== "string" && typeof cell.value !== "number" && typeof cell.value !== "boolean") {
+ throw new Error("Cell value must be string, number, or boolean");
+ }
+ if (cell.formula !== undefined && typeof cell.formula !== "string") {
+ throw new Error("Cell formula must be a string");
+ }
+ if (cell.numberFormat !== undefined && !NUMBER_FORMATS.includes(cell.numberFormat)) {
+ throw new Error(`Unsupported cell number format: ${cell.numberFormat}`);
+ }
+ if (cell.horizontalAlign !== undefined && !HORIZONTAL_ALIGNS.includes(cell.horizontalAlign)) {
+ throw new Error(`Unsupported cell horizontal align: ${cell.horizontalAlign}`);
+ }
+ if (cell.verticalAlign !== undefined && !VERTICAL_ALIGNS.includes(cell.verticalAlign)) {
+ throw new Error(`Unsupported cell vertical align: ${cell.verticalAlign}`);
+ }
+ if (cell.border !== undefined && !BORDER_STYLES.includes(cell.border)) {
+ throw new Error(`Unsupported cell border: ${cell.border}`);
+ }
+ if (cell.fontSize !== undefined) {
+ normalizeOptionalPositiveNumber(cell.fontSize, "Cell fontSize");
+ }
+ if (cell.fillColor !== undefined) {
+ assertColor(cell.fillColor);
+ }
+ return {
+ value: cell.value,
+ formula: cell.formula,
+ numberFormat: cell.numberFormat,
+ horizontalAlign: cell.horizontalAlign,
+ verticalAlign: cell.verticalAlign,
+ wrapText: cell.wrapText === true ? true : undefined,
+ bold: cell.bold === true ? true : undefined,
+ fontSize: cell.fontSize,
+ fillColor: cell.fillColor ? normalizeColor(cell.fillColor) : undefined,
+ border: cell.border
+ };
+ }
+
+ function normalizeMergedRange(range: string): string {
+ if (typeof range !== "string") {
+ throw new Error("Merged range must be a string");
+ }
+ const trimmed = range.trim().toUpperCase();
+ if (!/^[A-Z]+\d+:[A-Z]+\d+$/.test(trimmed)) {
+ throw new Error(`Invalid merged range: ${range}`);
+ }
+ return trimmed;
+ }
+
+ function normalizeOptionalPositiveNumber(value: number | undefined, label: string): number | undefined {
+ if (value === undefined) {
+ return undefined;
+ }
+ if (!Number.isFinite(value) || value <= 0) {
+ throw new Error(`${label} must be a finite positive number`);
+ }
+ return value;
+ }
+
+ function normalizeOptionalPositiveInteger(value: number | undefined, label: string): number | undefined {
+ if (value === undefined) {
+ return undefined;
+ }
+ if (!Number.isInteger(value) || value <= 0) {
+ throw new Error(`${label} must be a positive integer`);
+ }
+ return value;
+ }
+
+ function validateSheetName(name: string): void {
+ if (!name || !name.trim()) {
+ throw new Error("Sheet name must not be empty");
+ }
+ if (name.length > 31) {
+ throw new Error(`Sheet name is too long: ${name}`);
+ }
+ if (INVALID_SHEET_NAME_PATTERN.test(name)) {
+ throw new Error(`Sheet name contains invalid characters: ${name}`);
+ }
+ if (name.startsWith("'") || name.endsWith("'")) {
+ throw new Error(`Sheet name must not start or end with apostrophe: ${name}`);
+ }
+ }
+
+ function assertColor(color: string): void {
+ if (!/^#?[0-9a-fA-F]{6}$/.test(color)) {
+ throw new Error(`Unsupported color format: ${color}`);
+ }
+ }
+
+ function normalizeColor(color: string): string {
+ const hex = color.startsWith("#") ? color.slice(1) : color;
+ return `FF${hex.toUpperCase()}`;
+ }
+
+ function denormalizeColor(color: string | undefined): string | undefined {
+ if (!color) {
+ return undefined;
+ }
+ const normalized = color.toUpperCase();
+ if (/^[0-9A-F]{8}$/.test(normalized)) {
+ return `#${normalized.slice(2)}`;
+ }
+ if (/^[0-9A-F]{6}$/.test(normalized)) {
+ return `#${normalized}`;
+ }
+ return undefined;
+ }
+
+ function buildContentTypesXml(sheetCount: number, includeStyles: boolean): string {
+ const worksheetOverrides = Array.from({ length: sheetCount }, (_unused, index) => (
+ ` `
+ )).join("");
+ const stylesOverride = includeStyles
+ ? ` `
+ : "";
+
+ return `
+
+
+
+
+ ${worksheetOverrides}
+ ${stylesOverride}
+ `;
+ }
+
+ function buildRootRelationshipsXml(): string {
+ return `
+
+
+ `;
+ }
+
+ function buildWorkbookRelationshipsXml(
+ relationships: Array<{ relationshipId: string; target: string }>,
+ includeStyles: boolean
+ ): string {
+ const worksheetNodes = relationships.map((relationship) => (
+ ` `
+ )).join("");
+ const stylesNode = includeStyles
+ ? ` `
+ : "";
+
+ return `
+
+ ${worksheetNodes}
+ ${stylesNode}
+ `;
+ }
+
+ function buildWorkbookXml(
+ relationships: Array<{ relationshipId: string; name: string }>
+ ): string {
+ const sheets = relationships.map((relationship, index) => (
+ ` `
+ )).join("");
+
+ return `
+
+ ${sheets}
+ `;
+ }
+
+ function buildWorksheetXml(sheet: XlsxSheetModel, styleBook: StyleBook): string {
+ const sheetViewsXml = buildSheetViewsXml(sheet.freezePane);
+ const colsXml = buildColumnsXml(sheet.columns);
+ const mergeCellsXml = buildMergeCellsXml(sheet.mergedRanges);
+ const rows = sheet.rows.map((row, rowIndex) => buildWorksheetRowXml(row, rowIndex, styleBook)).filter(Boolean).join("");
+ return `
+
+ ${sheetViewsXml}
+ ${colsXml}
+ ${rows}
+ ${mergeCellsXml}
+ `;
+ }
+
+ function buildSheetViewsXml(freezePane: XlsxFreezePaneModel | undefined): string {
+ if (!freezePane || (!freezePane.rowSplit && !freezePane.colSplit)) {
+ return "";
+ }
+ const xSplit = freezePane.colSplit ? ` xSplit="${freezePane.colSplit}"` : "";
+ const ySplit = freezePane.rowSplit ? ` ySplit="${freezePane.rowSplit}"` : "";
+ const topLeftCell = encodeCellReference(freezePane.rowSplit || 0, freezePane.colSplit || 0);
+ const topLeftCellAttribute = topLeftCell ? ` topLeftCell="${topLeftCell}"` : "";
+ const activePane = resolveActivePane(freezePane);
+ return ` `;
+ }
+
+ function buildColumnsXml(columns: XlsxColumnModel[] | undefined): string {
+ if (!columns || columns.length === 0 || columns.every((column) => column.width === undefined && column.hidden !== true)) {
+ return "";
+ }
+ const cols = columns.map((column, index) => (
+ (column.width !== undefined || column.hidden === true)
+ ? ` `
+ : ""
+ )).filter(Boolean).join("");
+ return cols ? `${cols} ` : "";
+ }
+
+ function buildMergeCellsXml(mergedRanges: string[] | undefined): string {
+ if (!mergedRanges || mergedRanges.length === 0) {
+ return "";
+ }
+ const mergeCells = mergedRanges
+ .map((range) => ` `)
+ .join("");
+ return `${mergeCells} `;
+ }
+
+ function buildWorksheetRowXml(row: XlsxRowModel, rowIndex: number, styleBook: StyleBook): string {
+ const cells = row.cells
+ .map((cell, cellIndex) => buildWorksheetCellXml(cell, rowIndex, cellIndex, styleBook))
+ .filter(Boolean)
+ .join("");
+
+ if (!cells) {
+ return "";
+ }
+
+ const heightAttributes = row.height !== undefined
+ ? ` ht="${formatNumber(row.height)}" customHeight="1"`
+ : "";
+ return `${cells}
`;
+ }
+
+ function buildWorksheetCellXml(cell: XlsxCellModel, rowIndex: number, cellIndex: number, styleBook: StyleBook): string {
+ const reference = `${encodeColumnName(cellIndex)}${rowIndex + 1}`;
+ const styleIndex = resolveStyleIndex(cell, styleBook);
+ const styleAttribute = styleIndex > 0 ? ` s="${styleIndex}"` : "";
+ const resolvedNumberFormat = resolveCellNumberFormat(cell);
+
+ if (cell.formula !== undefined) {
+ const formulaXml = `${escapeXml(cell.formula)} `;
+ const valueXml = buildFormulaValueXml(cell.value);
+ const typeAttribute = getCellTypeAttribute(cell.value, true);
+ return `${formulaXml}${valueXml} `;
+ }
+
+ if (cell.value === undefined) {
+ return styleIndex > 0 ? ` ` : "";
+ }
+
+ if (resolvedNumberFormat === "text") {
+ return `${buildInlineStringTextXml(String(cell.value))} `;
+ }
+ if (typeof cell.value === "string") {
+ return `${buildInlineStringTextXml(cell.value)} `;
+ }
+ if (typeof cell.value === "number") {
+ return `${formatNumber(cell.value)} `;
+ }
+ return `${cell.value ? "1" : "0"} `;
+ }
+
+ function buildFormulaValueXml(value: XlsxPrimitiveValue | undefined): string {
+ if (value === undefined) {
+ return "";
+ }
+ if (typeof value === "string") {
+ return `${escapeXml(value)} `;
+ }
+ if (typeof value === "number") {
+ return `${formatNumber(value)} `;
+ }
+ return `${value ? "1" : "0"} `;
+ }
+
+ function getCellTypeAttribute(value: XlsxPrimitiveValue | undefined, hasFormula: boolean): string {
+ if (!hasFormula) {
+ return "";
+ }
+ if (typeof value === "string") {
+ return ` t="str"`;
+ }
+ if (typeof value === "boolean") {
+ return ` t="b"`;
+ }
+ return "";
+ }
+
+ function formatNumber(value: number): string {
+ if (!Number.isFinite(value)) {
+ throw new Error(`Cell number must be finite: ${value}`);
+ }
+ return String(value);
+ }
+
+ function createStyleBook(workbook: XlsxWorkbookModel): StyleBook {
+ const styles: StyleDescriptor[] = [DEFAULT_STYLE];
+ const styleIndexByKey = new Map([[styleKey(DEFAULT_STYLE), 0]]);
+
+ for (const sheet of workbook.sheets) {
+ for (const row of sheet.rows) {
+ for (const cell of row.cells) {
+ const descriptor = getStyleDescriptor(cell);
+ if (!descriptor) {
+ continue;
+ }
+ const key = styleKey(descriptor);
+ if (!styleIndexByKey.has(key)) {
+ styleIndexByKey.set(key, styles.length);
+ styles.push(descriptor);
+ }
+ }
+ }
+ }
+
+ return { styles, styleIndexByKey };
+ }
+
+ function getStyleDescriptor(cell: XlsxCellModel): StyleDescriptor | null {
+ const numberFormat = resolveCellNumberFormat(cell);
+ if (numberFormat === "general" && !cell.horizontalAlign && !cell.verticalAlign && !cell.wrapText && !cell.bold && !cell.fontSize && !cell.fillColor && !cell.border) {
+ return null;
+ }
+ return {
+ numberFormat,
+ horizontalAlign: cell.horizontalAlign,
+ verticalAlign: cell.verticalAlign,
+ wrapText: cell.wrapText === true ? true : undefined,
+ bold: cell.bold === true ? true : undefined,
+ fontSize: cell.fontSize,
+ fillColor: cell.fillColor,
+ border: cell.border
+ };
+ }
+
+ function styleKey(style: StyleDescriptor): string {
+ return [
+ style.numberFormat,
+ style.horizontalAlign || "",
+ style.verticalAlign || "",
+ style.wrapText ? "wrap" : "",
+ style.bold ? "bold" : "",
+ style.fontSize !== undefined ? String(style.fontSize) : "",
+ style.fillColor || "",
+ style.border || ""
+ ].join(STYLE_KEY_DELIMITER);
+ }
+
+ function resolveStyleIndex(cell: XlsxCellModel, styleBook: StyleBook): number {
+ const descriptor = getStyleDescriptor(cell);
+ if (!descriptor) {
+ return 0;
+ }
+ return styleBook.styleIndexByKey.get(styleKey(descriptor)) || 0;
+ }
+
+ function buildStylesXml(styles: StyleDescriptor[]): string {
+ const fonts = dedupeDescriptors(
+ styles.map((style) => ({ bold: style.bold, fontSize: style.fontSize } as FontDescriptor)),
+ fontKey,
+ { bold: undefined, fontSize: undefined }
+ );
+ const fills = dedupeFillDescriptors(styles.map((style) => ({ patternType: style.fillColor ? "solid" : "none", fillColor: style.fillColor } as FillDescriptor)));
+ const borders = dedupeDescriptors(styles.map((style) => ({ border: style.border } as BorderDescriptor)), borderKey, { border: undefined });
+
+ const styleNodes = styles.map((style) => {
+ const numFmtId = mapNumberFormatId(style.numberFormat);
+ const fontId = fonts.indexByKey.get(fontKey({ bold: style.bold, fontSize: style.fontSize })) || 0;
+ const fillId = fills.indexByKey.get(fillKey({ patternType: style.fillColor ? "solid" : "none", fillColor: style.fillColor })) || 0;
+ const borderId = borders.indexByKey.get(borderKey({ border: style.border })) || 0;
+ const applyNumberFormat = numFmtId !== 0 ? ` applyNumberFormat="1"` : "";
+ const applyAlignment = style.horizontalAlign || style.verticalAlign || style.wrapText ? ` applyAlignment="1"` : "";
+ const applyFont = fontId !== 0 ? ` applyFont="1"` : "";
+ const applyFill = fillId !== 0 ? ` applyFill="1"` : "";
+ const applyBorder = borderId !== 0 ? ` applyBorder="1"` : "";
+ const alignmentAttributes = [
+ style.horizontalAlign ? ` horizontal="${style.horizontalAlign}"` : "",
+ style.verticalAlign ? ` vertical="${style.verticalAlign}"` : "",
+ style.wrapText ? ` wrapText="1"` : ""
+ ].join("");
+ const alignmentNode = alignmentAttributes
+ ? ` `
+ : "";
+ return `${alignmentNode} `;
+ }).join("");
+
+ return `
+
+
+
+ ${fonts.items.map(buildFontXml).join("")}
+
+
+ ${fills.items.map(buildFillXml).join("")}
+
+
+ ${borders.items.map(buildBorderXml).join("")}
+
+
+
+
+
+ ${styleNodes}
+
+
+
+
+ `;
+ }
+
+ function dedupeDescriptors(items: T[], keyFn: (item: T) => string, defaultItem: T): { items: T[]; indexByKey: Map } {
+ const uniqueItems: T[] = [defaultItem];
+ const indexByKey = new Map([[keyFn(defaultItem), 0]]);
+ for (const item of items) {
+ const key = keyFn(item);
+ if (!indexByKey.has(key)) {
+ indexByKey.set(key, uniqueItems.length);
+ uniqueItems.push(item);
+ }
+ }
+ return { items: uniqueItems, indexByKey };
+ }
+
+ function fontKey(font: FontDescriptor): string {
+ return [
+ font.bold ? "bold" : "",
+ font.fontSize !== undefined ? String(font.fontSize) : ""
+ ].join(STYLE_KEY_DELIMITER);
+ }
+
+ function fillKey(fill: FillDescriptor): string {
+ return [
+ fill.patternType || "none",
+ fill.fillColor || ""
+ ].join(STYLE_KEY_DELIMITER);
+ }
+
+ function borderKey(border: BorderDescriptor): string {
+ return border.border || "";
+ }
+
+ function buildFontXml(font: FontDescriptor): string {
+ const parts = [
+ font.bold ? " " : "",
+ font.fontSize !== undefined ? ` ` : ""
+ ].join("");
+ return parts ? `${parts} ` : ` `;
+ }
+
+ function buildFillXml(fill: FillDescriptor): string {
+ if (fill.patternType === "gray125") {
+ return ` `;
+ }
+ if (!fill.fillColor || fill.patternType === "none") {
+ return ` `;
+ }
+ return ` `;
+ }
+
+ function dedupeFillDescriptors(items: FillDescriptor[]): { items: FillDescriptor[]; indexByKey: Map } {
+ const uniqueItems: FillDescriptor[] = [DEFAULT_FILL_NONE, DEFAULT_FILL_GRAY125];
+ const indexByKey = new Map([
+ [fillKey(DEFAULT_FILL_NONE), 0],
+ [fillKey(DEFAULT_FILL_GRAY125), 1]
+ ]);
+ for (const item of items) {
+ const normalizedItem: FillDescriptor = {
+ patternType: item.fillColor ? "solid" : (item.patternType || "none"),
+ fillColor: item.fillColor
+ };
+ const key = fillKey(normalizedItem);
+ if (!indexByKey.has(key)) {
+ indexByKey.set(key, uniqueItems.length);
+ uniqueItems.push(normalizedItem);
+ }
+ }
+ return { items: uniqueItems, indexByKey };
+ }
+
+ function buildBorderXml(border: BorderDescriptor): string {
+ if (!border.border) {
+ return ` `;
+ }
+ return ` `;
+ }
+
+ function mapNumberFormatId(numberFormat: XlsxNumberFormat): number {
+ switch (numberFormat) {
+ case "integer":
+ return 1;
+ case "decimal":
+ return 2;
+ case "text":
+ return 49;
+ case "date":
+ return 14;
+ case "datetime":
+ return 22;
+ case "percent":
+ return 10;
+ case "general":
+ default:
+ return 0;
+ }
+ }
+
+ function parseWorkbookEntries(entries: Record): XlsxWorkbookModel {
+ const workbookXml = decodeRequiredEntry(entries, "xl/workbook.xml");
+ const workbookRelsXml = decodeRequiredEntry(entries, "xl/_rels/workbook.xml.rels");
+ const stylesXml = entries["xl/styles.xml"] ? decodeUtf8(entries["xl/styles.xml"]) : null;
+ const sharedStringsXml = entries["xl/sharedStrings.xml"] ? decodeUtf8(entries["xl/sharedStrings.xml"]) : null;
+ const workbookDocument = parseXmlDocument(workbookXml);
+ const relationshipsDocument = parseXmlDocument(workbookRelsXml);
+ const styleBook = parseStylesXml(stylesXml);
+ const sharedStrings = parseSharedStringsXml(sharedStringsXml);
+ const relationshipMap = new Map();
+ const relationshipElements = Array.from(relationshipsDocument.getElementsByTagNameNS(
+ "http://schemas.openxmlformats.org/package/2006/relationships",
+ "Relationship"
+ ));
+
+ for (const relationshipElement of relationshipElements) {
+ const id = relationshipElement.getAttribute("Id");
+ const target = relationshipElement.getAttribute("Target");
+ if (id && target) {
+ relationshipMap.set(id, normalizeWorkbookTarget(target));
+ }
+ }
+
+ const sheetElements = Array.from(workbookDocument.getElementsByTagNameNS(
+ "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
+ "sheet"
+ ));
+
+ return {
+ sheets: sheetElements.map((sheetElement) => {
+ const name = sheetElement.getAttribute("name") || "";
+ validateSheetName(name);
+ const relationshipId = sheetElement.getAttributeNS(
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
+ "id"
+ ) || sheetElement.getAttribute("r:id");
+ if (!relationshipId) {
+ throw new Error(`Worksheet relationship id is missing for sheet: ${name}`);
+ }
+ const target = relationshipMap.get(relationshipId);
+ if (!target) {
+ throw new Error(`Worksheet relationship target is missing for sheet: ${name}`);
+ }
+ const worksheetXml = decodeRequiredEntry(entries, target);
+ return parseWorksheetXml(name, worksheetXml, styleBook, sharedStrings);
+ })
+ };
+ }
+
+ function normalizeWorkbookTarget(target: string): string {
+ return target.startsWith("xl/") ? target : `xl/${target.replace(/^\.\//, "")}`;
+ }
+
+ function parseWorksheetXml(
+ name: string,
+ xmlText: string,
+ styleBook: StyleDescriptor[],
+ sharedStrings: string[]
+ ): XlsxSheetModel {
+ const document = parseXmlDocument(xmlText);
+ const rowElements = Array.from(document.getElementsByTagNameNS(
+ "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
+ "row"
+ ));
+
+ return {
+ name,
+ columns: parseWorksheetColumns(document),
+ freezePane: parseWorksheetFreezePane(document),
+ mergedRanges: parseWorksheetMergedRanges(document),
+ rows: rowElements.map((rowElement) => ({
+ height: parseOptionalNumber(rowElement.getAttribute("ht")),
+ cells: parseWorksheetRowCells(rowElement, styleBook, sharedStrings)
+ }))
+ };
+ }
+
+ function parseWorksheetColumns(document: XMLDocument): XlsxColumnModel[] | undefined {
+ const colElements = Array.from(document.getElementsByTagNameNS(
+ "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
+ "col"
+ ));
+ if (colElements.length === 0) {
+ return undefined;
+ }
+ const columns: XlsxColumnModel[] = [];
+ for (const colElement of colElements) {
+ const min = Number(colElement.getAttribute("min") || "0");
+ const max = Number(colElement.getAttribute("max") || "0");
+ const width = parseOptionalNumber(colElement.getAttribute("width"));
+ const hidden = colElement.getAttribute("hidden") === "1" ? true : undefined;
+ for (let index = min; index <= max; index += 1) {
+ columns[index - 1] = { width, hidden };
+ }
+ }
+ while (columns.length > 0 && !columns[columns.length - 1]) {
+ columns.pop();
+ }
+ return columns.length > 0 ? columns.map((column) => column || {}) : undefined;
+ }
+
+ function parseWorksheetMergedRanges(document: XMLDocument): string[] | undefined {
+ const mergeCellElements = Array.from(document.getElementsByTagNameNS(
+ "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
+ "mergeCell"
+ ));
+ if (mergeCellElements.length === 0) {
+ return undefined;
+ }
+ return mergeCellElements
+ .map((element) => normalizeMergedRange(element.getAttribute("ref") || ""))
+ .filter(Boolean);
+ }
+
+ function parseWorksheetFreezePane(document: XMLDocument): XlsxFreezePaneModel | undefined {
+ const paneElement = document.getElementsByTagNameNS(
+ "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
+ "pane"
+ )[0];
+ if (!paneElement || paneElement.getAttribute("state") !== "frozen") {
+ return undefined;
+ }
+ const rowSplit = parseOptionalNumber(paneElement.getAttribute("ySplit"));
+ const colSplit = parseOptionalNumber(paneElement.getAttribute("xSplit"));
+ if (rowSplit === undefined && colSplit === undefined) {
+ return undefined;
+ }
+ return {
+ rowSplit,
+ colSplit
+ };
+ }
+
+ function parseWorksheetRowCells(
+ rowElement: Element,
+ styleBook: StyleDescriptor[],
+ sharedStrings: string[]
+ ): XlsxCellModel[] {
+ const cells: XlsxCellModel[] = [];
+ const cellElements = Array.from(rowElement.getElementsByTagNameNS(
+ "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
+ "c"
+ )).filter((element) => element.parentElement === rowElement);
+
+ for (const cellElement of cellElements) {
+ const reference = cellElement.getAttribute("r") || "";
+ const columnIndex = decodeColumnReference(reference);
+ while (cells.length < columnIndex) {
+ cells.push({});
+ }
+ cells.push(parseWorksheetCell(cellElement, styleBook, sharedStrings));
+ }
+
+ return cells;
+ }
+
+ function parseWorksheetCell(
+ cellElement: Element,
+ styleBook: StyleDescriptor[],
+ sharedStrings: string[]
+ ): XlsxCellModel {
+ const type = cellElement.getAttribute("t") || "";
+ const styleIndex = Number(cellElement.getAttribute("s") || "0");
+ const formulaElement = findDirectChild(cellElement, "f");
+ const valueElement = findDirectChild(cellElement, "v");
+ const inlineStringElement = findDirectChild(cellElement, "is");
+ let value: XlsxPrimitiveValue | undefined;
+
+ if (type === "inlineStr") {
+ const textElement = inlineStringElement ? findDirectChild(inlineStringElement, "t") : null;
+ value = textElement ? (textElement.textContent || "") : "";
+ } else if (type === "s") {
+ const sharedStringIndex = Number(valueElement?.textContent || "0");
+ value = Number.isFinite(sharedStringIndex) ? (sharedStrings[sharedStringIndex] || "") : "";
+ } else if (type === "b") {
+ value = valueElement?.textContent === "1";
+ } else if (type === "str") {
+ value = valueElement?.textContent || "";
+ } else if (valueElement) {
+ const rawValue = valueElement.textContent || "";
+ value = rawValue === "" ? "" : Number(rawValue);
+ }
+
+ const style = styleBook[styleIndex] || DEFAULT_STYLE;
+ const cell: XlsxCellModel = {};
+ if (style.numberFormat !== "general") {
+ cell.numberFormat = style.numberFormat;
+ }
+ if (style.horizontalAlign) {
+ cell.horizontalAlign = style.horizontalAlign;
+ }
+ if (style.verticalAlign) {
+ cell.verticalAlign = style.verticalAlign;
+ }
+ if (style.wrapText) {
+ cell.wrapText = true;
+ }
+ if (style.bold) {
+ cell.bold = true;
+ }
+ if (style.fontSize !== undefined) {
+ cell.fontSize = style.fontSize;
+ }
+ if (style.fillColor) {
+ cell.fillColor = denormalizeColor(style.fillColor);
+ }
+ if (style.border) {
+ cell.border = style.border;
+ }
+ if (formulaElement) {
+ cell.formula = formulaElement.textContent || "";
+ }
+ if (value !== undefined) {
+ cell.value = value;
+ }
+ return cell;
+ }
+
+ function parseSharedStringsXml(xmlText: string | null): string[] {
+ if (!xmlText) {
+ return [];
+ }
+ const document = parseXmlDocument(xmlText);
+ const stringItems = Array.from(document.getElementsByTagNameNS(
+ "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
+ "si"
+ ));
+ return stringItems.map((item) => extractSharedStringText(item));
+ }
+
+ function extractSharedStringText(itemElement: Element): string {
+ const directText = findDirectChild(itemElement, "t");
+ if (directText) {
+ return directText.textContent || "";
+ }
+ const richTextRuns = Array.from(itemElement.getElementsByTagNameNS(
+ "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
+ "r"
+ ));
+ if (richTextRuns.length === 0) {
+ return "";
+ }
+ return richTextRuns
+ .map((run) => findDirectChild(run, "t")?.textContent || "")
+ .join("");
+ }
+
+ function parseStylesXml(xmlText: string | null): StyleDescriptor[] {
+ if (!xmlText) {
+ return [DEFAULT_STYLE];
+ }
+ const document = parseXmlDocument(xmlText);
+ const fonts = parseFonts(document);
+ const fills = parseFills(document);
+ const borders = parseBorders(document);
+ const xfElements = Array.from(document.getElementsByTagNameNS(
+ "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
+ "xf"
+ )).filter((element) => element.parentElement?.localName === "cellXfs");
+
+ if (xfElements.length === 0) {
+ return [DEFAULT_STYLE];
+ }
+
+ return xfElements.map((xfElement) => {
+ const numFmtId = Number(xfElement.getAttribute("numFmtId") || "0");
+ const fontId = Number(xfElement.getAttribute("fontId") || "0");
+ const fillId = Number(xfElement.getAttribute("fillId") || "0");
+ const borderId = Number(xfElement.getAttribute("borderId") || "0");
+ const alignmentElement = findDirectChild(xfElement, "alignment");
+ const horizontalAlign = alignmentElement?.getAttribute("horizontal") as XlsxHorizontalAlign | null;
+ const verticalAlign = alignmentElement?.getAttribute("vertical") as XlsxVerticalAlign | null;
+ return {
+ numberFormat: parseNumberFormatId(numFmtId),
+ horizontalAlign: horizontalAlign || undefined,
+ verticalAlign: verticalAlign || undefined,
+ wrapText: alignmentElement?.getAttribute("wrapText") === "1" ? true : undefined,
+ bold: fonts[fontId]?.bold ? true : undefined,
+ fontSize: fonts[fontId]?.fontSize,
+ fillColor: fills[fillId]?.fillColor,
+ border: borders[borderId]?.border
+ };
+ });
+ }
+
+ function parseFonts(document: XMLDocument): FontDescriptor[] {
+ return Array.from(document.getElementsByTagNameNS(
+ "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
+ "font"
+ )).map((fontElement) => ({
+ bold: findDirectChild(fontElement, "b") ? true : undefined,
+ fontSize: parseOptionalNumber(findDirectChild(fontElement, "sz")?.getAttribute("val") || null)
+ }));
+ }
+
+ function parseFills(document: XMLDocument): FillDescriptor[] {
+ return Array.from(document.getElementsByTagNameNS(
+ "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
+ "fill"
+ )).map((fillElement) => {
+ const patternFill = findDirectChild(fillElement, "patternFill");
+ const patternType = patternFill?.getAttribute("patternType") as FillDescriptor["patternType"] | null;
+ const fgColor = patternFill ? findDirectChild(patternFill, "fgColor") : null;
+ return {
+ patternType: patternType || undefined,
+ fillColor: fgColor?.getAttribute("rgb") || undefined
+ };
+ });
+ }
+
+ function parseBorders(document: XMLDocument): BorderDescriptor[] {
+ return Array.from(document.getElementsByTagNameNS(
+ "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
+ "border"
+ )).map((borderElement) => {
+ const left = findDirectChild(borderElement, "left");
+ const style = left?.getAttribute("style") as XlsxBorderStyle | null;
+ return {
+ border: style && BORDER_STYLES.includes(style) ? style : undefined
+ };
+ });
+ }
+
+ function parseNumberFormatId(numFmtId: number): XlsxNumberFormat {
+ switch (numFmtId) {
+ case 1:
+ return "integer";
+ case 2:
+ return "decimal";
+ case 10:
+ return "percent";
+ case 14:
+ return "date";
+ case 22:
+ return "datetime";
+ default:
+ return "general";
+ }
+ }
+
+ function resolveCellNumberFormat(cell: XlsxCellModel): XlsxNumberFormat {
+ if (cell.numberFormat) {
+ return cell.numberFormat;
+ }
+ if (cell.formula === undefined && cell.value !== undefined) {
+ return "text";
+ }
+ return "general";
+ }
+
+ function buildInlineStringTextXml(value: string): string {
+ const sanitizedValue = sanitizeXmlText(value);
+ const preserveWhitespace = /^[\s]/.test(sanitizedValue) || /[\s]$/.test(sanitizedValue) || sanitizedValue.includes("\n") || sanitizedValue.includes("\r") || sanitizedValue.includes("\t");
+ const preserveAttribute = preserveWhitespace ? ` xml:space="preserve"` : "";
+ return `${escapeXml(sanitizedValue)} `;
+ }
+
+ function parseOptionalNumber(value: string | null): number | undefined {
+ if (!value) {
+ return undefined;
+ }
+ return Number(value);
+ }
+
+ function findDirectChild(element: Element, localName: string): Element | null {
+ for (const childNode of Array.from(element.childNodes)) {
+ if (childNode.nodeType !== Node.ELEMENT_NODE) {
+ continue;
+ }
+ const childElement = childNode as Element;
+ if (childElement.localName === localName) {
+ return childElement;
+ }
+ }
+ return null;
+ }
+
+ function parseXmlDocument(xmlText: string): XMLDocument {
+ const document = new DOMParser().parseFromString(xmlText, "application/xml");
+ if (document.querySelector("parsererror")) {
+ throw new Error("Failed to parse XML document");
+ }
+ return document;
+ }
+
+ function decodeRequiredEntry(entries: Record, name: string): string {
+ const bytes = entries[name];
+ if (!bytes) {
+ throw new Error(`Required ZIP entry is missing: ${name}`);
+ }
+ return decodeUtf8(bytes);
+ }
+
+ function encodeUtf8(value: string): Uint8Array {
+ return TEXT_ENCODER.encode(value);
+ }
+
+ function decodeUtf8(bytes: Uint8Array): string {
+ return TEXT_DECODER.decode(bytes);
+ }
+
+ function escapeXml(value: string): string {
+ return sanitizeXmlText(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ }
+
+ function sanitizeXmlText(value: string): string {
+ return value.replace(/[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD]/g, "");
+ }
+
+ function encodeColumnName(columnIndex: number): string {
+ let current = columnIndex + 1;
+ let result = "";
+ while (current > 0) {
+ const remainder = (current - 1) % 26;
+ result = String.fromCharCode(65 + remainder) + result;
+ current = Math.floor((current - 1) / 26);
+ }
+ return result;
+ }
+
+ function encodeCellReference(rowIndex: number, columnIndex: number): string {
+ if (rowIndex <= 0 && columnIndex <= 0) {
+ return "";
+ }
+ return `${encodeColumnName(columnIndex)}${rowIndex + 1}`;
+ }
+
+ function resolveActivePane(freezePane: XlsxFreezePaneModel): string {
+ if (freezePane.rowSplit && freezePane.colSplit) {
+ return "bottomRight";
+ }
+ if (freezePane.rowSplit) {
+ return "bottomLeft";
+ }
+ return "topRight";
+ }
+
+ function decodeColumnReference(reference: string): number {
+ const match = /^([A-Z]+)\d+$/i.exec(reference);
+ if (!match) {
+ throw new Error(`Invalid cell reference: ${reference}`);
+ }
+ const letters = match[1].toUpperCase();
+ let value = 0;
+ for (const character of letters) {
+ value = (value * 26) + (character.charCodeAt(0) - 64);
+ }
+ return value - 1;
+ }
+
+ function packZip(entries: ZipEntry[]): Uint8Array {
+ const localParts: Uint8Array[] = [];
+ const centralParts: Uint8Array[] = [];
+ let offset = 0;
+
+ for (const entry of entries) {
+ const nameBytes = encodeUtf8(entry.name);
+ const crc32 = computeCrc32(entry.data);
+ const localHeader = new Uint8Array(30 + nameBytes.length);
+ const localView = new DataView(localHeader.buffer);
+ localView.setUint32(0, 0x04034b50, true);
+ localView.setUint16(4, 20, true);
+ localView.setUint16(6, 0, true);
+ localView.setUint16(8, 0, true);
+ localView.setUint16(10, 0, true);
+ localView.setUint16(12, 0, true);
+ localView.setUint32(14, crc32, true);
+ localView.setUint32(18, entry.data.byteLength, true);
+ localView.setUint32(22, entry.data.byteLength, true);
+ localView.setUint16(26, nameBytes.length, true);
+ localView.setUint16(28, 0, true);
+ localHeader.set(nameBytes, 30);
+
+ const centralHeader = new Uint8Array(46 + nameBytes.length);
+ const centralView = new DataView(centralHeader.buffer);
+ centralView.setUint32(0, 0x02014b50, true);
+ centralView.setUint16(4, 20, true);
+ centralView.setUint16(6, 20, true);
+ centralView.setUint16(8, 0, true);
+ centralView.setUint16(10, 0, true);
+ centralView.setUint16(12, 0, true);
+ centralView.setUint16(14, 0, true);
+ centralView.setUint32(16, crc32, true);
+ centralView.setUint32(20, entry.data.byteLength, true);
+ centralView.setUint32(24, entry.data.byteLength, true);
+ centralView.setUint16(28, nameBytes.length, true);
+ centralView.setUint16(30, 0, true);
+ centralView.setUint16(32, 0, true);
+ centralView.setUint16(34, 0, true);
+ centralView.setUint16(36, 0, true);
+ centralView.setUint32(38, 0, true);
+ centralView.setUint32(42, offset, true);
+ centralHeader.set(nameBytes, 46);
+
+ localParts.push(localHeader, entry.data);
+ centralParts.push(centralHeader);
+ offset += localHeader.byteLength + entry.data.byteLength;
+ }
+
+ const centralDirectoryOffset = offset;
+ const centralDirectorySize = centralParts.reduce((sum, part) => sum + part.byteLength, 0);
+ const endOfCentralDirectory = new Uint8Array(22);
+ const endView = new DataView(endOfCentralDirectory.buffer);
+ endView.setUint32(0, 0x06054b50, true);
+ endView.setUint16(4, 0, true);
+ endView.setUint16(6, 0, true);
+ endView.setUint16(8, entries.length, true);
+ endView.setUint16(10, entries.length, true);
+ endView.setUint32(12, centralDirectorySize, true);
+ endView.setUint32(16, centralDirectoryOffset, true);
+ endView.setUint16(20, 0, true);
+
+ return concatUint8Arrays([...localParts, ...centralParts, endOfCentralDirectory]);
+ }
+
+ function unpackZip(bytes: Uint8Array): Record {
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ const endOffset = findEndOfCentralDirectoryOffset(bytes);
+ const totalEntries = view.getUint16(endOffset + 10, true);
+ const centralDirectoryOffset = view.getUint32(endOffset + 16, true);
+ const entries: Record = {};
+ let pointer = centralDirectoryOffset;
+
+ for (let index = 0; index < totalEntries; index += 1) {
+ if (view.getUint32(pointer, true) !== 0x02014b50) {
+ throw new Error("Invalid ZIP central directory header");
+ }
+
+ const compressionMethod = view.getUint16(pointer + 10, true);
+ const compressedSize = view.getUint32(pointer + 20, true);
+ const uncompressedSize = view.getUint32(pointer + 24, true);
+ const fileNameLength = view.getUint16(pointer + 28, true);
+ const extraLength = view.getUint16(pointer + 30, true);
+ const commentLength = view.getUint16(pointer + 32, true);
+ const localHeaderOffset = view.getUint32(pointer + 42, true);
+ const fileName = decodeUtf8(bytes.subarray(pointer + 46, pointer + 46 + fileNameLength));
+ const localView = new DataView(bytes.buffer, bytes.byteOffset + localHeaderOffset, bytes.byteLength - localHeaderOffset);
+ if (localView.getUint32(0, true) !== 0x04034b50) {
+ throw new Error(`Invalid ZIP local header for entry: ${fileName}`);
+ }
+ const localFileNameLength = localView.getUint16(26, true);
+ const localExtraLength = localView.getUint16(28, true);
+ const dataOffset = localHeaderOffset + 30 + localFileNameLength + localExtraLength;
+ const data = bytes.slice(dataOffset, dataOffset + compressedSize);
+
+ if (compressionMethod !== 0) {
+ throw new Error(`Unsupported ZIP compression method for entry ${fileName}: ${compressionMethod}`);
+ }
+ if (compressedSize !== uncompressedSize) {
+ throw new Error(`Stored ZIP entry size mismatch: ${fileName}`);
+ }
+
+ entries[fileName] = data;
+ pointer += 46 + fileNameLength + extraLength + commentLength;
+ }
+
+ return entries;
+ }
+
+ async function unpackZipAsync(bytes: Uint8Array): Promise> {
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ const endOffset = findEndOfCentralDirectoryOffset(bytes);
+ const totalEntries = view.getUint16(endOffset + 10, true);
+ const centralDirectoryOffset = view.getUint32(endOffset + 16, true);
+ const entries: Record = {};
+ let pointer = centralDirectoryOffset;
+
+ for (let index = 0; index < totalEntries; index += 1) {
+ if (view.getUint32(pointer, true) !== 0x02014b50) {
+ throw new Error("Invalid ZIP central directory header");
+ }
+
+ const compressionMethod = view.getUint16(pointer + 10, true);
+ const compressedSize = view.getUint32(pointer + 20, true);
+ const uncompressedSize = view.getUint32(pointer + 24, true);
+ const fileNameLength = view.getUint16(pointer + 28, true);
+ const extraLength = view.getUint16(pointer + 30, true);
+ const commentLength = view.getUint16(pointer + 32, true);
+ const localHeaderOffset = view.getUint32(pointer + 42, true);
+ const fileName = decodeUtf8(bytes.subarray(pointer + 46, pointer + 46 + fileNameLength));
+ const localView = new DataView(bytes.buffer, bytes.byteOffset + localHeaderOffset, bytes.byteLength - localHeaderOffset);
+ if (localView.getUint32(0, true) !== 0x04034b50) {
+ throw new Error(`Invalid ZIP local header for entry: ${fileName}`);
+ }
+ const localFileNameLength = localView.getUint16(26, true);
+ const localExtraLength = localView.getUint16(28, true);
+ const dataOffset = localHeaderOffset + 30 + localFileNameLength + localExtraLength;
+ const data = bytes.slice(dataOffset, dataOffset + compressedSize);
+
+ if (compressionMethod === 0) {
+ if (compressedSize !== uncompressedSize) {
+ throw new Error(`Stored ZIP entry size mismatch: ${fileName}`);
+ }
+ entries[fileName] = data;
+ } else if (compressionMethod === 8) {
+ entries[fileName] = await inflateDeflateRaw(data, uncompressedSize, fileName);
+ } else {
+ throw new Error(`Unsupported ZIP compression method for entry ${fileName}: ${compressionMethod}`);
+ }
+ pointer += 46 + fileNameLength + extraLength + commentLength;
+ }
+
+ return entries;
+ }
+
+ async function inflateDeflateRaw(
+ compressed: Uint8Array,
+ expectedSize: number,
+ fileName: string
+ ): Promise {
+ if (typeof DecompressionStream !== "function") {
+ throw new Error(`ZIP deflate compression is not supported in this runtime: ${fileName}`);
+ }
+ const sourceStream = typeof Blob === "function" && typeof Blob.prototype?.stream === "function"
+ ? new Blob([compressed]).stream()
+ : new Response(compressed).body;
+ if (!sourceStream) {
+ throw new Error(`ZIP deflate stream source is not available in this runtime: ${fileName}`);
+ }
+ const decompressedStream = sourceStream.pipeThrough(new DecompressionStream("deflate-raw"));
+ const buffer = await new Response(decompressedStream).arrayBuffer();
+ const inflated = new Uint8Array(buffer);
+ if (inflated.byteLength !== expectedSize) {
+ throw new Error(`Deflated ZIP entry size mismatch: ${fileName}`);
+ }
+ return inflated;
+ }
+
+ function findEndOfCentralDirectoryOffset(bytes: Uint8Array): number {
+ for (let index = bytes.byteLength - 22; index >= 0; index -= 1) {
+ if (
+ bytes[index] === 0x50 &&
+ bytes[index + 1] === 0x4b &&
+ bytes[index + 2] === 0x05 &&
+ bytes[index + 3] === 0x06
+ ) {
+ return index;
+ }
+ }
+ throw new Error("ZIP end of central directory not found");
+ }
+
+ function concatUint8Arrays(parts: Uint8Array[]): Uint8Array {
+ const totalLength = parts.reduce((sum, part) => sum + part.byteLength, 0);
+ const result = new Uint8Array(totalLength);
+ let offset = 0;
+ for (const part of parts) {
+ result.set(part, offset);
+ offset += part.byteLength;
+ }
+ return result;
+ }
+
+ function computeCrc32(bytes: Uint8Array): number {
+ let crc = 0xffffffff;
+ for (const byte of bytes) {
+ crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ byte) & 0xff];
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+ }
+
+ function buildCrc32Table(): Uint32Array {
+ const table = new Uint32Array(256);
+ for (let index = 0; index < 256; index += 1) {
+ let value = index;
+ for (let bit = 0; bit < 8; bit += 1) {
+ value = (value & 1) !== 0 ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1);
+ }
+ table[index] = value >>> 0;
+ }
+ return table;
+ }
+
+ (globalThis as typeof globalThis & {
+ __mikuprojectExcelIo?: {
+ XlsxWorkbookCodec: typeof XlsxWorkbookCodec;
+ };
+ }).__mikuprojectExcelIo = {
+ XlsxWorkbookCodec
+ };
+})();
diff --git a/src/ts/main.ts b/src/ts/main.ts
new file mode 100644
index 0000000..de90900
--- /dev/null
+++ b/src/ts/main.ts
@@ -0,0 +1,1648 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ const mikuprojectXml = (globalThis as typeof globalThis & {
+ __mikuprojectXml?: {
+ SAMPLE_XML: string;
+ SAMPLE_PROJECT_DRAFT_VIEW: unknown;
+ importMsProjectXml: (xmlText: string) => ProjectModel;
+ importCsvParentId: (csvText: string) => ProjectModel;
+ exportMsProjectXml: (model: ProjectModel) => string;
+ exportMermaidGantt: (model: ProjectModel) => string;
+ buildProjectDraftRequest: (input: {
+ name: string;
+ plannedStart?: string;
+ goal?: string;
+ teamCount?: number;
+ mustHavePhases?: string[];
+ mustHaveMilestones?: string[];
+ }) => unknown;
+ importProjectDraftView: (draft: unknown) => ProjectModel;
+ exportProjectOverviewView: (model: ProjectModel) => unknown;
+ exportPhaseDetailView: (
+ model: ProjectModel,
+ phaseUid?: string,
+ options?: {
+ mode?: "full" | "scoped";
+ rootUid?: string;
+ maxDepth?: number;
+ }
+ ) => unknown;
+ exportCsvParentId: (model: ProjectModel) => string;
+ normalizeProjectModel: (model: ProjectModel) => ProjectModel;
+ validateProjectModel: (model: ProjectModel) => ValidationIssue[];
+ };
+ }).__mikuprojectXml;
+
+ if (!mikuprojectXml) {
+ throw new Error("mikuproject XML module is not loaded");
+ }
+
+ const mikuprojectExcelIo = (globalThis as typeof globalThis & {
+ __mikuprojectExcelIo?: {
+ XlsxWorkbookCodec: new () => {
+ exportWorkbook: (workbook: unknown) => Uint8Array;
+ importWorkbook: (bytes: Uint8Array) => unknown;
+ importWorkbookAsync?: (bytes: Uint8Array) => Promise;
+ };
+ };
+ }).__mikuprojectExcelIo;
+
+ if (!mikuprojectExcelIo) {
+ throw new Error("mikuproject Excel IO module is not loaded");
+ }
+
+ const mikuprojectProjectXlsx = (globalThis as typeof globalThis & {
+ __mikuprojectProjectXlsx?: {
+ exportProjectWorkbook: (model: ProjectModel) => unknown;
+ importProjectWorkbook: (workbook: unknown, baseModel: ProjectModel) => ProjectModel;
+ importProjectWorkbookDetailed: (workbook: unknown, baseModel: ProjectModel) => {
+ model: ProjectModel;
+ changes: Array<{
+ scope: "project" | "tasks" | "resources" | "assignments" | "calendars";
+ uid: string;
+ label: string;
+ field: string;
+ before: string | number | boolean | undefined;
+ after: string | number | boolean;
+ }>;
+ };
+ };
+ }).__mikuprojectProjectXlsx;
+
+ if (!mikuprojectProjectXlsx) {
+ throw new Error("mikuproject Project XLSX module is not loaded");
+ }
+
+ const mikuprojectProjectWorkbookJson = (globalThis as typeof globalThis & {
+ __mikuprojectProjectWorkbookJson?: {
+ exportProjectWorkbookJson: (model: ProjectModel) => unknown;
+ importProjectWorkbookJson: (documentLike: unknown, baseModel: ProjectModel) => {
+ model: ProjectModel;
+ changes: Array<{
+ scope: "project" | "tasks" | "resources" | "assignments" | "calendars";
+ uid: string;
+ label: string;
+ field: string;
+ before: string | number | boolean | undefined;
+ after: string | number | boolean;
+ }>;
+ warnings: Array<{
+ message: string;
+ }>;
+ };
+ validateWorkbookJsonDocument: (documentLike: unknown) => {
+ document: unknown;
+ warnings: Array<{
+ message: string;
+ }>;
+ };
+ };
+ }).__mikuprojectProjectWorkbookJson;
+
+ if (!mikuprojectProjectWorkbookJson) {
+ throw new Error("mikuproject Project Workbook JSON module is not loaded");
+ }
+
+ const mikuprojectWbsXlsx = (globalThis as typeof globalThis & {
+ __mikuprojectWbsXlsx?: {
+ collectWbsHolidayDates: (model: ProjectModel) => string[];
+ exportWbsWorkbook: (
+ model: ProjectModel,
+ options?: {
+ holidayDates?: string[];
+ displayDaysBeforeBaseDate?: number;
+ displayDaysAfterBaseDate?: number;
+ useBusinessDaysForDisplayRange?: boolean;
+ useBusinessDaysForProgressBand?: boolean;
+ }
+ ) => unknown;
+ };
+ }).__mikuprojectWbsXlsx;
+
+ if (!mikuprojectWbsXlsx) {
+ throw new Error("mikuproject WBS XLSX module is not loaded");
+ }
+
+ const mikuprojectWbsMarkdown = (globalThis as typeof globalThis & {
+ __mikuprojectWbsMarkdown?: {
+ exportWbsMarkdown: (
+ model: ProjectModel,
+ options?: {
+ holidayDates?: string[];
+ displayDaysBeforeBaseDate?: number;
+ displayDaysAfterBaseDate?: number;
+ useBusinessDaysForDisplayRange?: boolean;
+ useBusinessDaysForProgressBand?: boolean;
+ }
+ ) => string;
+ };
+ }).__mikuprojectWbsMarkdown;
+
+ if (!mikuprojectWbsMarkdown) {
+ throw new Error("mikuproject WBS Markdown module is not loaded");
+ }
+
+ const mikuprojectNativeSvg = (globalThis as typeof globalThis & {
+ __mikuprojectNativeSvg?: {
+ exportNativeSvg: (
+ model: ProjectModel,
+ options?: {
+ holidayDates?: string[];
+ displayDaysBeforeBaseDate?: number;
+ displayDaysAfterBaseDate?: number;
+ useBusinessDaysForDisplayRange?: boolean;
+ useBusinessDaysForProgressBand?: boolean;
+ }
+ ) => string;
+ };
+ }).__mikuprojectNativeSvg;
+
+ if (!mikuprojectNativeSvg) {
+ throw new Error("mikuproject native SVG module is not loaded");
+ }
+
+ let currentModel: ProjectModel | null = null;
+ let currentNativeSvg = "";
+ let lastSavedXmlText = "";
+ let lastSavedXmlStamp = "";
+ let currentTabId: "input" | "transform" | "output" = "input";
+ let isXmlSourceDirty = true;
+ let isRefreshingTransformTab = false;
+
+ function getElement(id: string): T {
+ const element = document.getElementById(id);
+ if (!element) {
+ throw new Error(`Element not found: ${id}`);
+ }
+ return element as T;
+ }
+
+ function getTextArea(id: string): HTMLTextAreaElement {
+ return getElement(id);
+ }
+
+ function getInput(id: string): HTMLInputElement {
+ return getElement(id);
+ }
+
+ function getTabButtons(): HTMLButtonElement[] {
+ return Array.from(document.querySelectorAll(".md-top-tab[data-tab]"));
+ }
+
+ function getTabPanels(): HTMLElement[] {
+ return Array.from(document.querySelectorAll(".md-tab-panel[data-tab-panel]"));
+ }
+
+ function setActiveTab(
+ tabId: "input" | "transform" | "output",
+ options: { skipTransformRefresh?: boolean } = {}
+ ): void {
+ currentTabId = tabId;
+ for (const button of getTabButtons()) {
+ const isActive = button.dataset.tab === tabId;
+ button.classList.toggle("is-active", isActive);
+ button.setAttribute("aria-selected", isActive ? "true" : "false");
+ button.tabIndex = isActive ? 0 : -1;
+ }
+ for (const panel of getTabPanels()) {
+ panel.hidden = panel.dataset.tabPanel !== tabId;
+ }
+ if (tabId === "transform" && !options.skipTransformRefresh && !isRefreshingTransformTab) {
+ void refreshTransformTab().catch((error) => {
+ setStatus(error instanceof Error ? error.message : "Transform の更新に失敗しました");
+ });
+ }
+ }
+
+ async function refreshTransformTab(): Promise {
+ if (isRefreshingTransformTab) {
+ return;
+ }
+ isRefreshingTransformTab = true;
+ try {
+ if (!currentModel || isXmlSourceDirty) {
+ const xmlText = getTextArea("xmlInput").value.trim();
+ if (!xmlText) {
+ setStatus("XML が空です");
+ return;
+ }
+ parseCurrentXml({ silent: true });
+ }
+ await exportCurrentMermaid({ silent: true });
+ } finally {
+ isRefreshingTransformTab = false;
+ }
+ }
+
+ function moveTabFocus(currentButton: HTMLButtonElement, direction: -1 | 1): void {
+ const buttons = getTabButtons();
+ const currentIndex = buttons.indexOf(currentButton);
+ if (currentIndex < 0) {
+ return;
+ }
+ const nextIndex = (currentIndex + direction + buttons.length) % buttons.length;
+ const nextButton = buttons[nextIndex];
+ nextButton.focus();
+ const nextTab = nextButton.dataset.tab;
+ if (nextTab === "input" || nextTab === "transform" || nextTab === "output") {
+ setActiveTab(nextTab);
+ }
+ }
+
+ function bindTabs(): void {
+ const buttons = getTabButtons();
+ if (buttons.length === 0) {
+ return;
+ }
+ for (const button of buttons) {
+ button.addEventListener("click", () => {
+ const tabId = button.dataset.tab;
+ if (tabId === "input" || tabId === "transform" || tabId === "output") {
+ setActiveTab(tabId);
+ }
+ });
+ button.addEventListener("keydown", (event) => {
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") {
+ event.preventDefault();
+ moveTabFocus(button, 1);
+ return;
+ }
+ if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
+ event.preventDefault();
+ moveTabFocus(button, -1);
+ return;
+ }
+ if (event.key === "Home") {
+ event.preventDefault();
+ buttons[0].focus();
+ setActiveTab("input");
+ return;
+ }
+ if (event.key === "End") {
+ event.preventDefault();
+ buttons[buttons.length - 1].focus();
+ setActiveTab("output");
+ }
+ });
+ }
+ setActiveTab(currentTabId);
+ }
+
+ function parseHolidayDateList(raw: string): string[] {
+ if (!raw) {
+ return [];
+ }
+ const seen = new Set();
+ const holidays: string[] = [];
+ for (const token of raw.split(/[\s,、;]+/)) {
+ const value = token.trim();
+ if (!value) {
+ continue;
+ }
+ const match = value.match(/^(\d{4}-\d{2}-\d{2})/);
+ if (!match) {
+ continue;
+ }
+ const dateText = match[1];
+ if (seen.has(dateText)) {
+ continue;
+ }
+ seen.add(dateText);
+ holidays.push(dateText);
+ }
+ return holidays;
+ }
+
+ function parseWbsDefaultHolidayDates(): string[] {
+ return parseHolidayDateList(getTextArea("wbsHolidayDatesInput").value.trim());
+ }
+
+ function parseOptionalNonNegativeInteger(raw: string): number | undefined {
+ const value = raw.trim();
+ if (!value) {
+ return undefined;
+ }
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed)) {
+ return undefined;
+ }
+ return Math.max(0, Math.floor(parsed));
+ }
+
+ function parseWbsDisplayDaysBeforeBaseDate(): number | undefined {
+ return parseOptionalNonNegativeInteger(getInput("wbsDisplayDaysBeforeInput").value);
+ }
+
+ function parseWbsDisplayDaysAfterBaseDate(): number | undefined {
+ return parseOptionalNonNegativeInteger(getInput("wbsDisplayDaysAfterInput").value);
+ }
+
+ function useBusinessDaysForWbsDisplayRange(): boolean {
+ return true;
+ }
+
+ function useBusinessDaysForWbsProgressBand(): boolean {
+ return true;
+ }
+
+ function updateWbsHolidaySummary(holidayDates: string[]): void {
+ const summary = getElement("wbsHolidaySummary");
+ if (holidayDates.length === 0) {
+ summary.textContent = "既定祝日: 0 件";
+ return;
+ }
+ summary.textContent = `既定祝日: ${holidayDates.length} 件 (${holidayDates.join(", ")})`;
+ }
+
+ function syncWbsHolidayDatesInput(model: ProjectModel | null): void {
+ const input = getTextArea("wbsHolidayDatesInput");
+ if (!model) {
+ input.value = "";
+ updateWbsHolidaySummary([]);
+ return;
+ }
+ const holidayDates = mikuprojectWbsXlsx.collectWbsHolidayDates(model);
+ input.value = holidayDates.join("\n");
+ updateWbsHolidaySummary(holidayDates);
+ }
+
+ function showToast(message: string): void {
+ const toast = document.getElementById("toast") as (HTMLElement & { show?: (text: string, duration?: number) => void }) | null;
+ if (toast && typeof toast.show === "function") {
+ toast.show(message, 2200);
+ }
+ }
+
+ function getAiPromptText(): string {
+ const template = document.getElementById("aiPromptTemplate") as HTMLTemplateElement | null;
+ if (!template) {
+ return "";
+ }
+ return (template.content?.textContent || template.textContent || "").trim();
+ }
+
+ async function copyTextToClipboard(text: string): Promise {
+ if (
+ typeof navigator !== "undefined" &&
+ navigator.clipboard &&
+ typeof navigator.clipboard.writeText === "function"
+ ) {
+ await navigator.clipboard.writeText(text);
+ return;
+ }
+
+ const textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.setAttribute("readonly", "readonly");
+ textarea.style.position = "fixed";
+ textarea.style.opacity = "0";
+ document.body.appendChild(textarea);
+ textarea.select();
+ document.execCommand("copy");
+ document.body.removeChild(textarea);
+ }
+
+ async function copyAiPrompt(): Promise {
+ const promptText = getAiPromptText();
+ if (!promptText) {
+ throw new Error("生成AIプロンプトが見つかりません");
+ }
+ await copyTextToClipboard(promptText);
+ showToast("生成AIプロンプトをクリップボードにコピーしました");
+ setStatus("生成AIプロンプトをクリップボードにコピーしました");
+ }
+
+ function setSvgPreviewMarkup(markup: string): void {
+ getElement("nativeSvgPreview").innerHTML = markup;
+ }
+
+ function updateSvgButton(): void {
+ getElement("downloadSvgBtn").disabled = !currentModel;
+ }
+
+ function buildCurrentWbsOptions(model: ProjectModel): {
+ holidayDates: string[];
+ displayDaysBeforeBaseDate?: number;
+ displayDaysAfterBaseDate?: number;
+ useBusinessDaysForDisplayRange?: boolean;
+ useBusinessDaysForProgressBand?: boolean;
+ } {
+ syncWbsHolidayDatesInput(model);
+ return {
+ holidayDates: parseWbsDefaultHolidayDates(),
+ displayDaysBeforeBaseDate: parseWbsDisplayDaysBeforeBaseDate(),
+ displayDaysAfterBaseDate: parseWbsDisplayDaysAfterBaseDate(),
+ useBusinessDaysForDisplayRange: useBusinessDaysForWbsDisplayRange(),
+ useBusinessDaysForProgressBand: useBusinessDaysForWbsProgressBand()
+ };
+ }
+
+ function downloadBlob(blob: Blob, filename: string): void {
+ const objectUrl = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = objectUrl;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
+ }
+
+ async function renderSvgPreview(): Promise {
+ if (!currentModel) {
+ currentNativeSvg = "";
+ updateSvgButton();
+ setSvgPreviewMarkup(`SVG を生成すると、ここにプレビューを表示します。
`);
+ return;
+ }
+ currentNativeSvg = mikuprojectNativeSvg.exportNativeSvg(currentModel, buildCurrentWbsOptions(currentModel));
+ setSvgPreviewMarkup(currentNativeSvg);
+ updateSvgButton();
+ }
+
+ function setStatus(message: string): void {
+ getElement("statusMessage").textContent = message;
+ }
+
+ function formatSaveStamp(date: Date): string {
+ return [
+ date.getFullYear(),
+ String(date.getMonth() + 1).padStart(2, "0"),
+ String(date.getDate()).padStart(2, "0")
+ ].join("-") + " " + [
+ String(date.getHours()).padStart(2, "0"),
+ String(date.getMinutes()).padStart(2, "0")
+ ].join(":");
+ }
+
+ function updateXmlSaveState(isDirty: boolean): void {
+ const node = getElement("xmlSaveState");
+ node.textContent = isDirty
+ ? "XML 保存状態: 未保存"
+ : `XML 保存状態: 保存済み (${lastSavedXmlStamp || "-"})`;
+ node.classList.toggle("md-save-state--dirty", isDirty);
+ node.classList.toggle("md-save-state--clean", !isDirty);
+ }
+
+ function markXmlDirty(): void {
+ updateXmlSaveState(true);
+ }
+
+ function markXmlSavedCurrent(): void {
+ lastSavedXmlText = getTextArea("xmlInput").value;
+ lastSavedXmlStamp = formatSaveStamp(new Date());
+ updateXmlSaveState(false);
+ }
+
+ function refreshXmlSaveState(): void {
+ updateXmlSaveState(getTextArea("xmlInput").value !== lastSavedXmlText);
+ }
+
+ function syncXmlTextFromModel(model: ProjectModel): string {
+ const xmlText = mikuprojectXml.exportMsProjectXml(model);
+ getTextArea("xmlInput").value = xmlText;
+ isXmlSourceDirty = false;
+ refreshXmlSaveState();
+ return xmlText;
+ }
+
+ function renderPreviewList(containerId: string, items: string[]): void {
+ const container = getElement(containerId);
+ if (items.length === 0) {
+ container.innerHTML = `まだ表示できる項目がありません。
`;
+ return;
+ }
+ container.innerHTML = items.join("");
+ }
+
+ function formatFirstBaselineSummary }>(item: T): string {
+ const baseline = item.baselines[0];
+ if (!baseline) {
+ return "-";
+ }
+ return `#${baseline.number ?? "-"} ${baseline.start || "-"} -> ${baseline.finish || "-"} / Work=${baseline.work || "-"} / Cost=${baseline.cost ?? "-"}`;
+ }
+
+ function formatFirstTimephasedSummary }>(item: T): string {
+ const timephasedData = item.timephasedData[0];
+ if (!timephasedData) {
+ return "-";
+ }
+ return `Type=${timephasedData.type ?? "-"} ${timephasedData.start || "-"} -> ${timephasedData.finish || "-"} / Unit=${timephasedData.unit ?? "-"} / Value=${timephasedData.value || "-"}`;
+ }
+
+ function formatFirstExtendedAttributeSummary }>(item: T): string {
+ const attribute = item.extendedAttributes[0];
+ if (!attribute) {
+ return "-";
+ }
+ return `FieldID=${attribute.fieldID || "-"} / Value=${attribute.value || "-"}`;
+ }
+
+ function formatFirstProjectExtendedAttributeSummary(project: ProjectInfo): string {
+ const attribute = project.extendedAttributes[0];
+ if (!attribute) {
+ return "-";
+ }
+ return `FieldID=${attribute.fieldID || "-"} / FieldName=${attribute.fieldName || "-"} / Alias=${attribute.alias || "-"}`;
+ }
+
+ function formatFirstOutlineCodeSummary(project: ProjectInfo): string {
+ const outlineCode = project.outlineCodes[0];
+ if (!outlineCode) {
+ return "-";
+ }
+ return `FieldID=${outlineCode.fieldID || "-"} / FieldName=${outlineCode.fieldName || "-"} / Alias=${outlineCode.alias || "-"}`;
+ }
+
+ function formatFirstWbsMaskSummary(project: ProjectInfo): string {
+ const wbsMask = project.wbsMasks[0];
+ if (!wbsMask) {
+ return "-";
+ }
+ return `Level=${wbsMask.level} / Mask=${wbsMask.mask || "-"} / Length=${wbsMask.length ?? "-"} / Sequence=${wbsMask.sequence ?? "-"}`;
+ }
+
+ function formatCalendarWeekDaySummary(calendar: CalendarModel): string {
+ const weekDay = calendar.weekDays[0];
+ if (!weekDay) {
+ return "-";
+ }
+ const workingTimes = weekDay.workingTimes.length > 0
+ ? weekDay.workingTimes.map((item) => `${item.fromTime}-${item.toTime}`).join(", ")
+ : "-";
+ return `DayType=${weekDay.dayType} / Working=${weekDay.dayWorking ? 1 : 0} / Times=${workingTimes}`;
+ }
+
+ function formatCalendarExceptionSummary(calendar: CalendarModel): string {
+ const exception = calendar.exceptions[0];
+ if (!exception) {
+ return "-";
+ }
+ return `${exception.name || "(no name)"} ${exception.fromDate || "-"} -> ${exception.toDate || "-"} / Working=${exception.dayWorking ? 1 : 0}`;
+ }
+
+ function formatCalendarWorkWeekSummary(calendar: CalendarModel): string {
+ const workWeek = calendar.workWeeks[0];
+ if (!workWeek) {
+ return "-";
+ }
+ return `${workWeek.name || "(no name)"} ${workWeek.fromDate || "-"} -> ${workWeek.toDate || "-"} / WeekDays=${workWeek.weekDays.length}`;
+ }
+
+ function formatCalendarReferenceSummary(model: ProjectModel, calendar: CalendarModel): string {
+ const projectRefs = model.project.calendarUID === calendar.uid ? 1 : 0;
+ const taskRefs = model.tasks.filter((task) => task.calendarUID === calendar.uid).length;
+ const resourceRefs = model.resources.filter((resource) => resource.calendarUID === calendar.uid).length;
+ const baseRefs = model.calendars.filter((item) => item.baseCalendarUID === calendar.uid).length;
+ return `Project=${projectRefs} / Tasks=${taskRefs} / Resources=${resourceRefs} / BaseOf=${baseRefs}`;
+ }
+
+ function formatCalendarLink(model: ProjectModel, calendarUID?: string): string {
+ if (!calendarUID) {
+ return "-";
+ }
+ const calendar = model.calendars.find((item) => item.uid === calendarUID);
+ return calendar ? `${calendarUID} (${calendar.name || "(no name)"})` : `${calendarUID} (missing)`;
+ }
+
+ function formatTaskLink(model: ProjectModel, taskUID?: string): string {
+ if (!taskUID) {
+ return "-";
+ }
+ const task = model.tasks.find((item) => item.uid === taskUID);
+ return task ? `${taskUID} (${task.name || "(no name)"})` : `${taskUID} (missing)`;
+ }
+
+ function formatResourceLink(model: ProjectModel, resourceUID?: string): string {
+ if (!resourceUID) {
+ return "-";
+ }
+ const resource = model.resources.find((item) => item.uid === resourceUID);
+ return resource ? `${resourceUID} (${resource.name || "(no name)"})` : `${resourceUID} (missing)`;
+ }
+
+ function renderValidationIssues(issues: ValidationIssue[]): void {
+ const container = getElement("validationIssues");
+ const label = container.previousElementSibling as HTMLElement | null;
+ if (issues.length === 0) {
+ container.classList.add("md-hidden");
+ container.innerHTML = "";
+ label?.classList.add("md-hidden");
+ updateFeedbackVisibility();
+ return;
+ }
+ const sections: ValidationIssue["scope"][] = ["project", "tasks", "resources", "assignments", "calendars"];
+ const sectionLabels: Record = {
+ project: "Project",
+ tasks: "Tasks",
+ resources: "Resources",
+ assignments: "Assignments",
+ calendars: "Calendars"
+ };
+ container.classList.remove("md-hidden");
+ label?.classList.remove("md-hidden");
+ container.innerHTML = `
+ 検証メッセージ
+ ${sections
+ .map((scope) => {
+ const scopedIssues = issues.filter((issue) => issue.scope === scope);
+ if (scopedIssues.length === 0) {
+ return "";
+ }
+ return `
+
+
${sectionLabels[scope]}
+
+ ${scopedIssues.map((issue) => `[${issue.level}] ${issue.message} `).join("")}
+
+
+ `;
+ })
+ .join("")}
+ `;
+ updateFeedbackVisibility();
+ }
+
+ function renderImportWarnings(warnings: Array<{ message: string }>): void {
+ const container = getElement("importWarnings");
+ const label = container.previousElementSibling as HTMLElement | null;
+ if (warnings.length === 0) {
+ container.classList.add("md-hidden");
+ container.innerHTML = "";
+ label?.classList.add("md-hidden");
+ updateFeedbackVisibility();
+ return;
+ }
+ container.classList.remove("md-hidden");
+ label?.classList.remove("md-hidden");
+ container.innerHTML = `
+ 取込 warning
+
+ ${warnings.map((warning) => `${escapeHtml(warning.message)} `).join("")}
+
+ `;
+ updateFeedbackVisibility();
+ }
+
+ function renderXlsxImportSummary(changes: Array<{
+ scope: "project" | "tasks" | "resources" | "assignments" | "calendars";
+ uid: string;
+ label: string;
+ field: string;
+ before: string | number | boolean | undefined;
+ after: string | number | boolean;
+ }>): void {
+ const container = getElement("xlsxImportSummary");
+ const label = container.previousElementSibling as HTMLElement | null;
+ if (changes.length === 0) {
+ container.classList.add("md-hidden");
+ container.innerHTML = "";
+ label?.classList.add("md-hidden");
+ updateFeedbackVisibility();
+ return;
+ }
+ const scopeLabel: Record<"project" | "tasks" | "resources" | "assignments" | "calendars", string> = {
+ project: "Project",
+ tasks: "Tasks",
+ resources: "Resources",
+ assignments: "Assignments",
+ calendars: "Calendars"
+ };
+ const scopeCounts: Record<"project" | "tasks" | "resources" | "assignments" | "calendars", number> = {
+ project: 0,
+ tasks: 0,
+ resources: 0,
+ assignments: 0,
+ calendars: 0
+ };
+ const groupedByScope = new Map<"project" | "tasks" | "resources" | "assignments" | "calendars", Array<{
+ uid: string;
+ label: string;
+ items: Array<{
+ field: string;
+ before: string | number | boolean | undefined;
+ after: string | number | boolean;
+ }>;
+ }>>();
+ const groupedChanges = new Map;
+ }>();
+ for (const change of changes) {
+ const groupKey = `${change.scope}:${change.uid}:${change.label}`;
+ const currentGroup = groupedChanges.get(groupKey);
+ if (currentGroup) {
+ currentGroup.items.push({
+ field: change.field,
+ before: change.before,
+ after: change.after
+ });
+ continue;
+ }
+ groupedChanges.set(groupKey, {
+ scope: change.scope,
+ uid: change.uid,
+ label: change.label,
+ items: [{
+ field: change.field,
+ before: change.before,
+ after: change.after
+ }]
+ });
+ scopeCounts[change.scope] += 1;
+ }
+ for (const group of groupedChanges.values()) {
+ const scopedGroups = groupedByScope.get(group.scope) || [];
+ scopedGroups.push({
+ uid: group.uid,
+ label: group.label,
+ items: group.items
+ });
+ groupedByScope.set(group.scope, scopedGroups);
+ }
+ const changedScopes = (["project", "tasks", "resources", "assignments", "calendars"] as const).filter((scope) => scopeCounts[scope] > 0);
+ const unchangedScopes = (["project", "tasks", "resources", "assignments", "calendars"] as const).filter((scope) => scopeCounts[scope] === 0);
+ container.classList.remove("md-hidden");
+ label?.classList.remove("md-hidden");
+ container.innerHTML = `
+ XLSX Import 反映結果
+
+ ${changedScopes.map((scope) => `${scopeLabel[scope]} ${scopeCounts[scope]} `).join("")}
+
+ ${unchangedScopes.length > 0 ? `変更なし: ${unchangedScopes.map((scope) => scopeLabel[scope]).join(", ")}
` : ""}
+ ${changedScopes.map((scope) => `
+
+
${scopeLabel[scope]}
+
+ ${(groupedByScope.get(scope) || []).map((group) => `
+
+ UID=${group.uid} ${escapeHtml(group.label)}
+
+ ${group.items.map((item) => `${escapeHtml(item.field)}: ${escapeHtml(formatChangeValue(item.before))} -> ${escapeHtml(formatChangeValue(item.after))}`).join(" / ")}
+
+
+ `).join("")}
+
+
+ `).join("")}
+ 反映後の XML は更新済みです。必要なら XML Export で保存できます。
+ `;
+ updateFeedbackVisibility();
+ }
+
+ function updateFeedbackVisibility(): void {
+ const stack = document.querySelector(".md-feedback-stack");
+ const validationIssues = getElement("validationIssues");
+ const importWarnings = getElement("importWarnings");
+ const xlsxImportSummary = getElement("xlsxImportSummary");
+ const shouldShow = !validationIssues.classList.contains("md-hidden")
+ || !importWarnings.classList.contains("md-hidden")
+ || !xlsxImportSummary.classList.contains("md-hidden");
+ stack?.classList.toggle("md-hidden", !shouldShow);
+ }
+
+ function formatChangeValue(value: string | number | boolean | undefined): string {
+ if (value === undefined) {
+ return "(empty)";
+ }
+ return String(value);
+ }
+
+ function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ }
+
+ function updateSummary(model: ProjectModel | null): void {
+ updateSvgButton();
+ syncWbsHolidayDatesInput(model);
+ getElement("summaryProjectName").textContent = model?.project.name || "-";
+ getElement("summaryTaskCount").textContent = String(model?.tasks.length || 0);
+ getElement("summaryResourceCount").textContent = String(model?.resources.length || 0);
+ getElement("summaryAssignmentCount").textContent = String(model?.assignments.length || 0);
+ getElement("summaryCalendarCount").textContent = String(model?.calendars.length || 0);
+ getTextArea("modelOutput").value = model ? JSON.stringify(model, null, 2) : "";
+ renderPreviewList("projectPreview", model ? [`
+
+
${model.project.name || "(no name)"}
+
Title=${model.project.title || "-"}
+Author=${model.project.author || "-"} / Company=${model.project.company || "-"}
+Start=${model.project.startDate || "-"} / Finish=${model.project.finishDate || "-"}
+Calendar=${formatCalendarLink(model, model.project.calendarUID)}
+OutlineCodes=${model.project.outlineCodes.length} / WBSMasks=${model.project.wbsMasks.length} / Ext=${model.project.extendedAttributes.length}
+OutlineCode1=${formatFirstOutlineCodeSummary(model.project)}
+WBSMask1=${formatFirstWbsMaskSummary(model.project)}
+Ext1=${formatFirstProjectExtendedAttributeSummary(model.project)}
+
+ `] : []);
+ renderPreviewList("taskPreview", model ? model.tasks.map((task) => `
+
+
${task.name || "(no name)"}
+
UID=${task.uid} / ID=${task.id} / Outline=${task.outlineNumber || task.outlineLevel}
+Calendar=${formatCalendarLink(model, task.calendarUID)}
+Start=${task.start || "-"}
+Finish=${task.finish || "-"}
+Predecessors=${task.predecessors.map((item) => item.predecessorUid).join(", ") || "-"}
+Ext=${task.extendedAttributes.length} / Baselines=${task.baselines.length} / Timephased=${task.timephasedData.length}
+Ext1=${formatFirstExtendedAttributeSummary(task)}
+Baseline1=${formatFirstBaselineSummary(task)}
+Timephased1=${formatFirstTimephasedSummary(task)}
+
+ `) : []);
+ renderPreviewList("resourcePreview", model ? model.resources.map((resource) => `
+
+
${resource.name || "(no name)"}
+
UID=${resource.uid} / ID=${resource.id}
+Initials=${resource.initials || "-"}
+Group=${resource.group || "-"}
+Calendar=${formatCalendarLink(model, resource.calendarUID)}
+Ext=${resource.extendedAttributes.length} / Baselines=${resource.baselines.length} / Timephased=${resource.timephasedData.length}
+Ext1=${formatFirstExtendedAttributeSummary(resource)}
+Baseline1=${formatFirstBaselineSummary(resource)}
+Timephased1=${formatFirstTimephasedSummary(resource)}
+
+ `) : []);
+ renderPreviewList("assignmentPreview", model ? model.assignments.map((assignment) => `
+
+
Assignment ${assignment.uid || "-"}
+
Task=${formatTaskLink(model, assignment.taskUid)}
+Resource=${formatResourceLink(model, assignment.resourceUid)}
+Start=${assignment.start || "-"}
+Finish=${assignment.finish || "-"}
+Ext=${assignment.extendedAttributes.length} / Baselines=${assignment.baselines.length} / Timephased=${assignment.timephasedData.length}
+Ext1=${formatFirstExtendedAttributeSummary(assignment)}
+Baseline1=${formatFirstBaselineSummary(assignment)}
+Timephased1=${formatFirstTimephasedSummary(assignment)}
+
+ `) : []);
+ renderPreviewList("calendarPreview", model ? model.calendars.map((calendar) => `
+
+
${calendar.name || "(no name)"}
+
UID=${calendar.uid}
+Base=${calendar.isBaseCalendar ? 1 : 0} / Baseline=${calendar.isBaselineCalendar ? 1 : 0} / BaseCalendarUID=${calendar.baseCalendarUID || "-"}
+WeekDays=${calendar.weekDays.length} / Exceptions=${calendar.exceptions.length} / WorkWeeks=${calendar.workWeeks.length}
+Refs=${formatCalendarReferenceSummary(model, calendar)}
+WeekDay1=${formatCalendarWeekDaySummary(calendar)}
+Exception1=${formatCalendarExceptionSummary(calendar)}
+WorkWeek1=${formatCalendarWorkWeekSummary(calendar)}
+
+ `) : []);
+ }
+
+ function loadSample(): void {
+ currentModel = null;
+ getTextArea("xmlInput").value = mikuprojectXml.SAMPLE_XML;
+ isXmlSourceDirty = true;
+ markXmlDirty();
+ setStatus("サンプル XML を読み込みました");
+ setActiveTab("input");
+ }
+
+ async function importXmlFromFile(file: File | null | undefined): Promise {
+ if (!file) {
+ return;
+ }
+ const xmlText = await file.text();
+ getTextArea("xmlInput").value = xmlText;
+ markXmlDirty();
+ currentModel = mikuprojectXml.importMsProjectXml(xmlText);
+ isXmlSourceDirty = false;
+ const issues = mikuprojectXml.validateProjectModel(currentModel);
+ updateSummary(currentModel);
+ renderValidationIssues(issues);
+ renderImportWarnings([]);
+ renderXlsxImportSummary([]);
+ setStatus(issues.length > 0 ? `XML ファイルを読み込んで解析しました。検証で ${issues.length} 件の問題があります` : "XML ファイルを読み込んで解析しました");
+ showToast("XML を読み込んで解析しました");
+ setActiveTab("transform", { skipTransformRefresh: true });
+ await exportCurrentMermaid({ silent: true });
+ }
+
+ function ensureCurrentModel(): ProjectModel {
+ if (currentModel) {
+ return currentModel;
+ }
+ const xmlText = getTextArea("xmlInput").value.trim();
+ if (!xmlText) {
+ throw new Error("内部モデルがありません");
+ }
+ currentModel = mikuprojectXml.importMsProjectXml(xmlText);
+ isXmlSourceDirty = false;
+ return currentModel;
+ }
+
+ function parseCurrentXml(options: { silent?: boolean } = {}): void {
+ const xmlText = getTextArea("xmlInput").value.trim();
+ if (!xmlText) {
+ setStatus("XML が空です");
+ return;
+ }
+ currentModel = mikuprojectXml.importMsProjectXml(xmlText);
+ isXmlSourceDirty = false;
+ const issues = mikuprojectXml.validateProjectModel(currentModel);
+ updateSummary(currentModel);
+ renderValidationIssues(issues);
+ renderImportWarnings([]);
+ renderXlsxImportSummary([]);
+ if (!options.silent) {
+ setStatus(issues.length > 0 ? `XML を解析しました。検証で ${issues.length} 件の問題があります` : "XML を内部モデルへ変換しました");
+ showToast("XML を解析しました");
+ }
+ setActiveTab("transform", { skipTransformRefresh: true });
+ }
+
+ async function exportCurrentMermaid(options: { silent?: boolean } = {}): Promise {
+ if (!currentModel) {
+ setStatus("内部モデルがありません");
+ return;
+ }
+ const mermaidText = mikuprojectXml.exportMermaidGantt(currentModel);
+ getTextArea("mermaidOutput").value = mermaidText;
+ await renderSvgPreview();
+ if (!options.silent) {
+ setStatus("内部モデルから Mermaid gantt を生成し、native SVG preview を更新しました");
+ showToast("Mermaid を生成しました");
+ }
+ setActiveTab("transform", { skipTransformRefresh: true });
+ }
+
+ function exportCurrentCsv(): void {
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const csvText = mikuprojectXml.exportCsvParentId(model);
+ const now = new Date();
+ const stamp = [
+ now.getFullYear(),
+ String(now.getMonth() + 1).padStart(2, "0"),
+ String(now.getDate()).padStart(2, "0"),
+ String(now.getHours()).padStart(2, "0"),
+ String(now.getMinutes()).padStart(2, "0")
+ ].join("");
+ downloadBlob(
+ new Blob([`${csvText}\n`], { type: "text/csv;charset=utf-8" }),
+ `mikuproject-export-${stamp}.csv`
+ );
+ setStatus("内部モデルから CSV + ParentID を生成して保存しました");
+ showToast("CSV を保存しました");
+ setActiveTab("output");
+ }
+
+ function exportCurrentProjectOverviewView(): void {
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const viewText = JSON.stringify(mikuprojectXml.exportProjectOverviewView(model), null, 2);
+ getTextArea("projectOverviewOutput").value = viewText;
+ downloadBlob(
+ new Blob([`${viewText}\n`], { type: "application/json;charset=utf-8" }),
+ "mikuproject-project-overview-view.editjson"
+ );
+ setStatus("project_overview_view を生成して保存しました");
+ showToast("project_overview_view を保存しました");
+ setActiveTab("output");
+ }
+
+ function exportCurrentAiProjectionBundle(): void {
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const projectOverview = mikuprojectXml.exportProjectOverviewView(model) as {
+ phases?: Array<{ uid?: string }>;
+ };
+ const phaseDetailViewsFull = (projectOverview.phases || [])
+ .map((phase) => phase?.uid)
+ .filter((uid): uid is string => Boolean(uid))
+ .map((phaseUid) => mikuprojectXml.exportPhaseDetailView(model, phaseUid, { mode: "full" }));
+ const bundle = {
+ view_type: "ai_projection_bundle",
+ project_overview_view: projectOverview,
+ phase_detail_views_full: phaseDetailViewsFull
+ };
+ const bundleText = JSON.stringify(bundle, null, 2);
+ getTextArea("aiBundleOutput").value = bundleText;
+ downloadBlob(
+ new Blob([`${bundleText}\n`], { type: "application/json;charset=utf-8" }),
+ "mikuproject-full-bundle.editjson"
+ );
+ setStatus(`AI 連携用まとめ JSON を生成して保存しました (phase_detail_view full ${phaseDetailViewsFull.length} 件)`);
+ showToast("AI 連携用まとめ JSON を保存しました");
+ setActiveTab("output");
+ }
+
+ function extractLastJsonBlock(value: string): string {
+ const matches = Array.from(value.matchAll(/```json\s*([\s\S]*?)```/g));
+ if (matches.length > 0) {
+ return matches.at(-1)?.[1]?.trim() || "";
+ }
+ return value.trim();
+ }
+
+ function detectJsonDocumentKind(documentLike: unknown): "workbook_json" | "project_draft_view" | undefined {
+ if (!documentLike || typeof documentLike !== "object") {
+ return undefined;
+ }
+ const candidate = documentLike as {
+ format?: string;
+ view_type?: string;
+ };
+ if (candidate.format === "mikuproject_workbook_json") {
+ return "workbook_json";
+ }
+ if (candidate.view_type === "project_draft_view") {
+ return "project_draft_view";
+ }
+ return undefined;
+ }
+
+ async function importProjectDraftFromText(): Promise {
+ const sourceText = getTextArea("projectDraftImportInput").value.trim();
+ if (!sourceText) {
+ throw new Error("project_draft_view JSON を入力してください");
+ }
+ const jsonText = extractLastJsonBlock(sourceText);
+ const draft = JSON.parse(jsonText);
+ currentModel = mikuprojectXml.importProjectDraftView(draft);
+ syncXmlTextFromModel(currentModel);
+ const issues = mikuprojectXml.validateProjectModel(currentModel);
+ updateSummary(currentModel);
+ renderValidationIssues(issues);
+ renderImportWarnings([]);
+ renderXlsxImportSummary([]);
+ await exportCurrentMermaid({ silent: true });
+ setStatus(issues.length > 0 ? `project_draft_view を取り込みました。検証で ${issues.length} 件の問題があります` : "project_draft_view を取り込みました");
+ showToast("project_draft_view を取り込みました");
+ setActiveTab("transform", { skipTransformRefresh: true });
+ }
+
+ function loadProjectDraftSample(): void {
+ const sampleDraftText = JSON.stringify(mikuprojectXml.SAMPLE_PROJECT_DRAFT_VIEW, null, 2);
+ getTextArea("projectDraftImportInput").value = sampleDraftText;
+ setStatus("サンプル project_draft_view を読み込みました");
+ setActiveTab("input");
+ }
+
+ async function importProjectDraftFromFile(file?: File | null): Promise {
+ if (!file) {
+ throw new Error("project_draft_view JSON ファイルを選択してください");
+ }
+ const sourceText = await file.text();
+ getTextArea("projectDraftImportInput").value = sourceText;
+ await importProjectDraftFromText();
+ }
+
+ async function importWorkbookJsonFromSourceText(sourceText: string): Promise {
+ const trimmedSourceText = sourceText.trim();
+ if (!trimmedSourceText) {
+ throw new Error("workbook JSON を入力してください");
+ }
+ const documentLike = JSON.parse(extractLastJsonBlock(trimmedSourceText));
+ const baseModel = ensureCurrentModel();
+ const result = mikuprojectProjectWorkbookJson.importProjectWorkbookJson(documentLike, baseModel);
+ currentModel = result.model;
+ const issues = mikuprojectXml.validateProjectModel(currentModel);
+ updateSummary(currentModel);
+ renderValidationIssues(issues);
+ renderImportWarnings(result.warnings);
+ renderXlsxImportSummary(result.changes);
+ if (result.changes.length > 0) {
+ getTextArea("xmlInput").value = mikuprojectXml.exportMsProjectXml(currentModel);
+ markXmlDirty();
+ }
+ isXmlSourceDirty = false;
+ const summaryText = result.changes.length > 0
+ ? `JSON を読み込んで ${result.changes.length} 件の変更を反映しました。XML は再生成済みで、必要なら XML Export で保存できます`
+ : "JSON に反映対象の変更はありませんでした。XML は未変更です";
+ const warningText = result.warnings.length > 0 ? `。JSON 取込で ${result.warnings.length} 件の warning を無視しました` : "";
+ setStatus(issues.length > 0 ? `${summaryText}${warningText}。検証で ${issues.length} 件の問題があります` : `${summaryText}${warningText}`);
+ showToast("JSON を反映しました");
+ setActiveTab("transform", { skipTransformRefresh: true });
+ await exportCurrentMermaid({ silent: true });
+ }
+
+ async function importWorkbookJsonFromFile(file?: File | null): Promise {
+ if (!file) {
+ throw new Error("workbook JSON ファイルを選択してください");
+ }
+ const sourceText = await file.text();
+ await importWorkbookJsonFromSourceText(sourceText);
+ }
+
+ async function importFromFile(file: File | null | undefined): Promise {
+ if (!file) {
+ return;
+ }
+ const normalizedName = file.name.trim().toLowerCase();
+ if (normalizedName.endsWith(".xml")) {
+ await importXmlFromFile(file);
+ return;
+ }
+ if (normalizedName.endsWith(".xlsx")) {
+ await importXlsxFromFile(file);
+ return;
+ }
+ if (normalizedName.endsWith(".csv")) {
+ await importCsvFromFile(file);
+ return;
+ }
+ if (normalizedName.endsWith(".editjson")) {
+ await importProjectDraftFromFile(file);
+ return;
+ }
+ if (normalizedName.endsWith(".json")) {
+ const sourceText = await file.text();
+ const documentLike = JSON.parse(extractLastJsonBlock(sourceText));
+ const kind = detectJsonDocumentKind(documentLike);
+ if (kind === "workbook_json") {
+ await importWorkbookJsonFromSourceText(sourceText);
+ return;
+ }
+ if (kind === "project_draft_view") {
+ getTextArea("projectDraftImportInput").value = sourceText;
+ await importProjectDraftFromText();
+ return;
+ }
+ throw new Error("JSON の format / view_type を判別できません。workbook JSON か project_draft_view を指定してください");
+ }
+ throw new Error("対応していないファイル形式です。.xml / .xlsx / .json / .editjson / .csv を指定してください");
+ }
+
+ function exportCurrentPhaseDetailView(mode: "full" | "scoped" = "scoped"): void {
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const requestedPhaseUid = getInput("phaseDetailUidInput").value.trim() || undefined;
+ const requestedRootUid = mode === "scoped" ? getInput("phaseDetailRootUidInput").value.trim() || undefined : undefined;
+ const maxDepthText = getInput("phaseDetailMaxDepthInput").value.trim();
+ const requestedMaxDepth = mode === "scoped" && maxDepthText !== "" ? Number.parseInt(maxDepthText, 10) : undefined;
+ if (typeof requestedMaxDepth === "number" && (!Number.isFinite(requestedMaxDepth) || requestedMaxDepth < 0)) {
+ throw new Error("max depth は 0 以上の整数で指定してください");
+ }
+ const view = mikuprojectXml.exportPhaseDetailView(model, requestedPhaseUid, {
+ mode,
+ rootUid: requestedRootUid,
+ maxDepth: requestedMaxDepth
+ }) as {
+ phase?: { uid?: string };
+ scope?: { mode?: "full" | "scoped"; root_uid?: string | null; max_depth?: number | null };
+ };
+ if (view.phase?.uid) {
+ getInput("phaseDetailUidInput").value = view.phase.uid;
+ }
+ getInput("phaseDetailRootUidInput").value = view.scope?.root_uid || "";
+ getInput("phaseDetailMaxDepthInput").value = typeof view.scope?.max_depth === "number" ? String(view.scope.max_depth) : "";
+ const viewText = JSON.stringify(view, null, 2);
+ getTextArea("phaseDetailOutput").value = viewText;
+ const phaseSuffix = view.phase?.uid ? `-${view.phase.uid}` : "";
+ const modeSuffix = view.scope?.mode === "scoped" ? "-scoped" : "-full";
+ const rootSuffix = view.scope?.root_uid ? `-root-${view.scope.root_uid}` : "";
+ const depthSuffix = typeof view.scope?.max_depth === "number" ? `-depth-${view.scope.max_depth}` : "";
+ downloadBlob(
+ new Blob([`${viewText}\n`], { type: "application/json;charset=utf-8" }),
+ `mikuproject-phase-detail-view${phaseSuffix}${modeSuffix}${rootSuffix}${depthSuffix}.editjson`
+ );
+ setStatus(`phase_detail_view (${view.scope?.mode === "scoped" ? "scoped" : "full"}) を生成して保存しました`);
+ showToast(`phase_detail_view (${view.scope?.mode === "scoped" ? "scoped" : "full"}) を保存しました`);
+ setActiveTab("output");
+ }
+
+ function exportCurrentXlsx(): void {
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const workbook = mikuprojectProjectXlsx.exportProjectWorkbook(model);
+ const codec = new mikuprojectExcelIo.XlsxWorkbookCodec();
+ const bytes = codec.exportWorkbook(workbook);
+ const now = new Date();
+ const stamp = [
+ now.getFullYear(),
+ String(now.getMonth() + 1).padStart(2, "0"),
+ String(now.getDate()).padStart(2, "0"),
+ String(now.getHours()).padStart(2, "0"),
+ String(now.getMinutes()).padStart(2, "0")
+ ].join("");
+ downloadBlob(
+ new Blob([bytes], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }),
+ `mikuproject-export-${stamp}.xlsx`
+ );
+ setStatus("XLSX ファイルをエクスポートしました");
+ showToast("XLSX を保存しました");
+ setActiveTab("output");
+ }
+
+ function exportCurrentWorkbookJson(): void {
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const jsonText = JSON.stringify(mikuprojectProjectWorkbookJson.exportProjectWorkbookJson(model), null, 2);
+ const now = new Date();
+ const stamp = [
+ now.getFullYear(),
+ String(now.getMonth() + 1).padStart(2, "0"),
+ String(now.getDate()).padStart(2, "0"),
+ String(now.getHours()).padStart(2, "0"),
+ String(now.getMinutes()).padStart(2, "0")
+ ].join("");
+ getTextArea("workbookJsonOutput").value = jsonText;
+ downloadBlob(
+ new Blob([`${jsonText}\n`], { type: "application/json;charset=utf-8" }),
+ `mikuproject-workbook-${stamp}.json`
+ );
+ setStatus("XLSX 相当の workbook JSON を生成して保存しました");
+ showToast("JSON を保存しました");
+ setActiveTab("output");
+ }
+
+ function exportCurrentWbsXlsx(): void {
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const defaultHolidayDates = parseWbsDefaultHolidayDates();
+ const displayDaysBeforeBaseDate = parseWbsDisplayDaysBeforeBaseDate();
+ const displayDaysAfterBaseDate = parseWbsDisplayDaysAfterBaseDate();
+ const useBusinessDaysForDisplayRange = useBusinessDaysForWbsDisplayRange();
+ const useBusinessDaysForProgressBand = useBusinessDaysForWbsProgressBand();
+ const workbook = mikuprojectWbsXlsx.exportWbsWorkbook(model, {
+ holidayDates: defaultHolidayDates,
+ displayDaysBeforeBaseDate,
+ displayDaysAfterBaseDate,
+ useBusinessDaysForDisplayRange,
+ useBusinessDaysForProgressBand
+ });
+ const codec = new mikuprojectExcelIo.XlsxWorkbookCodec();
+ const bytes = codec.exportWorkbook(workbook);
+ const now = new Date();
+ const stamp = [
+ now.getFullYear(),
+ String(now.getMonth() + 1).padStart(2, "0"),
+ String(now.getDate()).padStart(2, "0"),
+ String(now.getHours()).padStart(2, "0"),
+ String(now.getMinutes()).padStart(2, "0")
+ ].join("");
+ downloadBlob(
+ new Blob([bytes], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }),
+ `mikuproject-wbs-${stamp}.xlsx`
+ );
+ const displayRangeText = displayDaysBeforeBaseDate !== undefined || displayDaysAfterBaseDate !== undefined
+ ? ` / 表示期間 営業日 基準日前 ${displayDaysBeforeBaseDate || 0} 日, 基準日後 ${displayDaysAfterBaseDate || 0} 日`
+ : "";
+ const progressBandText = " / 進捗帯 営業日";
+ setStatus(`WBS XLSX ファイルをエクスポートしました${defaultHolidayDates.length > 0 ? ` (祝日 ${defaultHolidayDates.length} 件)` : ""}${displayRangeText}${progressBandText}`);
+ showToast("WBS XLSX を保存しました");
+ setActiveTab("output");
+ }
+
+ async function importXlsxFromFile(file: File | null | undefined): Promise {
+ if (!file) {
+ return;
+ }
+ const baseModel = ensureCurrentModel();
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ const codec = new mikuprojectExcelIo.XlsxWorkbookCodec();
+ const workbook = typeof codec.importWorkbookAsync === "function"
+ ? await codec.importWorkbookAsync(bytes)
+ : codec.importWorkbook(bytes);
+ const result = mikuprojectProjectXlsx.importProjectWorkbookDetailed(workbook, baseModel);
+ currentModel = result.model;
+ const issues = mikuprojectXml.validateProjectModel(currentModel);
+ updateSummary(currentModel);
+ renderValidationIssues(issues);
+ renderImportWarnings([]);
+ renderXlsxImportSummary(result.changes);
+ if (result.changes.length > 0) {
+ getTextArea("xmlInput").value = mikuprojectXml.exportMsProjectXml(currentModel);
+ markXmlDirty();
+ }
+ const summaryText = result.changes.length > 0
+ ? `XLSX を読み込んで ${result.changes.length} 件の変更を反映しました。XML は再生成済みで、必要なら XML Export で保存できます`
+ : "XLSX に反映対象の変更はありませんでした。XML は未変更です";
+ isXmlSourceDirty = false;
+ setStatus(issues.length > 0 ? `${summaryText}。検証で ${issues.length} 件の問題があります` : summaryText);
+ showToast("XLSX を反映しました");
+ setActiveTab("transform", { skipTransformRefresh: true });
+ await exportCurrentMermaid({ silent: true });
+ }
+
+ async function importCsvFromFile(file: File | null | undefined): Promise {
+ if (!file) {
+ return;
+ }
+ const csvText = (await file.text()).trim();
+ if (!csvText) {
+ setStatus("CSV が空です");
+ return;
+ }
+ currentModel = mikuprojectXml.importCsvParentId(csvText);
+ isXmlSourceDirty = false;
+ const issues = mikuprojectXml.validateProjectModel(currentModel);
+ updateSummary(currentModel);
+ renderValidationIssues(issues);
+ renderImportWarnings([]);
+ renderXlsxImportSummary([]);
+ setStatus(issues.length > 0 ? `CSV ファイルを読み込んで解析しました。検証で ${issues.length} 件の問題があります` : "CSV + ParentID を内部モデルへ変換しました");
+ showToast("CSV を読み込みました");
+ setActiveTab("transform", { skipTransformRefresh: true });
+ await exportCurrentMermaid({ silent: true });
+ }
+
+ function downloadCurrentXml(): void {
+ const model = ensureCurrentModel();
+ const xmlText = syncXmlTextFromModel(model);
+ const blob = new Blob([`${xmlText}\n`], { type: "application/xml;charset=utf-8" });
+ const objectUrl = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ const now = new Date();
+ const stamp = [
+ now.getFullYear(),
+ String(now.getMonth() + 1).padStart(2, "0"),
+ String(now.getDate()).padStart(2, "0"),
+ String(now.getHours()).padStart(2, "0"),
+ String(now.getMinutes()).padStart(2, "0")
+ ].join("");
+ link.href = objectUrl;
+ link.download = `mikuproject-export-${stamp}.xml`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
+ markXmlSavedCurrent();
+ setStatus("XML ファイルをエクスポートしました");
+ showToast("XML を保存しました");
+ setActiveTab("output");
+ }
+
+ async function downloadCurrentSvg(): Promise {
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const mermaidText = mikuprojectXml.exportMermaidGantt(model);
+ getTextArea("mermaidOutput").value = mermaidText;
+ await renderSvgPreview();
+ if (!currentNativeSvg) {
+ setStatus("出力する SVG がありません");
+ return;
+ }
+ downloadBlob(new Blob([currentNativeSvg], { type: "image/svg+xml;charset=utf-8" }), "mikuproject-native.svg");
+ setStatus("SVG を保存しました");
+ showToast("SVG を保存しました");
+ setActiveTab("output");
+ }
+
+ function downloadCurrentMermaidMarkdown(): void {
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const mermaidText = mikuprojectXml.exportMermaidGantt(model);
+ getTextArea("mermaidOutput").value = mermaidText;
+ const now = new Date();
+ const stamp = [
+ now.getFullYear(),
+ String(now.getMonth() + 1).padStart(2, "0"),
+ String(now.getDate()).padStart(2, "0")
+ ].join("");
+ const markdownText = `\`\`\`mermaid\n${mermaidText}\n\`\`\`\n`;
+ downloadBlob(
+ new Blob([markdownText], { type: "text/markdown;charset=utf-8" }),
+ `mermaid-${stamp}.md`
+ );
+ setStatus("Mermaid Markdown を保存しました");
+ showToast("Mermaid Markdown を保存しました");
+ setActiveTab("output");
+ }
+
+ function downloadCurrentWbsMarkdown(): void {
+ const model = ensureCurrentModel();
+ syncXmlTextFromModel(model);
+ const defaultHolidayDates = mikuprojectWbsXlsx.collectWbsHolidayDates(model);
+ syncWbsHolidayDatesInput(model);
+ const displayDaysBeforeBaseDate = parseOptionalNonNegativeInteger(getInput("wbsDisplayDaysBeforeInput").value);
+ const displayDaysAfterBaseDate = parseOptionalNonNegativeInteger(getInput("wbsDisplayDaysAfterInput").value);
+ const useBusinessDaysForDisplayRange = useBusinessDaysForWbsDisplayRange();
+ const useBusinessDaysForProgressBand = useBusinessDaysForWbsProgressBand();
+ const markdownText = mikuprojectWbsMarkdown.exportWbsMarkdown(model, {
+ holidayDates: defaultHolidayDates,
+ displayDaysBeforeBaseDate,
+ displayDaysAfterBaseDate,
+ useBusinessDaysForDisplayRange,
+ useBusinessDaysForProgressBand
+ });
+ const now = new Date();
+ const stamp = [
+ now.getFullYear(),
+ String(now.getMonth() + 1).padStart(2, "0"),
+ String(now.getDate()).padStart(2, "0")
+ ].join("");
+ downloadBlob(
+ new Blob([markdownText], { type: "text/markdown;charset=utf-8" }),
+ `mikuproject-wbs-${stamp}.md`
+ );
+ setStatus("WBS Markdown を保存しました");
+ showToast("WBS Markdown を保存しました");
+ setActiveTab("output");
+ }
+
+ function runRoundTripCheck(): void {
+ if (!currentModel) {
+ parseCurrentXml();
+ if (!currentModel) {
+ return;
+ }
+ }
+ const exportedXml = mikuprojectXml.exportMsProjectXml(currentModel);
+ const reparsedModel = mikuprojectXml.importMsProjectXml(exportedXml);
+ const validationIssues = mikuprojectXml.validateProjectModel(reparsedModel);
+ renderValidationIssues(validationIssues);
+ if (validationIssues.some((issue) => issue.level === "error")) {
+ throw new Error(validationIssues.map((issue) => issue.message).join("\n"));
+ }
+ const normalizedLeft = JSON.stringify(mikuprojectXml.normalizeProjectModel(currentModel));
+ const normalizedRight = JSON.stringify(mikuprojectXml.normalizeProjectModel(reparsedModel));
+ if (normalizedLeft !== normalizedRight) {
+ throw new Error("再読込後の内部モデルが一致しません");
+ }
+ setStatus("再読込テストに成功しました");
+ showToast("再読込テスト成功");
+ setActiveTab("transform");
+ }
+
+ function bindEvents(): void {
+ getElement("loadSampleBtn").addEventListener("click", loadSample);
+ getElement("importFileInput").addEventListener("click", (event) => {
+ const input = event.target as HTMLInputElement | null;
+ if (input) {
+ input.value = "";
+ }
+ });
+ getElement("importFileBtn").addEventListener("click", () => {
+ const input = getElement("importFileInput");
+ input.value = "";
+ input.click();
+ });
+ getElement("downloadSvgBtn").addEventListener("click", () => {
+ void downloadCurrentSvg().catch((error) => {
+ setStatus(error instanceof Error ? error.message : "SVG 保存に失敗しました");
+ });
+ });
+ getElement("exportMermaidMdBtn").addEventListener("click", () => {
+ try {
+ downloadCurrentMermaidMarkdown();
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Mermaid Markdown 保存に失敗しました");
+ }
+ });
+ getElement("exportCsvBtn").addEventListener("click", () => {
+ try {
+ exportCurrentCsv();
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "CSV 生成に失敗しました");
+ }
+ });
+ getElement("exportProjectOverviewBtn").addEventListener("click", () => {
+ try {
+ exportCurrentProjectOverviewView();
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "project_overview_view 生成に失敗しました");
+ }
+ });
+ getElement("exportAiBundleBtn").addEventListener("click", () => {
+ try {
+ exportCurrentAiProjectionBundle();
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "AI 連携用まとめ JSON 生成に失敗しました");
+ }
+ });
+ getElement("loadProjectDraftSampleBtn").addEventListener("click", loadProjectDraftSample);
+ getElement("copyAiPromptBtn").addEventListener("click", async () => {
+ try {
+ await copyAiPrompt();
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "生成AIプロンプトのコピーに失敗しました");
+ }
+ });
+ getElement("importProjectDraftBtn").addEventListener("click", async () => {
+ try {
+ await importProjectDraftFromText();
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "project_draft_view 取り込みに失敗しました");
+ }
+ });
+ getElement("exportPhaseDetailBtn").addEventListener("click", () => {
+ try {
+ exportCurrentPhaseDetailView("scoped");
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "phase_detail_view 生成に失敗しました");
+ }
+ });
+ getElement("exportPhaseDetailFullBtn").addEventListener("click", () => {
+ try {
+ exportCurrentPhaseDetailView("full");
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "phase_detail_view 生成に失敗しました");
+ }
+ });
+ getElement("exportXlsxBtn").addEventListener("click", () => {
+ try {
+ exportCurrentXlsx();
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "XLSX エクスポートに失敗しました");
+ }
+ });
+ getElement("exportWorkbookJsonBtn").addEventListener("click", () => {
+ try {
+ exportCurrentWorkbookJson();
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "JSON エクスポートに失敗しました");
+ }
+ });
+ getElement("exportWbsXlsxBtn").addEventListener("click", () => {
+ try {
+ exportCurrentWbsXlsx();
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "WBS XLSX エクスポートに失敗しました");
+ }
+ });
+ getElement("exportWbsMdBtn").addEventListener("click", () => {
+ try {
+ downloadCurrentWbsMarkdown();
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "WBS Markdown 保存に失敗しました");
+ }
+ });
+ getElement("downloadXmlBtn").addEventListener("click", () => {
+ try {
+ downloadCurrentXml();
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "XML 保存に失敗しました");
+ }
+ });
+ getElement("roundTripBtn").addEventListener("click", () => {
+ try {
+ runRoundTripCheck();
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "再読込テストに失敗しました");
+ }
+ });
+ getElement("importFileInput").addEventListener("change", async (event) => {
+ const input = event.target as HTMLInputElement | null;
+ const file = input?.files && input.files[0];
+ if (file) {
+ setStatus(`${file.name} を読み込んでいます...`);
+ }
+ try {
+ await importFromFile(file);
+ } catch (error) {
+ console.error("[mikuproject] file import failed", error);
+ setStatus(error instanceof Error ? error.message : "ファイル読込に失敗しました");
+ } finally {
+ if (input) {
+ input.value = "";
+ }
+ }
+ });
+ getTextArea("xmlInput").addEventListener("input", () => {
+ isXmlSourceDirty = true;
+ refreshXmlSaveState();
+ });
+ }
+
+ function initialize(): void {
+ bindTabs();
+ bindEvents();
+ updateSummary(null);
+ renderValidationIssues([]);
+ renderImportWarnings([]);
+ renderXlsxImportSummary([]);
+ updateSvgButton();
+ loadSample();
+ }
+
+ (globalThis as typeof globalThis & {
+ __mikuprojectMainTestHooks?: {
+ parseCurrentXml: () => void;
+ exportCurrentMermaid: () => Promise;
+ renderValidationIssues: (issues: ValidationIssue[]) => void;
+ renderXlsxImportSummary: (changes: Array<{
+ scope: "project" | "tasks" | "resources" | "assignments" | "calendars";
+ uid: string;
+ label: string;
+ field: string;
+ before: string | number | boolean | undefined;
+ after: string | number | boolean;
+ }>) => void;
+ updateFeedbackVisibility: () => void;
+ };
+ }).__mikuprojectMainTestHooks = {
+ parseCurrentXml,
+ exportCurrentMermaid,
+ renderValidationIssues,
+ renderXlsxImportSummary,
+ updateFeedbackVisibility
+ };
+
+ document.addEventListener("DOMContentLoaded", initialize);
+})();
diff --git a/src/ts/markdown-escape.ts b/src/ts/markdown-escape.ts
new file mode 100644
index 0000000..eb52e41
--- /dev/null
+++ b/src/ts/markdown-escape.ts
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ type MarkdownLiteralPart = {
+ kind: "text" | "escaped";
+ text: string;
+ rawText: string;
+ };
+
+ function escapeMarkdownLineStart(text: string): string {
+ return String(text || "")
+ .replace(/^(\s*)([#>])/u, "$1\\$2")
+ .replace(/^(\s*)([-+*])(\s+)/u, "$1\\$2$3")
+ .replace(/^(\s*)(\d+)\.(\s+)/u, "$1$2\\.$3");
+ }
+
+ function escapeMarkdownLiteralParts(text: string): MarkdownLiteralPart[] {
+ const source = String(text || "");
+ const parts: MarkdownLiteralPart[] = [];
+ let buffer = "";
+
+ function pushTextBuffer() {
+ if (!buffer) {
+ return;
+ }
+ parts.push({ kind: "text", text: buffer, rawText: buffer });
+ buffer = "";
+ }
+
+ function pushEscaped(textValue: string, rawText: string) {
+ pushTextBuffer();
+ if (!textValue) {
+ return;
+ }
+ parts.push({ kind: "escaped", text: textValue, rawText });
+ }
+
+ for (let index = 0; index < source.length; index += 1) {
+ const ch = source[index];
+ const atLineStart = index === 0;
+ const next = source[index + 1] || "";
+ if (ch === "\\") {
+ pushEscaped("\\\\", ch);
+ continue;
+ }
+ if (ch === "&") {
+ pushEscaped("&", ch);
+ continue;
+ }
+ if (ch === "<") {
+ pushEscaped("<", ch);
+ continue;
+ }
+ if (ch === ">") {
+ pushEscaped(">", ch);
+ continue;
+ }
+ if (/[`*_{}\[\]()!|~]/.test(ch)) {
+ pushEscaped(`\\${ch}`, ch);
+ continue;
+ }
+ if (atLineStart && /[#]/.test(ch)) {
+ pushEscaped(`\\${ch}`, ch);
+ continue;
+ }
+ if (atLineStart && /[-+*]/.test(ch) && /\s/u.test(next)) {
+ pushEscaped(`\\${ch}`, ch);
+ continue;
+ }
+ if (atLineStart && /\d/u.test(ch)) {
+ let digitRun = ch;
+ let cursor = index + 1;
+ while (cursor < source.length && /\d/u.test(source[cursor])) {
+ digitRun += source[cursor];
+ cursor += 1;
+ }
+ if (source[cursor] === "." && /\s/u.test(source[cursor + 1] || "")) {
+ pushTextBuffer();
+ parts.push({ kind: "text", text: digitRun, rawText: digitRun });
+ parts.push({ kind: "escaped", text: "\\.", rawText: "." });
+ index = cursor;
+ continue;
+ }
+ }
+ buffer += ch;
+ }
+
+ pushTextBuffer();
+ return parts;
+ }
+
+ function escapeMarkdownLiteralText(text: string): string {
+ return String(text || "")
+ .replace(/\r\n?/g, "\n")
+ .split("\n")
+ .map((line) => escapeMarkdownLiteralParts(line).map((part) => part.text).join(""))
+ .join("\n");
+ }
+
+ // Table cells need the normal literal escaping plus pipe escaping and
+ // line-break conversion because Markdown tables do not preserve raw newlines.
+ function escapeMarkdownTableCell(text: string): string {
+ return escapeMarkdownLiteralText(text).replaceAll("|", "\\|").replaceAll("\n", " ");
+ }
+
+ (globalThis as typeof globalThis & {
+ __mikuprojectMarkdownEscape?: {
+ escapeMarkdownLineStart: typeof escapeMarkdownLineStart;
+ escapeMarkdownLiteralParts: typeof escapeMarkdownLiteralParts;
+ escapeMarkdownLiteralText: typeof escapeMarkdownLiteralText;
+ escapeMarkdownTableCell: typeof escapeMarkdownTableCell;
+ };
+ }).__mikuprojectMarkdownEscape = {
+ escapeMarkdownLineStart,
+ escapeMarkdownLiteralParts,
+ escapeMarkdownLiteralText,
+ escapeMarkdownTableCell
+ };
+})();
diff --git a/src/ts/msproject-xml.ts b/src/ts/msproject-xml.ts
new file mode 100644
index 0000000..271f6bc
--- /dev/null
+++ b/src/ts/msproject-xml.ts
@@ -0,0 +1,3751 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ const SAMPLE_PROJECT_DRAFT_VIEW = {
+ view_type: "project_draft_view",
+ project: {
+ name: "mikuproject開発",
+ planned_start: "2026-03-16",
+ planned_finish: "2026-04-01"
+ },
+ tasks: [
+ {
+ uid: "draft-100",
+ name: "基盤整備",
+ parent_uid: null,
+ position: 0,
+ is_summary: true,
+ percent_complete: 100,
+ planned_start: "2026-03-16",
+ planned_finish: "2026-03-17"
+ },
+ {
+ uid: "draft-110",
+ name: "着手",
+ parent_uid: "draft-100",
+ position: 0,
+ is_milestone: true,
+ percent_complete: 100,
+ planned_start: "2026-03-16",
+ planned_finish: "2026-03-16"
+ },
+ {
+ uid: "draft-120",
+ name: "初期実装(MS Project XML 調査・基軸フォーマット選定・内部モデルの概要確定)",
+ parent_uid: "draft-100",
+ position: 1,
+ percent_complete: 100,
+ planned_start: "2026-03-16",
+ planned_finish: "2026-03-16"
+ },
+ {
+ uid: "draft-130",
+ name: "round-trip拡張(MS Project XML → 内部JSON形式 → MS Project XML の往復対応)",
+ parent_uid: "draft-100",
+ position: 2,
+ percent_complete: 100,
+ planned_start: "2026-03-17",
+ planned_finish: "2026-03-17"
+ },
+ {
+ uid: "draft-150",
+ name: "架空検討フェーズ【架空】",
+ parent_uid: null,
+ position: 1,
+ is_summary: true,
+ percent_complete: 25,
+ planned_start: "2026-03-19",
+ planned_finish: "2026-03-25"
+ },
+ {
+ uid: "draft-160",
+ name: "ユーザー操作フローの見直し【架空】",
+ parent_uid: "draft-150",
+ position: 0,
+ percent_complete: 50,
+ planned_start: "2026-03-19",
+ planned_finish: "2026-03-23"
+ },
+ {
+ uid: "draft-170",
+ name: "画面構成の再整理【架空】",
+ parent_uid: "draft-150",
+ position: 1,
+ planned_start: "2026-03-24",
+ planned_finish: "2026-03-25"
+ },
+ {
+ uid: "draft-200",
+ name: "XLSX / UI 強化",
+ parent_uid: null,
+ position: 2,
+ is_summary: true,
+ planned_start: "2026-03-27",
+ planned_finish: "2026-03-28"
+ },
+ {
+ uid: "draft-210",
+ name: "GitHub リポジトリ独立化",
+ parent_uid: "draft-200",
+ position: 0,
+ is_milestone: true,
+ planned_start: "2026-03-27",
+ planned_finish: "2026-03-27"
+ },
+ {
+ uid: "draft-220",
+ name: "MS Project XML と XLSX の相互変換・round-trip実装",
+ parent_uid: "draft-200",
+ position: 1,
+ planned_start: "2026-03-27",
+ planned_finish: "2026-03-27"
+ },
+ {
+ uid: "draft-230",
+ name: "XLSXレイアウト再設計・再整理",
+ parent_uid: "draft-200",
+ position: 2,
+ planned_start: "2026-03-28",
+ planned_finish: "2026-03-28"
+ },
+ {
+ uid: "draft-300",
+ name: "リリース",
+ parent_uid: null,
+ position: 3,
+ is_summary: true,
+ planned_start: "2026-03-29",
+ planned_finish: "2026-03-29"
+ },
+ {
+ uid: "draft-310",
+ name: "v1.0 リリース",
+ parent_uid: "draft-300",
+ position: 0,
+ is_milestone: true,
+ planned_start: "2026-03-29",
+ planned_finish: "2026-03-29"
+ }
+ ]
+ } as const;
+
+ const SAMPLE_XML = buildSampleXml();
+ /*
+
+ Sample Project Title
+ Local HTML Tools
+ Toshiki Iga
+ 2026-03-16T08:30:00
+ 2026-03-16T09:10:00
+ 14
+ 2026-03-16T09:00:00
+ 2026-03-16T09:00:00
+ 2026-03-31T18:00:00
+ 1
+ 09:00:00
+ 18:00:00
+ 480
+ 2400
+ 20
+ 2026-03-19T09:00:00
+ 1
+ 2
+ 7
+ JPY
+ 0
+ ¥
+ 0
+ 2026-04-01T00:00:00
+ 1
+ 0
+ 1
+ 2
+ 5000/h
+ 7000/h
+ 0
+ 0
+ 0
+ 1
+ 1
+ 0
+ 1
+ 1
+ 1
+ 0
+ 1
+ 0
+ 1
+
+
+ 188743731
+ Outline Code1
+ Phase
+ 1
+ 0
+ 0
+ 0
+ 0
+
+
+ 1
+ *
+ 0
+ 0
+
+
+
+
+ PLAN
+ Planning
+
+
+ BUILD
+ Implementation
+
+
+
+
+
+
+ 1
+ A
+ 1
+ 1
+
+
+ 2
+ 00
+ 2
+ 1
+
+
+
+
+ 188743734
+ Text1
+ Owner
+ 0
+ 0
+ 1
+
+
+
+
+ 1
+ Standard
+ 1
+ 1
+
+
+ 元日(公式)
+ 2026-01-01T00:00:00
+ 2026-01-01T23:59:59
+ 0
+
+
+ 成人の日(公式)
+ 2026-01-12T00:00:00
+ 2026-01-12T23:59:59
+ 0
+
+
+ 建国記念の日(公式)
+ 2026-02-11T00:00:00
+ 2026-02-11T23:59:59
+ 0
+
+
+ 天皇誕生日(公式)
+ 2026-02-23T00:00:00
+ 2026-02-23T23:59:59
+ 0
+
+
+ 春分の日(公式)
+ 2026-03-20T00:00:00
+ 2026-03-20T23:59:59
+ 0
+
+
+ 昭和の日(公式)
+ 2026-04-29T00:00:00
+ 2026-04-29T23:59:59
+ 0
+
+
+ 憲法記念日(公式)
+ 2026-05-03T00:00:00
+ 2026-05-03T23:59:59
+ 0
+
+
+ みどりの日(公式)
+ 2026-05-04T00:00:00
+ 2026-05-04T23:59:59
+ 0
+
+
+ こどもの日(公式)
+ 2026-05-05T00:00:00
+ 2026-05-05T23:59:59
+ 0
+
+
+ 休日(公式)
+ 2026-05-06T00:00:00
+ 2026-05-06T23:59:59
+ 0
+
+
+ 海の日(公式)
+ 2026-07-20T00:00:00
+ 2026-07-20T23:59:59
+ 0
+
+
+ 山の日(公式)
+ 2026-08-11T00:00:00
+ 2026-08-11T23:59:59
+ 0
+
+
+ 敬老の日(公式)
+ 2026-09-21T00:00:00
+ 2026-09-21T23:59:59
+ 0
+
+
+ 休日(公式)
+ 2026-09-22T00:00:00
+ 2026-09-22T23:59:59
+ 0
+
+
+ 秋分の日(公式)
+ 2026-09-23T00:00:00
+ 2026-09-23T23:59:59
+ 0
+
+
+ スポーツの日(公式)
+ 2026-10-12T00:00:00
+ 2026-10-12T23:59:59
+ 0
+
+
+ 文化の日(公式)
+ 2026-11-03T00:00:00
+ 2026-11-03T23:59:59
+ 0
+
+
+ 勤労感謝の日(公式)
+ 2026-11-23T00:00:00
+ 2026-11-23T23:59:59
+ 0
+
+
+ 元日(公式)
+ 2027-01-01T00:00:00
+ 2027-01-01T23:59:59
+ 0
+
+
+ 成人の日(公式)
+ 2027-01-11T00:00:00
+ 2027-01-11T23:59:59
+ 0
+
+
+ 建国記念の日(公式)
+ 2027-02-11T00:00:00
+ 2027-02-11T23:59:59
+ 0
+
+
+ 天皇誕生日(公式)
+ 2027-02-23T00:00:00
+ 2027-02-23T23:59:59
+ 0
+
+
+ 春分の日(公式)
+ 2027-03-21T00:00:00
+ 2027-03-21T23:59:59
+ 0
+
+
+ 休日(公式)
+ 2027-03-22T00:00:00
+ 2027-03-22T23:59:59
+ 0
+
+
+ 昭和の日(公式)
+ 2027-04-29T00:00:00
+ 2027-04-29T23:59:59
+ 0
+
+
+ 憲法記念日(公式)
+ 2027-05-03T00:00:00
+ 2027-05-03T23:59:59
+ 0
+
+
+ みどりの日(公式)
+ 2027-05-04T00:00:00
+ 2027-05-04T23:59:59
+ 0
+
+
+ こどもの日(公式)
+ 2027-05-05T00:00:00
+ 2027-05-05T23:59:59
+ 0
+
+
+ 海の日(公式)
+ 2027-07-19T00:00:00
+ 2027-07-19T23:59:59
+ 0
+
+
+ 山の日(公式)
+ 2027-08-11T00:00:00
+ 2027-08-11T23:59:59
+ 0
+
+
+ 敬老の日(公式)
+ 2027-09-20T00:00:00
+ 2027-09-20T23:59:59
+ 0
+
+
+ 秋分の日(公式)
+ 2027-09-23T00:00:00
+ 2027-09-23T23:59:59
+ 0
+
+
+ スポーツの日(公式)
+ 2027-10-11T00:00:00
+ 2027-10-11T23:59:59
+ 0
+
+
+ 文化の日(公式)
+ 2027-11-03T00:00:00
+ 2027-11-03T23:59:59
+ 0
+
+
+ 勤労感謝の日(公式)
+ 2027-11-23T00:00:00
+ 2027-11-23T23:59:59
+ 0
+
+
+ 元日(推定)
+ 2028-01-01T00:00:00
+ 2028-01-01T23:59:59
+ 0
+
+
+ 成人の日(推定)
+ 2028-01-10T00:00:00
+ 2028-01-10T23:59:59
+ 0
+
+
+ 建国記念の日(推定)
+ 2028-02-11T00:00:00
+ 2028-02-11T23:59:59
+ 0
+
+
+ 天皇誕生日(推定)
+ 2028-02-23T00:00:00
+ 2028-02-23T23:59:59
+ 0
+
+
+ 春分の日(推定)
+ 2028-03-20T00:00:00
+ 2028-03-20T23:59:59
+ 0
+
+
+ 昭和の日(推定)
+ 2028-04-29T00:00:00
+ 2028-04-29T23:59:59
+ 0
+
+
+ 憲法記念日(推定)
+ 2028-05-03T00:00:00
+ 2028-05-03T23:59:59
+ 0
+
+
+ みどりの日(推定)
+ 2028-05-04T00:00:00
+ 2028-05-04T23:59:59
+ 0
+
+
+ こどもの日(推定)
+ 2028-05-05T00:00:00
+ 2028-05-05T23:59:59
+ 0
+
+
+ 海の日(推定)
+ 2028-07-17T00:00:00
+ 2028-07-17T23:59:59
+ 0
+
+
+ 山の日(推定)
+ 2028-08-11T00:00:00
+ 2028-08-11T23:59:59
+ 0
+
+
+ 敬老の日(推定)
+ 2028-09-18T00:00:00
+ 2028-09-18T23:59:59
+ 0
+
+
+ 秋分の日(推定)
+ 2028-09-22T00:00:00
+ 2028-09-22T23:59:59
+ 0
+
+
+ スポーツの日(推定)
+ 2028-10-09T00:00:00
+ 2028-10-09T23:59:59
+ 0
+
+
+ 文化の日(推定)
+ 2028-11-03T00:00:00
+ 2028-11-03T23:59:59
+ 0
+
+
+ 勤労感謝の日(推定)
+ 2028-11-23T00:00:00
+ 2028-11-23T23:59:59
+ 0
+
+
+ 元日(推定)
+ 2029-01-01T00:00:00
+ 2029-01-01T23:59:59
+ 0
+
+
+ 成人の日(推定)
+ 2029-01-08T00:00:00
+ 2029-01-08T23:59:59
+ 0
+
+
+ 建国記念の日(推定)
+ 2029-02-11T00:00:00
+ 2029-02-11T23:59:59
+ 0
+
+
+ 休日(推定)
+ 2029-02-12T00:00:00
+ 2029-02-12T23:59:59
+ 0
+
+
+ 天皇誕生日(推定)
+ 2029-02-23T00:00:00
+ 2029-02-23T23:59:59
+ 0
+
+
+ 春分の日(推定)
+ 2029-03-20T00:00:00
+ 2029-03-20T23:59:59
+ 0
+
+
+ 昭和の日(推定)
+ 2029-04-29T00:00:00
+ 2029-04-29T23:59:59
+ 0
+
+
+ 休日(推定)
+ 2029-04-30T00:00:00
+ 2029-04-30T23:59:59
+ 0
+
+
+ 憲法記念日(推定)
+ 2029-05-03T00:00:00
+ 2029-05-03T23:59:59
+ 0
+
+
+ みどりの日(推定)
+ 2029-05-04T00:00:00
+ 2029-05-04T23:59:59
+ 0
+
+
+ こどもの日(推定)
+ 2029-05-05T00:00:00
+ 2029-05-05T23:59:59
+ 0
+
+
+ 海の日(推定)
+ 2029-07-16T00:00:00
+ 2029-07-16T23:59:59
+ 0
+
+
+ 山の日(推定)
+ 2029-08-11T00:00:00
+ 2029-08-11T23:59:59
+ 0
+
+
+ 敬老の日(推定)
+ 2029-09-17T00:00:00
+ 2029-09-17T23:59:59
+ 0
+
+
+ 秋分の日(推定)
+ 2029-09-23T00:00:00
+ 2029-09-23T23:59:59
+ 0
+
+
+ 休日(推定)
+ 2029-09-24T00:00:00
+ 2029-09-24T23:59:59
+ 0
+
+
+ スポーツの日(推定)
+ 2029-10-08T00:00:00
+ 2029-10-08T23:59:59
+ 0
+
+
+ 文化の日(推定)
+ 2029-11-03T00:00:00
+ 2029-11-03T23:59:59
+ 0
+
+
+ 勤労感謝の日(推定)
+ 2029-11-23T00:00:00
+ 2029-11-23T23:59:59
+ 0
+
+
+ 元日(推定)
+ 2030-01-01T00:00:00
+ 2030-01-01T23:59:59
+ 0
+
+
+ 成人の日(推定)
+ 2030-01-14T00:00:00
+ 2030-01-14T23:59:59
+ 0
+
+
+ 建国記念の日(推定)
+ 2030-02-11T00:00:00
+ 2030-02-11T23:59:59
+ 0
+
+
+ 天皇誕生日(推定)
+ 2030-02-23T00:00:00
+ 2030-02-23T23:59:59
+ 0
+
+
+ 春分の日(推定)
+ 2030-03-20T00:00:00
+ 2030-03-20T23:59:59
+ 0
+
+
+ 昭和の日(推定)
+ 2030-04-29T00:00:00
+ 2030-04-29T23:59:59
+ 0
+
+
+ 憲法記念日(推定)
+ 2030-05-03T00:00:00
+ 2030-05-03T23:59:59
+ 0
+
+
+ みどりの日(推定)
+ 2030-05-04T00:00:00
+ 2030-05-04T23:59:59
+ 0
+
+
+ こどもの日(推定)
+ 2030-05-05T00:00:00
+ 2030-05-05T23:59:59
+ 0
+
+
+ 休日(推定)
+ 2030-05-06T00:00:00
+ 2030-05-06T23:59:59
+ 0
+
+
+ 海の日(推定)
+ 2030-07-15T00:00:00
+ 2030-07-15T23:59:59
+ 0
+
+
+ 山の日(推定)
+ 2030-08-11T00:00:00
+ 2030-08-11T23:59:59
+ 0
+
+
+ 休日(推定)
+ 2030-08-12T00:00:00
+ 2030-08-12T23:59:59
+ 0
+
+
+ 敬老の日(推定)
+ 2030-09-16T00:00:00
+ 2030-09-16T23:59:59
+ 0
+
+
+ 秋分の日(推定)
+ 2030-09-23T00:00:00
+ 2030-09-23T23:59:59
+ 0
+
+
+ スポーツの日(推定)
+ 2030-10-14T00:00:00
+ 2030-10-14T23:59:59
+ 0
+
+
+ 文化の日(推定)
+ 2030-11-03T00:00:00
+ 2030-11-03T23:59:59
+ 0
+
+
+ 休日(推定)
+ 2030-11-04T00:00:00
+ 2030-11-04T23:59:59
+ 0
+
+
+ 勤労感謝の日(推定)
+ 2030-11-23T00:00:00
+ 2030-11-23T23:59:59
+ 0
+
+
+
+
+ 2
+ 1
+
+
+ 09:00:00
+ 12:00:00
+
+
+ 13:00:00
+ 18:00:00
+
+
+
+
+
+
+ 2
+ Development
+ 0
+ 1
+
+
+ Spring Sprint
+ 2026-03-16T00:00:00
+ 2026-03-31T23:59:59
+
+
+ 2
+ 1
+
+
+ 10:00:00
+ 18:00:00
+
+
+
+
+
+
+
+
+ 6
+ 1
+
+
+ 10:00:00
+ 15:00:00
+
+
+
+
+
+
+
+
+ 1
+ 1
+ Project Summary
+ 1
+ 1
+ 1
+ 1
+ 1
+ 500
+ 2026-03-16T09:00:00
+ 2026-03-20T18:00:00
+ PT40H0M0S
+ PT0H0M0S
+ PT0H0M0S
+ PT40H0M0S
+ PT0H0M0S
+ PT0H0M0S
+ PT0H0M0S
+ 200000
+ 100000
+ 100000
+ PT20H0M0S
+ PT20H0M0S
+ 0
+ 1
+ 0
+ 50
+ 50
+
+
+ 2
+ 2
+ Design
+ 2
+ 1.1
+ 1.1
+ 1
+ 1
+ 500
+ 2026-03-16T09:00:00
+ 2026-03-17T18:00:00
+ PT16H0M0S
+ 2026-03-16T09:00:00
+ 2026-03-17T18:00:00
+ PT0H0M0S
+ PT0H0M0S
+ PT16H0M0S
+ PT0H0M0S
+ PT0H0M0S
+ PT0H0M0S
+ 80000
+ 80000
+ 0
+ PT0H0M0S
+ PT16H0M0S
+ 0
+ 0
+ 0
+ 100
+ 100
+ Design completed
+
+ 188743734
+ Miku
+
+
+ 0
+ 2026-03-16T09:00:00
+ 2026-03-17T18:00:00
+ PT16H0M0S
+ 80000
+
+
+ 1
+ 2
+ 2026-03-16T09:00:00
+ 2026-03-16T18:00:00
+ 2
+ PT8H0M0S
+
+
+
+ 3
+ 3
+ Implementation
+ 2
+ 1.2
+ 1.2
+ 1
+ 1
+ 700
+ 2026-03-18T09:00:00
+ 2026-03-20T18:00:00
+ PT24H0M0S
+ 2026-03-21T18:00:00
+ PT0H0M0S
+ PT0H0M0S
+ PT24H0M0S
+ PT0H0M0S
+ PT4H0M0S
+ PT2H0M0S
+ 120000
+ 0
+ 120000
+ PT24H0M0S
+ PT0H0M0S
+ 4
+ 2026-03-18T09:00:00
+ 0
+ 0
+ 1
+ 0
+ 0
+ Implementation starts after design
+
+ 2
+ 1
+ PT0H0M0S
+
+
+
+
+
+ 1
+ 1
+ Miku
+ 1
+ MK
+ Engineering
+ 0
+ 1
+ 2
+ 5000/h
+ 2
+ 7000/h
+ 2
+ 1000
+ PT40H0M0S
+ PT20H0M0S
+ PT20H0M0S
+ 200000
+ 100000
+ 100000
+ 50
+
+ 188743737
+ Platform Team
+
+
+ 0
+ 2026-03-16T09:00:00
+ 2026-03-20T18:00:00
+ PT40H0M0S
+ 200000
+
+
+ 1
+ 1
+ 2026-03-16T09:00:00
+ 2026-03-16T18:00:00
+ 2
+ PT8H0M0S
+
+
+
+
+
+ 1
+ 2
+ 1
+ 2026-03-16T09:00:00
+ 2026-03-17T18:00:00
+ PT0H0M0S
+ PT0H0M0S
+ PT0H0M0S
+ 0
+ 0
+ 1
+ PT16H0M0S
+ 80000
+ 40000
+ 40000
+ 50
+ PT2H0M0S
+ PT1H0M0S
+ PT8H0M0S
+ PT8H0M0S
+
+ 255852547
+ Design Slot
+
+
+ 0
+ 2026-03-16T09:00:00
+ 2026-03-17T18:00:00
+ PT16H0M0S
+ 80000
+
+
+ 1
+ 1
+ 2026-03-16T09:00:00
+ 2026-03-16T18:00:00
+ 2
+ PT8H0M0S
+
+
+
+ 2
+ 3
+ 1
+ 2026-03-18T09:00:00
+ 2026-03-20T18:00:00
+ PT0H0M0S
+ PT0H0M0S
+ PT0H0M0S
+ 0
+ 0
+ 1
+ PT24H0M0S
+ 120000
+ 0
+ 120000
+ 0
+ PT0H0M0S
+ PT0H0M0S
+ PT0H0M0S
+ PT24H0M0S
+
+
+
+
+ */
+
+ function buildSampleXml(): string {
+ const sampleModel = importProjectDraftView(SAMPLE_PROJECT_DRAFT_VIEW);
+ sampleModel.project.currentDate = "2026-03-23T09:00:00";
+ sampleModel.project.statusDate = "2026-03-23T09:00:00";
+ return exportMsProjectXml(sampleModel);
+ }
+
+ function textContent(parent: Element, tagName: string): string {
+ const element = parent.getElementsByTagName(tagName)[0];
+ return String(element?.textContent || "").trim();
+ }
+
+ function parseBoolean(value: string): boolean {
+ return value === "1" || value.toLowerCase() === "true";
+ }
+
+ function parseNumber(value: string, defaultValue = 0): number {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : defaultValue;
+ }
+
+ function parseDateValue(value: string | undefined): number | null {
+ if (!value) {
+ return null;
+ }
+ const timestamp = Date.parse(value);
+ return Number.isFinite(timestamp) ? timestamp : null;
+ }
+
+ function parseDateOnly(value: string | undefined): Date | null {
+ const text = String(value || "").trim().slice(0, 10);
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) {
+ return null;
+ }
+ const parsed = new Date(`${text}T00:00:00`);
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+ }
+
+ function formatDateOnly(value: Date): string {
+ const year = value.getFullYear();
+ const month = String(value.getMonth() + 1).padStart(2, "0");
+ const day = String(value.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+ }
+
+ function addDateDays(base: Date, days: number): Date {
+ const next = new Date(base.getTime());
+ next.setDate(next.getDate() + days);
+ return next;
+ }
+
+ function addDateYears(base: Date, years: number): Date {
+ const next = new Date(base.getTime());
+ next.setFullYear(next.getFullYear() + years);
+ return next;
+ }
+
+ function toMsDayType(value: Date): number {
+ const day = value.getDay();
+ return day === 0 ? 1 : day + 1;
+ }
+
+ function buildNthWeekdayOfMonth(year: number, monthIndex: number, jsWeekday: number, nth: number): Date {
+ const first = new Date(year, monthIndex, 1);
+ const offset = (jsWeekday - first.getDay() + 7) % 7;
+ return new Date(year, monthIndex, 1 + offset + ((nth - 1) * 7));
+ }
+
+ function calculateVernalEquinoxDay(year: number): number {
+ return Math.floor(20.8431 + (0.242194 * (year - 1980)) - Math.floor((year - 1980) / 4));
+ }
+
+ function calculateAutumnalEquinoxDay(year: number): number {
+ return Math.floor(23.2488 + (0.242194 * (year - 1980)) - Math.floor((year - 1980) / 4));
+ }
+
+ function buildJapaneseHolidayMapForYear(year: number): Map {
+ const holidays = new Map();
+ const addHoliday = (date: Date, name: string) => {
+ holidays.set(formatDateOnly(date), name);
+ };
+ addHoliday(new Date(year, 0, 1), "元日");
+ addHoliday(buildNthWeekdayOfMonth(year, 0, 1, 2), "成人の日");
+ addHoliday(new Date(year, 1, 11), "建国記念の日");
+ addHoliday(new Date(year, 1, 23), "天皇誕生日");
+ addHoliday(new Date(year, 2, calculateVernalEquinoxDay(year)), "春分の日");
+ addHoliday(new Date(year, 3, 29), "昭和の日");
+ addHoliday(new Date(year, 4, 3), "憲法記念日");
+ addHoliday(new Date(year, 4, 4), "みどりの日");
+ addHoliday(new Date(year, 4, 5), "こどもの日");
+ addHoliday(buildNthWeekdayOfMonth(year, 6, 1, 3), "海の日");
+ addHoliday(new Date(year, 7, 11), "山の日");
+ addHoliday(buildNthWeekdayOfMonth(year, 8, 1, 3), "敬老の日");
+ addHoliday(new Date(year, 8, calculateAutumnalEquinoxDay(year)), "秋分の日");
+ addHoliday(buildNthWeekdayOfMonth(year, 9, 1, 2), "スポーツの日");
+ addHoliday(new Date(year, 10, 3), "文化の日");
+ addHoliday(new Date(year, 10, 23), "勤労感謝の日");
+
+ const baseHolidayDates = Array.from(holidays.keys()).sort();
+ for (const dateText of baseHolidayDates) {
+ const date = parseDateOnly(dateText);
+ if (!date || date.getDay() !== 0) {
+ continue;
+ }
+ let substitute = addDateDays(date, 1);
+ while (holidays.has(formatDateOnly(substitute))) {
+ substitute = addDateDays(substitute, 1);
+ }
+ holidays.set(formatDateOnly(substitute), "休日");
+ }
+
+ const sortedDates = Array.from(holidays.keys()).sort();
+ for (let index = 0; index < sortedDates.length - 1; index += 1) {
+ const current = parseDateOnly(sortedDates[index]);
+ const next = parseDateOnly(sortedDates[index + 1]);
+ if (!current || !next) {
+ continue;
+ }
+ const gapDays = Math.floor((next.getTime() - current.getTime()) / 86400000);
+ if (gapDays !== 2) {
+ continue;
+ }
+ const between = addDateDays(current, 1);
+ const betweenText = formatDateOnly(between);
+ if (holidays.has(betweenText) || between.getDay() === 0) {
+ continue;
+ }
+ holidays.set(betweenText, "休日");
+ }
+
+ return new Map(Array.from(holidays.entries()).sort((left, right) => left[0].localeCompare(right[0])));
+ }
+
+ function buildDefaultWorkingTimes(project: ProjectInfo): WorkingTimeModel[] {
+ const start = project.defaultStartTime || "09:00:00";
+ const finish = project.defaultFinishTime || "18:00:00";
+ if (start < "12:00:00" && finish > "13:00:00") {
+ return [
+ { fromTime: start, toTime: "12:00:00" },
+ { fromTime: "13:00:00", toTime: finish }
+ ];
+ }
+ return [{ fromTime: start, toTime: finish }];
+ }
+
+ function buildDefaultStandardWeekDays(project: ProjectInfo): WeekDayModel[] {
+ const workingTimes = buildDefaultWorkingTimes(project);
+ return Array.from({ length: 7 }, (_, index) => {
+ const dayType = index + 1;
+ const dayWorking = dayType !== 1 && dayType !== 7;
+ return {
+ dayType,
+ dayWorking,
+ workingTimes: dayWorking ? workingTimes.map((item) => ({ ...item })) : []
+ };
+ });
+ }
+
+ function buildDefaultJapaneseHolidayExceptions(project: ProjectInfo): CalendarExceptionModel[] {
+ const start = parseDateOnly(project.startDate) || parseDateOnly(project.finishDate) || new Date();
+ const finishLimit = parseDateOnly(project.finishDate) || start;
+ const rangeStart = start.getTime() <= finishLimit.getTime() ? start : finishLimit;
+ const rangeFinish = start.getTime() <= finishLimit.getTime() ? finishLimit : start;
+ const exceptions: CalendarExceptionModel[] = [];
+ for (let year = rangeStart.getFullYear(); year <= rangeFinish.getFullYear(); year += 1) {
+ const holidays = buildJapaneseHolidayMapForYear(year);
+ for (const [dateText, name] of holidays.entries()) {
+ const date = parseDateOnly(dateText);
+ if (!date || date.getTime() < rangeStart.getTime() || date.getTime() > rangeFinish.getTime()) {
+ continue;
+ }
+ exceptions.push({
+ name,
+ fromDate: `${dateText}T00:00:00`,
+ toDate: `${dateText}T23:59:59`,
+ dayWorking: false,
+ workingTimes: []
+ });
+ }
+ }
+ return exceptions;
+ }
+
+ function findFallbackCalendarUid(model: ProjectModel): string | undefined {
+ const baseCalendar = model.calendars.find((calendar) => calendar.isBaseCalendar);
+ return baseCalendar?.uid || model.calendars[0]?.uid;
+ }
+
+ function allocateDefaultCalendarUid(model: ProjectModel): string {
+ const usedUids = new Set(model.calendars.map((calendar) => String(calendar.uid)));
+ let candidate = 1;
+ while (usedUids.has(String(candidate))) {
+ candidate += 1;
+ }
+ return String(candidate);
+ }
+
+ function ensureDefaultProjectCalendar(model: ProjectModel): ProjectModel {
+ if (model.calendars.length === 0) {
+ const uid = allocateDefaultCalendarUid(model);
+ model.calendars.push({
+ uid,
+ name: "Standard",
+ isBaseCalendar: true,
+ isBaselineCalendar: true,
+ weekDays: buildDefaultStandardWeekDays(model.project),
+ exceptions: buildDefaultJapaneseHolidayExceptions(model.project),
+ workWeeks: []
+ });
+ model.project.calendarUID = uid;
+ }
+ return model;
+ }
+
+ function parseWeekDays(parent: Element): WeekDayModel[] {
+ return Array.from(parent.getElementsByTagName("WeekDays")[0]?.getElementsByTagName("WeekDay") || []).map((weekDay) => ({
+ dayType: parseNumber(textContent(weekDay, "DayType"), 0),
+ dayWorking: parseBoolean(textContent(weekDay, "DayWorking")),
+ workingTimes: Array.from(weekDay.getElementsByTagName("WorkingTimes")[0]?.getElementsByTagName("WorkingTime") || []).map((workingTime) => ({
+ fromTime: textContent(workingTime, "FromTime"),
+ toTime: textContent(workingTime, "ToTime")
+ }))
+ }));
+ }
+
+ function appendWeekDays(doc: XMLDocument, parent: Element, weekDays: WeekDayModel[]): void {
+ if (weekDays.length === 0) {
+ return;
+ }
+ const weekDaysElement = doc.createElement("WeekDays");
+ for (const weekDay of weekDays) {
+ const weekDayElement = doc.createElement("WeekDay");
+ appendTextElement(doc, weekDayElement, "DayType", weekDay.dayType);
+ appendTextElement(doc, weekDayElement, "DayWorking", weekDay.dayWorking);
+ if (weekDay.workingTimes.length > 0) {
+ const workingTimesElement = doc.createElement("WorkingTimes");
+ for (const workingTime of weekDay.workingTimes) {
+ const workingTimeElement = doc.createElement("WorkingTime");
+ appendTextElement(doc, workingTimeElement, "FromTime", workingTime.fromTime);
+ appendTextElement(doc, workingTimeElement, "ToTime", workingTime.toTime);
+ workingTimesElement.appendChild(workingTimeElement);
+ }
+ weekDayElement.appendChild(workingTimesElement);
+ }
+ weekDaysElement.appendChild(weekDayElement);
+ }
+ parent.appendChild(weekDaysElement);
+ }
+
+ function parseWorkingTimes(parent: Element): WorkingTimeModel[] {
+ return Array.from(parent.getElementsByTagName("WorkingTimes")[0]?.getElementsByTagName("WorkingTime") || []).map((workingTime) => ({
+ fromTime: textContent(workingTime, "FromTime"),
+ toTime: textContent(workingTime, "ToTime")
+ }));
+ }
+
+ function appendWorkingTimes(doc: XMLDocument, parent: Element, workingTimes: WorkingTimeModel[]): void {
+ if (workingTimes.length === 0) {
+ return;
+ }
+ const workingTimesElement = doc.createElement("WorkingTimes");
+ for (const workingTime of workingTimes) {
+ const workingTimeElement = doc.createElement("WorkingTime");
+ appendTextElement(doc, workingTimeElement, "FromTime", workingTime.fromTime);
+ appendTextElement(doc, workingTimeElement, "ToTime", workingTime.toTime);
+ workingTimesElement.appendChild(workingTimeElement);
+ }
+ parent.appendChild(workingTimesElement);
+ }
+
+ function parseOutlineCodeMasks(parent: Element): OutlineCodeMaskModel[] {
+ const masksElement = parent.getElementsByTagName("Masks")[0];
+ if (!masksElement) {
+ return [];
+ }
+ return Array.from(masksElement.children)
+ .filter((child) => child.tagName === "Mask")
+ .map((mask) => ({
+ level: parseNumber(textContent(mask, "Level"), 0),
+ mask: textContent(mask, "Mask") || undefined,
+ length: textContent(mask, "Length") ? parseNumber(textContent(mask, "Length"), 0) : undefined,
+ sequence: textContent(mask, "Sequence") ? parseNumber(textContent(mask, "Sequence"), 0) : undefined
+ }));
+ }
+
+ function parseOutlineCodeValues(parent: Element): OutlineCodeValueModel[] {
+ const valuesElement = parent.getElementsByTagName("Values")[0];
+ if (!valuesElement) {
+ return [];
+ }
+ return Array.from(valuesElement.children)
+ .filter((child) => child.tagName === "Value")
+ .map((value) => ({
+ value: textContent(value, "Value"),
+ description: textContent(value, "Description") || undefined
+ }));
+ }
+
+ function isPlaceholderUid(value: string | undefined): boolean {
+ return String(value || "").trim() === "0";
+ }
+
+ function isUnassignedResourceUid(value: string | undefined): boolean {
+ return String(value || "").trim() === "-65535";
+ }
+
+ function describeTask(task: TaskModel): string {
+ return `UID=${task.uid}${task.name ? ` (${task.name})` : ""}`;
+ }
+
+ function isComparableOutlineNumber(value: string | undefined): boolean {
+ if (!value) {
+ return false;
+ }
+ return value.split(".").every((part) => /^\d+$/.test(part));
+ }
+
+ function compareOutlineNumbers(left: string | undefined, right: string | undefined): number {
+ if (!left || !right) {
+ return 0;
+ }
+ const leftParts = left.split(".").map((part) => Number(part));
+ const rightParts = right.split(".").map((part) => Number(part));
+ const maxLength = Math.max(leftParts.length, rightParts.length);
+ for (let index = 0; index < maxLength; index += 1) {
+ const leftPart = leftParts[index];
+ const rightPart = rightParts[index];
+ if (leftPart === undefined) {
+ return -1;
+ }
+ if (rightPart === undefined) {
+ return 1;
+ }
+ if (leftPart !== rightPart) {
+ return leftPart - rightPart;
+ }
+ }
+ return 0;
+ }
+
+ function detectTaskOrderIssue(tasks: TaskModel[]): { previous: TaskModel; current: TaskModel } | null {
+ let previousComparableTask: TaskModel | null = null;
+ for (const task of tasks) {
+ if (isPlaceholderUid(task.uid)) {
+ continue;
+ }
+ if (!isComparableOutlineNumber(task.outlineNumber)) {
+ continue;
+ }
+ if (previousComparableTask && compareOutlineNumbers(previousComparableTask.outlineNumber, task.outlineNumber) >= 0) {
+ return {
+ previous: previousComparableTask,
+ current: task
+ };
+ }
+ previousComparableTask = task;
+ }
+ return null;
+ }
+
+ function describeResource(resource: ResourceModel): string {
+ return `UID=${resource.uid || "(なし)"}${resource.name ? ` (${resource.name})` : ""}`;
+ }
+
+ function describeCalendar(calendar: CalendarModel): string {
+ return `UID=${calendar.uid}${calendar.name ? ` (${calendar.name})` : ""}`;
+ }
+
+ function describeAssignment(assignment: AssignmentModel): string {
+ return `UID=${assignment.uid || "(なし)"}`;
+ }
+
+ function describeTaskRef(model: ProjectModel, taskUid: string | undefined): string {
+ if (!taskUid) {
+ return "TaskUID=(なし)";
+ }
+ const task = model.tasks.find((item) => item.uid === taskUid);
+ return task ? `TaskUID=${taskUid}${task.name ? ` (${task.name})` : ""}` : `TaskUID=${taskUid}`;
+ }
+
+ function describeResourceRef(model: ProjectModel, resourceUid: string | undefined): string {
+ if (!resourceUid) {
+ return "ResourceUID=(なし)";
+ }
+ const resource = model.resources.find((item) => item.uid === resourceUid);
+ return resource ? `ResourceUID=${resourceUid}${resource.name ? ` (${resource.name})` : ""}` : `ResourceUID=${resourceUid}`;
+ }
+
+ function parseXmlDocument(xmlText: string): XMLDocument {
+ const parser = new DOMParser();
+ const xml = parser.parseFromString(xmlText, "application/xml");
+ const parserError = xml.getElementsByTagName("parsererror")[0];
+ if (parserError) {
+ throw new Error("XML の解析に失敗しました");
+ }
+ return xml;
+ }
+
+ function normalizeMermaidText(value: string | undefined, fallback: string): string {
+ const text = String(value || fallback).replace(/[::#,,]/g, " ").replace(/\s+/g, " ").trim();
+ return text || fallback;
+ }
+
+ function normalizeMermaidGanttLabel(
+ value: string | undefined,
+ fallback: string,
+ leadingPrefix: string
+ ): string {
+ const text = normalizeMermaidText(value, fallback);
+ return /^\d/.test(text) ? `${leadingPrefix} ${text}` : text;
+ }
+
+ function normalizeMermaidTaskId(value: string | undefined, fallback: string): string {
+ return String(value || fallback).replace(/[^A-Za-z0-9_]/g, "_");
+ }
+
+ function toMermaidDuration(duration: string | undefined): string | null {
+ const text = String(duration || "").trim();
+ if (!text) {
+ return null;
+ }
+ const match = /^P(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)$/.exec(text);
+ if (!match) {
+ return null;
+ }
+ const hours = Number(match[1] || 0);
+ const minutes = Number(match[2] || 0);
+ const seconds = Number(match[3] || 0);
+ const parts: string[] = [];
+ if (hours > 0) {
+ parts.push(`${hours}h`);
+ }
+ if (minutes > 0) {
+ parts.push(`${minutes}m`);
+ }
+ if (seconds > 0) {
+ parts.push(`${seconds}s`);
+ }
+ return parts.length > 0 ? parts.join(" ") : null;
+ }
+
+ function formatMermaidLag(duration: string | undefined): string {
+ const short = toMermaidDuration(duration);
+ if (short) {
+ return short;
+ }
+ return String(duration || "").trim();
+ }
+
+ function isZeroDuration(duration: string | undefined): boolean {
+ const text = String(duration || "").trim();
+ return text === "" || text === "PT0H0M0S" || text === "PT0M0S" || text === "PT0S";
+ }
+
+ function describePredecessorType(type: number | undefined): string {
+ if (type === undefined) {
+ return "default";
+ }
+ const typeMap: Record = {
+ 0: "FF",
+ 1: "FS",
+ 2: "FF",
+ 3: "SF",
+ 4: "SS"
+ };
+ return typeMap[type] || `type=${type}`;
+ }
+
+ function formatDependencyType(type: number | undefined): string {
+ if (type === undefined) {
+ return "FS";
+ }
+ const typeMap: Record = {
+ 0: "FF",
+ 1: "FS",
+ 2: "FF",
+ 3: "SF",
+ 4: "SS"
+ };
+ return typeMap[type] || String(type);
+ }
+
+ function parseDurationHours(duration: string | undefined): number | undefined {
+ const text = String(duration || "").trim();
+ if (!text) {
+ return undefined;
+ }
+ const match = /^(-)?P(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)$/.exec(text);
+ if (!match) {
+ return undefined;
+ }
+ const sign = match[1] ? -1 : 1;
+ const hours = Number(match[2] || 0);
+ const minutes = Number(match[3] || 0);
+ const seconds = Number(match[4] || 0);
+ return sign * (hours + minutes / 60 + seconds / 3600);
+ }
+
+ function buildTaskParentMap(tasks: TaskModel[]): Map {
+ const parentMap = new Map();
+ const stack: TaskModel[] = [];
+ for (const task of tasks) {
+ while (stack.length > 0 && task.outlineLevel <= stack[stack.length - 1].outlineLevel) {
+ stack.pop();
+ }
+ parentMap.set(task.uid, stack.length > 0 ? stack[stack.length - 1].uid : null);
+ if (task.summary) {
+ stack.push(task);
+ }
+ }
+ return parentMap;
+ }
+
+ function buildTaskPositionMap(tasks: TaskModel[], parentMap: Map): Map {
+ const counters = new Map();
+ const positionMap = new Map();
+ for (const task of tasks) {
+ const parentUid = parentMap.get(task.uid) || "__root__";
+ const position = counters.get(parentUid) || 0;
+ positionMap.set(task.uid, position);
+ counters.set(parentUid, position + 1);
+ }
+ return positionMap;
+ }
+
+ function collectTopLevelPhases(tasks: TaskModel[]): TaskModel[] {
+ return tasks.filter((task) => !isPlaceholderUid(task.uid) && task.summary && task.outlineLevel === 1);
+ }
+
+ function collectPhaseTaskUids(tasks: TaskModel[], phaseUid: string): Set {
+ const phaseIndex = tasks.findIndex((task) => task.uid === phaseUid);
+ if (phaseIndex < 0) {
+ return new Set();
+ }
+ const phase = tasks[phaseIndex];
+ const uids = new Set();
+ for (let index = phaseIndex + 1; index < tasks.length; index += 1) {
+ const task = tasks[index];
+ if (task.outlineLevel <= phase.outlineLevel) {
+ break;
+ }
+ if (!isPlaceholderUid(task.uid)) {
+ uids.add(task.uid);
+ }
+ }
+ return uids;
+ }
+
+ function collectTaskSubtreeUids(tasks: TaskModel[], rootUid: string, maxDepth?: number): Set {
+ const rootIndex = tasks.findIndex((task) => task.uid === rootUid);
+ if (rootIndex < 0) {
+ return new Set();
+ }
+ const rootTask = tasks[rootIndex];
+ const uids = new Set();
+ if (!isPlaceholderUid(rootTask.uid)) {
+ uids.add(rootTask.uid);
+ }
+ for (let index = rootIndex + 1; index < tasks.length; index += 1) {
+ const task = tasks[index];
+ if (task.outlineLevel <= rootTask.outlineLevel) {
+ break;
+ }
+ const relativeDepth = task.outlineLevel - rootTask.outlineLevel;
+ if (typeof maxDepth === "number" && relativeDepth > maxDepth) {
+ continue;
+ }
+ if (!isPlaceholderUid(task.uid)) {
+ uids.add(task.uid);
+ }
+ }
+ return uids;
+ }
+
+ function resolvePhaseUidForTask(taskUid: string, parentMap: Map, phaseUidSet: Set): string | undefined {
+ let currentUid: string | null | undefined = taskUid;
+ while (currentUid) {
+ if (phaseUidSet.has(currentUid)) {
+ return currentUid;
+ }
+ currentUid = parentMap.get(currentUid) || null;
+ }
+ return undefined;
+ }
+
+ function buildDefaultRules(scope: "project_overview_view" | "phase_detail_view") {
+ if (scope === "project_overview_view") {
+ return {
+ allow_patch_ops: ["add_task", "update_task", "move_task"],
+ forbid_completed_task_changes: true,
+ forbid_summary_task_direct_edit: true
+ };
+ }
+ return {
+ allow_patch_ops: ["add_task", "update_task", "move_task", "link_tasks", "unlink_tasks"],
+ forbid_completed_task_changes: true,
+ forbid_summary_task_direct_edit: true
+ };
+ }
+
+ function toIsoLocalString(value: Date): string {
+ const year = value.getFullYear();
+ const month = String(value.getMonth() + 1).padStart(2, "0");
+ const day = String(value.getDate()).padStart(2, "0");
+ const hour = String(value.getHours()).padStart(2, "0");
+ const minute = String(value.getMinutes()).padStart(2, "0");
+ const second = String(value.getSeconds()).padStart(2, "0");
+ return `${year}-${month}-${day}T${hour}:${minute}:${second}`;
+ }
+
+ function addHoursToDateTime(dateTime: string, hours: number): string {
+ const parsed = new Date(dateTime);
+ if (Number.isNaN(parsed.getTime())) {
+ return dateTime;
+ }
+ parsed.setTime(parsed.getTime() + (hours * 60 * 60 * 1000));
+ return toIsoLocalString(parsed);
+ }
+
+ function isDateOnlyText(value: string | undefined): boolean {
+ return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value.trim());
+ }
+
+ function withTimeOnDate(dateText: string, timeText: string): string {
+ return `${dateText}T${timeText}`;
+ }
+
+ function buildProjectDraftRequest(input: {
+ name: string;
+ plannedStart?: string;
+ goal?: string;
+ teamCount?: number;
+ mustHavePhases?: string[];
+ mustHaveMilestones?: string[];
+ }) {
+ return {
+ view_type: "project_draft_request",
+ project: {
+ name: input.name,
+ planned_start: input.plannedStart || undefined
+ },
+ requirements: {
+ goal: input.goal || undefined,
+ team_count: input.teamCount,
+ must_have_phases: input.mustHavePhases || [],
+ must_have_milestones: input.mustHaveMilestones || []
+ }
+ };
+ }
+
+ function importProjectDraftView(draft: unknown): ProjectModel {
+ if (!draft || typeof draft !== "object") {
+ throw new Error("project_draft_view がオブジェクトではありません");
+ }
+ const data = draft as {
+ view_type?: string;
+ project?: {
+ name?: string;
+ planned_start?: string;
+ planned_finish?: string;
+ };
+ tasks?: Array<{
+ uid?: string;
+ name?: string;
+ parent_uid?: string | null;
+ position?: number;
+ is_summary?: boolean;
+ is_milestone?: boolean;
+ percent_complete?: number;
+ planned_duration?: string;
+ planned_duration_hours?: number;
+ planned_start?: string;
+ planned_finish?: string;
+ predecessors?: Array;
+ predecessor_uids?: string[];
+ }>;
+ };
+ if (data.view_type !== "project_draft_view") {
+ throw new Error("view_type が project_draft_view ではありません");
+ }
+ if (!data.project?.name?.trim()) {
+ throw new Error("project.name がありません");
+ }
+ const inputTasks = Array.isArray(data.tasks) ? data.tasks : [];
+ const seenUids = new Set();
+ for (const task of inputTasks) {
+ const uid = String(task.uid || "").trim();
+ if (!uid) {
+ throw new Error("draft task の uid がありません");
+ }
+ if (seenUids.has(uid)) {
+ throw new Error(`draft task の uid が重複しています: ${uid}`);
+ }
+ seenUids.add(uid);
+ if (!String(task.name || "").trim()) {
+ throw new Error(`draft task の name がありません: ${uid}`);
+ }
+ }
+ for (const task of inputTasks) {
+ const parentUid = task.parent_uid == null || task.parent_uid === "" ? null : String(task.parent_uid);
+ if (parentUid && !seenUids.has(parentUid)) {
+ throw new Error(`draft task の parent_uid が既存 uid を指していません: ${String(task.uid || "")} -> ${parentUid}`);
+ }
+ }
+ const projectStart = data.project.planned_start || data.project.planned_finish || toIsoLocalString(new Date());
+ const draftUidMap = new Map();
+ inputTasks.forEach((task, index) => {
+ draftUidMap.set(String(task.uid || "").trim(), String(index + 1));
+ });
+ const normalizedTasks = inputTasks.map((task, index) => ({
+ uid: draftUidMap.get(String(task.uid || "").trim()) || String(index + 1),
+ name: String(task.name || "").trim(),
+ parentUid: task.parent_uid == null || task.parent_uid === "" ? null : (draftUidMap.get(String(task.parent_uid)) || null),
+ position: typeof task.position === "number" && Number.isFinite(task.position) ? task.position : index,
+ isSummary: Boolean(task.is_summary),
+ isMilestone: Boolean(task.is_milestone),
+ percentComplete: typeof task.percent_complete === "number" && Number.isFinite(task.percent_complete)
+ ? Math.max(0, Math.min(100, task.percent_complete))
+ : 0,
+ plannedDuration: task.planned_duration || undefined,
+ plannedDurationHours: typeof task.planned_duration_hours === "number" && Number.isFinite(task.planned_duration_hours)
+ ? task.planned_duration_hours
+ : undefined,
+ plannedStart: task.planned_start || undefined,
+ plannedFinish: task.planned_finish || undefined,
+ predecessorUids: [
+ ...(Array.isArray(task.predecessor_uids) ? task.predecessor_uids : []),
+ ...(Array.isArray(task.predecessors)
+ ? task.predecessors.flatMap((item) => {
+ if (typeof item === "string") {
+ return [item];
+ }
+ return item?.task_uid ? [item.task_uid] : [];
+ })
+ : [])
+ ].map((item) => draftUidMap.get(String(item)) || String(item))
+ }));
+ const byParent = new Map();
+ for (const task of normalizedTasks) {
+ const siblings = byParent.get(task.parentUid) || [];
+ siblings.push(task);
+ byParent.set(task.parentUid, siblings);
+ }
+ for (const siblings of byParent.values()) {
+ siblings.sort((left, right) => left.position - right.position || left.uid.localeCompare(right.uid));
+ }
+ const orderedTasks: TaskModel[] = [];
+ function walk(parentUid: string | null, outlinePath: number[]): void {
+ const siblings = byParent.get(parentUid) || [];
+ siblings.forEach((task, index) => {
+ const currentPath = [...outlinePath, index + 1];
+ const outlineNumber = currentPath.join(".");
+ let start = task.plannedStart || task.plannedFinish || projectStart;
+ let finish = task.plannedFinish
+ || (typeof task.plannedDurationHours === "number" ? addHoursToDateTime(start, task.plannedDurationHours) : start);
+ const dateOnlyTaskRange = !task.isMilestone
+ && isDateOnlyText(start)
+ && isDateOnlyText(finish)
+ && task.plannedDuration == null
+ && typeof task.plannedDurationHours !== "number";
+ if (dateOnlyTaskRange) {
+ start = withTimeOnDate(start, "09:00:00");
+ finish = withTimeOnDate(finish, "18:00:00");
+ }
+ const hasChildren = (byParent.get(task.uid) || []).length > 0;
+ orderedTasks.push({
+ uid: task.uid,
+ id: task.uid,
+ name: task.name,
+ outlineLevel: currentPath.length,
+ outlineNumber,
+ wbs: outlineNumber,
+ start,
+ finish,
+ duration: task.plannedDuration || (typeof task.plannedDurationHours === "number" ? `PT${task.plannedDurationHours}H` : "PT0H0M0S"),
+ milestone: task.isMilestone,
+ summary: task.isSummary || hasChildren,
+ percentComplete: task.percentComplete,
+ predecessors: task.predecessorUids.map((predecessorUid) => ({ predecessorUid })),
+ extendedAttributes: [],
+ baselines: [],
+ timephasedData: []
+ });
+ walk(task.uid, currentPath);
+ });
+ }
+ walk(null, []);
+ const taskFinishes = orderedTasks.map((task) => task.finish).filter(Boolean).sort();
+ return normalizeProjectModel(ensureDefaultProjectCalendar({
+ project: {
+ name: data.project.name.trim(),
+ title: data.project.name.trim(),
+ startDate: projectStart,
+ finishDate: data.project.planned_finish || taskFinishes.at(-1) || projectStart,
+ scheduleFromStart: true,
+ outlineCodes: [],
+ wbsMasks: [],
+ extendedAttributes: []
+ },
+ tasks: orderedTasks,
+ resources: [],
+ assignments: [],
+ calendars: []
+ }));
+ }
+
+ function exportProjectOverviewView(model: ProjectModel) {
+ const parentMap = buildTaskParentMap(model.tasks);
+ const phaseTasks = collectTopLevelPhases(model.tasks);
+ const phaseUidSet = new Set(phaseTasks.map((task) => task.uid));
+ const allMilestones = model.tasks.filter((task) => !isPlaceholderUid(task.uid) && task.milestone);
+ const topLevelDependencyMap = new Map();
+ for (const task of model.tasks) {
+ const toPhaseUid = resolvePhaseUidForTask(task.uid, parentMap, phaseUidSet);
+ if (!toPhaseUid) {
+ continue;
+ }
+ for (const predecessor of task.predecessors) {
+ const fromPhaseUid = resolvePhaseUidForTask(predecessor.predecessorUid, parentMap, phaseUidSet);
+ if (!fromPhaseUid || fromPhaseUid === toPhaseUid) {
+ continue;
+ }
+ const key = `${fromPhaseUid}->${toPhaseUid}:${formatDependencyType(predecessor.type)}`;
+ if (!topLevelDependencyMap.has(key)) {
+ topLevelDependencyMap.set(key, {
+ from_uid: fromPhaseUid,
+ to_uid: toPhaseUid,
+ type: formatDependencyType(predecessor.type)
+ });
+ }
+ }
+ }
+ return {
+ view_type: "project_overview_view",
+ project: {
+ name: model.project.name,
+ planned_start: model.project.startDate,
+ planned_finish: model.project.finishDate,
+ status_date: model.project.statusDate
+ },
+ summary: {
+ task_count: model.tasks.filter((task) => !isPlaceholderUid(task.uid)).length,
+ summary_task_count: model.tasks.filter((task) => !isPlaceholderUid(task.uid) && task.summary).length,
+ milestone_count: allMilestones.length,
+ max_outline_level: model.tasks.reduce((max, task) => Math.max(max, task.outlineLevel || 0), 0)
+ },
+ phases: phaseTasks.map((phase) => {
+ const phaseTaskUids = collectPhaseTaskUids(model.tasks, phase.uid);
+ const descendantTasks = model.tasks.filter((task) => phaseTaskUids.has(task.uid));
+ return {
+ uid: phase.uid,
+ name: phase.name,
+ wbs: phase.wbs || phase.outlineNumber,
+ task_count: descendantTasks.length,
+ milestone_count: descendantTasks.filter((task) => task.milestone).length,
+ planned_start: phase.start,
+ planned_finish: phase.finish,
+ duration: phase.duration,
+ duration_hours: parseDurationHours(phase.duration),
+ percent_complete: phase.percentComplete,
+ sample_tasks: descendantTasks.slice(0, 3).map((task) => ({
+ uid: task.uid,
+ name: task.name
+ }))
+ };
+ }),
+ milestones: allMilestones.map((task) => ({
+ uid: task.uid,
+ name: task.name,
+ parent_uid: parentMap.get(task.uid),
+ date: task.finish || task.start
+ })),
+ top_level_dependencies: Array.from(topLevelDependencyMap.values()),
+ rules: buildDefaultRules("project_overview_view")
+ };
+ }
+
+ function exportPhaseDetailView(
+ model: ProjectModel,
+ requestedPhaseUid?: string,
+ options?: {
+ mode?: "full" | "scoped";
+ rootUid?: string;
+ maxDepth?: number;
+ }
+ ) {
+ const phaseTasks = collectTopLevelPhases(model.tasks);
+ if (phaseTasks.length === 0) {
+ throw new Error("phase が見つかりません");
+ }
+ const phase = requestedPhaseUid
+ ? phaseTasks.find((task) => task.uid === requestedPhaseUid)
+ : phaseTasks[0];
+ if (!phase) {
+ throw new Error(`phase が見つかりません: ${requestedPhaseUid}`);
+ }
+ const parentMap = buildTaskParentMap(model.tasks);
+ const positionMap = buildTaskPositionMap(model.tasks, parentMap);
+ const phaseTaskUids = collectPhaseTaskUids(model.tasks, phase.uid);
+ const phaseTasksOnly = model.tasks.filter((task) => phaseTaskUids.has(task.uid));
+ const mode = options?.mode === "scoped" ? "scoped" : "full";
+ const rootUid = mode === "scoped" ? options?.rootUid?.trim() || undefined : undefined;
+ const maxDepth = mode === "scoped" && typeof options?.maxDepth === "number" && Number.isFinite(options.maxDepth) && options.maxDepth >= 0
+ ? Math.floor(options.maxDepth)
+ : undefined;
+ let scopedTaskUids = phaseTaskUids;
+ if (rootUid) {
+ const rootTask = phaseTasksOnly.find((task) => task.uid === rootUid);
+ if (!rootTask) {
+ throw new Error(`phase 配下に root_uid が見つかりません: ${rootUid}`);
+ }
+ scopedTaskUids = collectTaskSubtreeUids(phaseTasksOnly, rootUid, maxDepth);
+ }
+ const descendantTasks = phaseTasksOnly.filter((task) => scopedTaskUids.has(task.uid));
+ return {
+ view_type: "phase_detail_view",
+ project: {
+ name: model.project.name,
+ planned_start: model.project.startDate,
+ planned_finish: model.project.finishDate
+ },
+ phase: {
+ uid: phase.uid,
+ name: phase.name,
+ wbs: phase.wbs || phase.outlineNumber,
+ planned_start: phase.start,
+ planned_finish: phase.finish,
+ task_count: descendantTasks.length,
+ milestone_count: descendantTasks.filter((task) => task.milestone).length,
+ percent_complete: phase.percentComplete
+ },
+ scope: {
+ mode,
+ root_uid: rootUid || null,
+ max_depth: maxDepth ?? null
+ },
+ tasks: descendantTasks.map((task) => ({
+ uid: task.uid,
+ name: task.name,
+ parent_uid: parentMap.get(task.uid),
+ position: positionMap.get(task.uid) ?? 0,
+ is_summary: task.summary,
+ is_milestone: task.milestone,
+ planned_duration: task.duration,
+ planned_duration_hours: parseDurationHours(task.duration),
+ planned_start: task.start,
+ planned_finish: task.finish,
+ percent_complete: task.percentComplete,
+ predecessor_uids: task.predecessors.map((item) => item.predecessorUid)
+ })),
+ milestones: descendantTasks.filter((task) => task.milestone).map((task) => ({
+ uid: task.uid,
+ name: task.name,
+ date: task.finish || task.start
+ })),
+ dependency_summary: descendantTasks.flatMap((task) =>
+ task.predecessors
+ .filter((predecessor) => scopedTaskUids.has(predecessor.predecessorUid))
+ .map((predecessor) => ({
+ from_uid: predecessor.predecessorUid,
+ to_uid: task.uid,
+ type: formatDependencyType(predecessor.type),
+ lag: predecessor.linkLag || "PT0H0M0S",
+ lag_hours: parseDurationHours(predecessor.linkLag || "PT0H0M0S") ?? 0
+ }))
+ ),
+ rules: buildDefaultRules("phase_detail_view")
+ };
+ }
+
+ function buildTaskSectionMap(tasks: TaskModel[], projectName: string): Map {
+ const sectionMap = new Map();
+ const summaryStack: TaskModel[] = [];
+ for (const task of tasks) {
+ while (summaryStack.length > 0 && task.outlineLevel <= summaryStack[summaryStack.length - 1].outlineLevel) {
+ summaryStack.pop();
+ }
+ if (task.summary) {
+ summaryStack.push(task);
+ continue;
+ }
+ const sectionName = summaryStack.length > 0
+ ? normalizeMermaidGanttLabel(summaryStack[summaryStack.length - 1].name, "Summary", "Section")
+ : normalizeMermaidGanttLabel(projectName, "Tasks", "Section");
+ sectionMap.set(task.uid, sectionName);
+ }
+ return sectionMap;
+ }
+
+ function exportMermaidGantt(model: ProjectModel): string {
+ const lines: string[] = [
+ "gantt",
+ ` title ${normalizeMermaidGanttLabel(model.project.name, "Project", "Project")}`,
+ " dateFormat YYYY-MM-DDTHH:mm:ss",
+ " axisFormat %m/%d"
+ ];
+ const sectionMap = buildTaskSectionMap(model.tasks, model.project.name);
+ const taskNameMap = new Map(
+ model.tasks.map((task) => [
+ task.uid,
+ normalizeMermaidGanttLabel(task.name, `Task ${task.uid}`, "Task")
+ ])
+ );
+ const exportedTasks = model.tasks.filter((task) => !task.summary && task.start && task.finish);
+ let currentSection = "";
+ for (const task of exportedTasks) {
+ const section = sectionMap.get(task.uid) || "Tasks";
+ if (section !== currentSection) {
+ currentSection = section;
+ lines.push(` section ${section}`);
+ }
+ const tags: string[] = [];
+ if (task.critical) {
+ tags.push("crit");
+ }
+ if (task.milestone) {
+ tags.push("milestone");
+ } else if (task.percentComplete >= 100) {
+ tags.push("done");
+ } else if (task.percentComplete > 0) {
+ tags.push("active");
+ }
+ const taskId = `task_${normalizeMermaidTaskId(task.uid || task.id || "x", "x")}`;
+ const singlePredecessor = task.predecessors.length === 1 ? task.predecessors[0] : null;
+ const nativeDependencyTarget = singlePredecessor
+ ? `task_${normalizeMermaidTaskId(singlePredecessor.predecessorUid, "x")}`
+ : null;
+ const nativeDuration = !task.milestone ? toMermaidDuration(task.duration) : null;
+ const useNativeDependency = Boolean(
+ singlePredecessor
+ && nativeDependencyTarget
+ && nativeDuration
+ && isZeroDuration(singlePredecessor.linkLag)
+ && (singlePredecessor.type === undefined || singlePredecessor.type === 1)
+ );
+ const fields = useNativeDependency
+ ? [...tags, taskId, `after ${nativeDependencyTarget}`, nativeDuration]
+ : [...tags, taskId, task.start, task.finish].filter(Boolean);
+ lines.push(` ${normalizeMermaidGanttLabel(task.name, `Task ${task.uid}`, "Task")} :${fields.join(", ")}`);
+ for (const predecessor of task.predecessors) {
+ const predecessorTaskId = `task_${normalizeMermaidTaskId(predecessor.predecessorUid, "x")}`;
+ const predecessorName = taskNameMap.get(predecessor.predecessorUid) || `Task ${predecessor.predecessorUid}`;
+ if (useNativeDependency && predecessorTaskId === nativeDependencyTarget) {
+ lines.push(` %% dependency(native): ${task.name || taskId} after ${predecessorName} (${taskId} after ${predecessorTaskId})`);
+ continue;
+ }
+ const details = [
+ `type=${describePredecessorType(predecessor.type)}`,
+ !isZeroDuration(predecessor.linkLag) ? `lag=${formatMermaidLag(predecessor.linkLag)}` : ""
+ ].filter(Boolean).join(", ");
+ lines.push(` %% dependency: ${task.name || taskId} after ${predecessorName}${details ? ` (${details})` : ""} [${taskId} after ${predecessorTaskId}]`);
+ if (!isZeroDuration(predecessor.linkLag)) {
+ lines.push(` %% dependency(pseudo): ${task.name || taskId} ~= after ${predecessorName} + ${formatMermaidLag(predecessor.linkLag)}`);
+ }
+ }
+ if (task.predecessors.length > 1) {
+ lines.push(` %% dependency(note): ${task.name || taskId} has multiple predecessors`);
+ } else if (singlePredecessor && !useNativeDependency) {
+ const reasons = [
+ !isZeroDuration(singlePredecessor.linkLag) ? `lag=${formatMermaidLag(singlePredecessor.linkLag)}` : "",
+ singlePredecessor.type !== undefined && singlePredecessor.type !== 1 ? `type=${describePredecessorType(singlePredecessor.type)}` : "",
+ !nativeDuration && !task.milestone ? `duration=${task.duration || "(empty)"}` : ""
+ ].filter(Boolean).join(", ");
+ if (reasons) {
+ lines.push(` %% dependency(note): ${task.name || taskId} kept as comment because ${reasons}`);
+ }
+ }
+ }
+ if (exportedTasks.length === 0) {
+ lines.push(" section Tasks");
+ lines.push(" No tasks :milestone, empty_0, 1970-01-01T00:00:00, 1970-01-01T00:00:00");
+ }
+ return `${lines.join("\n")}\n`;
+ }
+
+ function buildTaskParentUidMap(tasks: TaskModel[]): Map {
+ const parentMap = new Map();
+ const stack: TaskModel[] = [];
+ for (const task of tasks) {
+ while (stack.length > 0 && task.outlineLevel <= stack[stack.length - 1].outlineLevel) {
+ stack.pop();
+ }
+ const parent = stack[stack.length - 1];
+ if (parent) {
+ parentMap.set(task.uid, parent.uid);
+ }
+ stack.push(task);
+ }
+ return parentMap;
+ }
+
+ function escapeCsvCell(value: string | number | undefined): string {
+ const text = String(value ?? "");
+ if (/[",\n]/.test(text)) {
+ return `"${text.replace(/"/g, "\"\"")}"`;
+ }
+ return text;
+ }
+
+ function exportCsvParentId(model: ProjectModel): string {
+ const header = ["ID", "ParentID", "WBS", "Name", "Start", "Finish", "PredecessorID", "Resource", "PercentComplete", "PercentWorkComplete", "Milestone", "Summary", "Critical", "Type", "Priority", "Work", "CalendarUID", "ConstraintType", "ConstraintDate", "Deadline", "Notes"];
+ const parentMap = buildTaskParentUidMap(model.tasks);
+ const resourceMap = new Map(model.resources.map((resource) => [resource.uid, resource.name]));
+ const assignmentMap = new Map();
+ for (const assignment of model.assignments) {
+ const resourceName = resourceMap.get(assignment.resourceUid);
+ if (!resourceName) {
+ continue;
+ }
+ const names = assignmentMap.get(assignment.taskUid) || [];
+ if (!names.includes(resourceName)) {
+ names.push(resourceName);
+ }
+ assignmentMap.set(assignment.taskUid, names);
+ }
+ const rows = model.tasks.map((task) => [
+ task.uid,
+ parentMap.get(task.uid) || "",
+ task.wbs || task.outlineNumber || "",
+ task.name,
+ task.start || "",
+ task.finish || "",
+ task.predecessors.map((item) => item.predecessorUid).join("|"),
+ (assignmentMap.get(task.uid) || []).join("|"),
+ task.percentComplete,
+ task.percentWorkComplete ?? "",
+ task.milestone ? 1 : 0,
+ task.summary ? 1 : 0,
+ task.critical === undefined ? "" : (task.critical ? 1 : 0),
+ task.type ?? "",
+ task.priority ?? "",
+ task.work || "",
+ task.calendarUID || "",
+ task.constraintType ?? "",
+ task.constraintDate || "",
+ task.deadline || "",
+ task.notes || ""
+ ]);
+ return [header, ...rows].map((row) => row.map((cell) => escapeCsvCell(cell)).join(",")).join("\n") + "\n";
+ }
+
+ function parseCsvRows(csvText: string): string[][] {
+ const rows: string[][] = [];
+ let row: string[] = [];
+ let cell = "";
+ let inQuotes = false;
+ for (let index = 0; index < csvText.length; index += 1) {
+ const char = csvText[index];
+ const next = csvText[index + 1];
+ if (char === "\"") {
+ if (inQuotes && next === "\"") {
+ cell += "\"";
+ index += 1;
+ } else {
+ inQuotes = !inQuotes;
+ }
+ continue;
+ }
+ if (!inQuotes && char === ",") {
+ row.push(cell);
+ cell = "";
+ continue;
+ }
+ if (!inQuotes && (char === "\n" || char === "\r")) {
+ if (char === "\r" && next === "\n") {
+ index += 1;
+ }
+ row.push(cell);
+ rows.push(row);
+ row = [];
+ cell = "";
+ continue;
+ }
+ cell += char;
+ }
+ if (cell.length > 0 || row.length > 0) {
+ row.push(cell);
+ rows.push(row);
+ }
+ return rows.filter((item) => item.some((cellValue) => String(cellValue).trim() !== ""));
+ }
+
+ function parseCsvMultiValueCell(value: string): string[] {
+ const normalized = String(value || "").trim();
+ if (!normalized) {
+ return [];
+ }
+ const items = normalized
+ .split(/[|;,、]/)
+ .map((item) => item.trim())
+ .filter(Boolean);
+ return Array.from(new Set(items));
+ }
+
+ function parseCsvBooleanCell(value: string, fallback: boolean): boolean {
+ const normalized = String(value || "").trim().toLowerCase();
+ if (!normalized) {
+ return fallback;
+ }
+ if (["1", "true", "yes", "y", "on"].includes(normalized)) {
+ return true;
+ }
+ if (["0", "false", "no", "n", "off"].includes(normalized)) {
+ return false;
+ }
+ return fallback;
+ }
+
+ function importCsvParentId(csvText: string): ProjectModel {
+ const rows = parseCsvRows(csvText.trim());
+ if (rows.length === 0) {
+ throw new Error("CSV が空です");
+ }
+ const header = rows[0].map((item) => item.trim());
+ const requiredColumns = ["ID", "ParentID", "Name"];
+ for (const requiredColumn of requiredColumns) {
+ if (!header.includes(requiredColumn)) {
+ throw new Error(`CSV に必須列がありません: ${requiredColumn}`);
+ }
+ }
+ const columnIndex = (name: string): number => header.indexOf(name);
+ type CsvTaskRow = {
+ id: string;
+ parentId: string;
+ wbs: string;
+ name: string;
+ start: string;
+ finish: string;
+ predecessorId: string;
+ resource: string;
+ percentComplete: number;
+ percentWorkComplete: number | undefined;
+ milestone: boolean;
+ summary: boolean | undefined;
+ critical: boolean | undefined;
+ type: number | undefined;
+ priority: number | undefined;
+ work: string;
+ calendarUID: string;
+ constraintType: number | undefined;
+ constraintDate: string;
+ deadline: string;
+ notes: string;
+ children: CsvTaskRow[];
+ };
+ const entries = rows.slice(1).map((row) => ({
+ id: String(row[columnIndex("ID")] || "").trim(),
+ parentId: String(row[columnIndex("ParentID")] || "").trim(),
+ wbs: String((columnIndex("WBS") >= 0 ? row[columnIndex("WBS")] : "") || "").trim(),
+ name: String(row[columnIndex("Name")] || "").trim(),
+ start: String((columnIndex("Start") >= 0 ? row[columnIndex("Start")] : "") || "").trim(),
+ finish: String((columnIndex("Finish") >= 0 ? row[columnIndex("Finish")] : "") || "").trim(),
+ predecessorId: String((columnIndex("PredecessorID") >= 0 ? row[columnIndex("PredecessorID")] : "") || "").trim(),
+ resource: String((columnIndex("Resource") >= 0 ? row[columnIndex("Resource")] : "") || "").trim(),
+ percentComplete: parseNumber(String((columnIndex("PercentComplete") >= 0 ? row[columnIndex("PercentComplete")] : "0") || "0").trim(), 0),
+ percentWorkComplete: columnIndex("PercentWorkComplete") >= 0 && String(row[columnIndex("PercentWorkComplete")] || "").trim()
+ ? parseNumber(String(row[columnIndex("PercentWorkComplete")] || "").trim(), 0)
+ : undefined,
+ milestone: parseCsvBooleanCell(String((columnIndex("Milestone") >= 0 ? row[columnIndex("Milestone")] : "") || "").trim(), false),
+ summary: columnIndex("Summary") >= 0 && String(row[columnIndex("Summary")] || "").trim()
+ ? parseCsvBooleanCell(String(row[columnIndex("Summary")] || "").trim(), false)
+ : undefined,
+ critical: columnIndex("Critical") >= 0 && String(row[columnIndex("Critical")] || "").trim()
+ ? parseCsvBooleanCell(String(row[columnIndex("Critical")] || "").trim(), false)
+ : undefined,
+ type: columnIndex("Type") >= 0 && String(row[columnIndex("Type")] || "").trim()
+ ? parseNumber(String(row[columnIndex("Type")] || "").trim(), 0)
+ : undefined,
+ priority: columnIndex("Priority") >= 0 && String(row[columnIndex("Priority")] || "").trim()
+ ? parseNumber(String(row[columnIndex("Priority")] || "").trim(), 0)
+ : undefined,
+ work: String((columnIndex("Work") >= 0 ? row[columnIndex("Work")] : "") || "").trim(),
+ calendarUID: String((columnIndex("CalendarUID") >= 0 ? row[columnIndex("CalendarUID")] : "") || "").trim(),
+ constraintType: columnIndex("ConstraintType") >= 0 && String(row[columnIndex("ConstraintType")] || "").trim()
+ ? parseNumber(String(row[columnIndex("ConstraintType")] || "").trim(), 0)
+ : undefined,
+ constraintDate: String((columnIndex("ConstraintDate") >= 0 ? row[columnIndex("ConstraintDate")] : "") || "").trim(),
+ deadline: String((columnIndex("Deadline") >= 0 ? row[columnIndex("Deadline")] : "") || "").trim(),
+ notes: String((columnIndex("Notes") >= 0 ? row[columnIndex("Notes")] : "") || "").trim(),
+ children: [] as CsvTaskRow[]
+ })).filter((entry) => entry.id);
+ const seenIds = new Set();
+ for (const entry of entries) {
+ if (seenIds.has(entry.id)) {
+ throw new Error(`CSV の ID が重複しています: ${entry.id}`);
+ }
+ seenIds.add(entry.id);
+ if (!entry.name) {
+ throw new Error(`CSV の Name が空です: ID=${entry.id}`);
+ }
+ if (entry.parentId && entry.parentId === entry.id) {
+ throw new Error(`CSV の ParentID が自身を指しています: ID=${entry.id}`);
+ }
+ }
+ const entryMap = new Map(entries.map((entry) => [entry.id, entry]));
+ for (const entry of entries) {
+ if (entry.parentId && !entryMap.has(entry.parentId)) {
+ throw new Error(`CSV の ParentID が既存 ID を指していません: ID=${entry.id}, ParentID=${entry.parentId}`);
+ }
+ }
+ const visiting = new Set();
+ const visited = new Set();
+ function checkCycle(entry: CsvTaskRow): void {
+ if (visited.has(entry.id)) {
+ return;
+ }
+ if (visiting.has(entry.id)) {
+ throw new Error(`CSV の ParentID が循環しています: ID=${entry.id}`);
+ }
+ visiting.add(entry.id);
+ if (entry.parentId) {
+ const parent = entryMap.get(entry.parentId);
+ if (parent) {
+ checkCycle(parent);
+ }
+ }
+ visiting.delete(entry.id);
+ visited.add(entry.id);
+ }
+ entries.forEach((entry) => checkCycle(entry));
+ const roots: CsvTaskRow[] = [];
+ for (const entry of entries) {
+ const parent = entry.parentId ? entryMap.get(entry.parentId) : undefined;
+ if (parent) {
+ parent.children.push(entry);
+ } else {
+ roots.push(entry);
+ }
+ }
+
+ const tasks: TaskModel[] = [];
+ function walk(entry: CsvTaskRow, outlinePath: number[]): void {
+ const children = entry.children;
+ let start = entry.start;
+ let finish = entry.finish;
+ if ((!start || !finish) && children.length > 0) {
+ const childStarts = children.map((child) => child.start).filter(Boolean).sort();
+ const childFinishes = children.map((child) => child.finish).filter(Boolean).sort();
+ start = start || childStarts[0] || "";
+ finish = finish || childFinishes.at(-1) || "";
+ }
+ const outlineNumber = outlinePath.join(".");
+ tasks.push({
+ uid: entry.id,
+ id: entry.id,
+ name: entry.name,
+ outlineLevel: outlinePath.length,
+ outlineNumber,
+ wbs: entry.wbs || outlineNumber,
+ type: entry.type,
+ priority: entry.priority,
+ work: entry.work || undefined,
+ calendarUID: entry.calendarUID || undefined,
+ start,
+ finish,
+ duration: "PT0H0M0S",
+ milestone: entry.milestone || Boolean(start && finish && start === finish),
+ summary: entry.summary ?? (children.length > 0),
+ critical: entry.critical,
+ percentComplete: Math.max(0, Math.min(100, entry.percentComplete)),
+ percentWorkComplete: entry.percentWorkComplete !== undefined ? Math.max(0, Math.min(100, entry.percentWorkComplete)) : undefined,
+ constraintType: entry.constraintType,
+ constraintDate: entry.constraintDate || undefined,
+ deadline: entry.deadline || undefined,
+ notes: entry.notes || undefined,
+ predecessors: parseCsvMultiValueCell(entry.predecessorId).map((item) => ({ predecessorUid: item })),
+ extendedAttributes: [],
+ baselines: [],
+ timephasedData: []
+ });
+ children.forEach((child, index) => walk(child, [...outlinePath, index + 1]));
+ }
+ roots.forEach((root, index) => walk(root, [index + 1]));
+
+ const resourceNames = Array.from(new Set(entries.flatMap((entry) => parseCsvMultiValueCell(entry.resource))));
+ const resources: ResourceModel[] = resourceNames.map((name, index) => ({
+ uid: String(index + 1),
+ id: String(index + 1),
+ name,
+ extendedAttributes: [],
+ baselines: [],
+ timephasedData: []
+ }));
+ const resourceUidByName = new Map(resources.map((resource) => [resource.name, resource.uid]));
+ let assignmentUid = 1;
+ const taskByUid = new Map(tasks.map((task) => [task.uid, task]));
+ const assignments: AssignmentModel[] = entries.flatMap((entry) => {
+ const task = taskByUid.get(entry.id);
+ if (!task) {
+ return [];
+ }
+ return parseCsvMultiValueCell(entry.resource).map((name) => ({
+ uid: String(assignmentUid++),
+ taskUid: entry.id,
+ resourceUid: resourceUidByName.get(name) || "",
+ start: task.start || undefined,
+ finish: task.finish || undefined,
+ percentWorkComplete: task.percentComplete,
+ extendedAttributes: [],
+ baselines: [],
+ timephasedData: []
+ }));
+ });
+
+ const taskStarts = tasks.map((task) => task.start).filter(Boolean).sort();
+ const taskFinishes = tasks.map((task) => task.finish).filter(Boolean).sort();
+ return normalizeProjectModel(ensureDefaultProjectCalendar({
+ project: {
+ name: "CSV Imported Project",
+ title: "CSV Imported Project",
+ startDate: taskStarts[0] || "",
+ finishDate: taskFinishes.at(-1) || "",
+ scheduleFromStart: true,
+ outlineCodes: [],
+ wbsMasks: [],
+ extendedAttributes: []
+ },
+ tasks,
+ resources,
+ assignments,
+ calendars: []
+ }));
+ }
+
+ function importMsProjectXml(xmlText: string): ProjectModel {
+ const xml = parseXmlDocument(xmlText);
+ const projectElement = xml.documentElement;
+ const calendars = Array.from(projectElement.getElementsByTagName("Calendars")[0]?.getElementsByTagName("Calendar") || []);
+ const tasks = Array.from(projectElement.getElementsByTagName("Tasks")[0]?.getElementsByTagName("Task") || []);
+ const resources = Array.from(projectElement.getElementsByTagName("Resources")[0]?.getElementsByTagName("Resource") || []);
+ const assignments = Array.from(projectElement.getElementsByTagName("Assignments")[0]?.getElementsByTagName("Assignment") || []);
+
+ return normalizeProjectModel(ensureDefaultProjectCalendar({
+ project: {
+ name: textContent(projectElement, "Name"),
+ title: textContent(projectElement, "Title") || undefined,
+ author: textContent(projectElement, "Author") || undefined,
+ company: textContent(projectElement, "Company") || undefined,
+ creationDate: textContent(projectElement, "CreationDate") || undefined,
+ lastSaved: textContent(projectElement, "LastSaved") || undefined,
+ saveVersion: textContent(projectElement, "SaveVersion") ? parseNumber(textContent(projectElement, "SaveVersion"), 0) : undefined,
+ currentDate: textContent(projectElement, "CurrentDate") || undefined,
+ startDate: textContent(projectElement, "StartDate"),
+ finishDate: textContent(projectElement, "FinishDate"),
+ scheduleFromStart: parseBoolean(textContent(projectElement, "ScheduleFromStart")),
+ defaultStartTime: textContent(projectElement, "DefaultStartTime") || undefined,
+ defaultFinishTime: textContent(projectElement, "DefaultFinishTime") || undefined,
+ minutesPerDay: textContent(projectElement, "MinutesPerDay") ? parseNumber(textContent(projectElement, "MinutesPerDay"), 0) : undefined,
+ minutesPerWeek: textContent(projectElement, "MinutesPerWeek") ? parseNumber(textContent(projectElement, "MinutesPerWeek"), 0) : undefined,
+ daysPerMonth: textContent(projectElement, "DaysPerMonth") ? parseNumber(textContent(projectElement, "DaysPerMonth"), 0) : undefined,
+ statusDate: textContent(projectElement, "StatusDate") || undefined,
+ weekStartDay: textContent(projectElement, "WeekStartDay") ? parseNumber(textContent(projectElement, "WeekStartDay"), 0) : undefined,
+ workFormat: textContent(projectElement, "WorkFormat") ? parseNumber(textContent(projectElement, "WorkFormat"), 0) : undefined,
+ durationFormat: textContent(projectElement, "DurationFormat") ? parseNumber(textContent(projectElement, "DurationFormat"), 0) : undefined,
+ currencyCode: textContent(projectElement, "CurrencyCode") || undefined,
+ currencyDigits: textContent(projectElement, "CurrencyDigits") ? parseNumber(textContent(projectElement, "CurrencyDigits"), 0) : undefined,
+ currencySymbol: textContent(projectElement, "CurrencySymbol") || undefined,
+ currencySymbolPosition: textContent(projectElement, "CurrencySymbolPosition") ? parseNumber(textContent(projectElement, "CurrencySymbolPosition"), 0) : undefined,
+ fyStartDate: textContent(projectElement, "FYStartDate") || undefined,
+ fiscalYearStart: textContent(projectElement, "FiscalYearStart") ? parseBoolean(textContent(projectElement, "FiscalYearStart")) : undefined,
+ criticalSlackLimit: textContent(projectElement, "CriticalSlackLimit") ? parseNumber(textContent(projectElement, "CriticalSlackLimit"), 0) : undefined,
+ defaultTaskType: textContent(projectElement, "DefaultTaskType") ? parseNumber(textContent(projectElement, "DefaultTaskType"), 0) : undefined,
+ defaultFixedCostAccrual: textContent(projectElement, "DefaultFixedCostAccrual") ? parseNumber(textContent(projectElement, "DefaultFixedCostAccrual"), 0) : undefined,
+ defaultStandardRate: textContent(projectElement, "DefaultStandardRate") || undefined,
+ defaultOvertimeRate: textContent(projectElement, "DefaultOvertimeRate") || undefined,
+ defaultTaskEVMethod: textContent(projectElement, "DefaultTaskEVMethod") ? parseNumber(textContent(projectElement, "DefaultTaskEVMethod"), 0) : undefined,
+ newTaskStartDate: textContent(projectElement, "NewTaskStartDate") ? parseNumber(textContent(projectElement, "NewTaskStartDate"), 0) : undefined,
+ newTasksAreManual: textContent(projectElement, "NewTasksAreManual") ? parseBoolean(textContent(projectElement, "NewTasksAreManual")) : undefined,
+ newTasksEffortDriven: textContent(projectElement, "NewTasksEffortDriven") ? parseBoolean(textContent(projectElement, "NewTasksEffortDriven")) : undefined,
+ newTasksEstimated: textContent(projectElement, "NewTasksEstimated") ? parseBoolean(textContent(projectElement, "NewTasksEstimated")) : undefined,
+ actualsInSync: textContent(projectElement, "ActualsInSync") ? parseBoolean(textContent(projectElement, "ActualsInSync")) : undefined,
+ editableActualCosts: textContent(projectElement, "EditableActualCosts") ? parseBoolean(textContent(projectElement, "EditableActualCosts")) : undefined,
+ honorConstraints: textContent(projectElement, "HonorConstraints") ? parseBoolean(textContent(projectElement, "HonorConstraints")) : undefined,
+ insertedProjectsLikeSummary: textContent(projectElement, "InsertedProjectsLikeSummary") ? parseBoolean(textContent(projectElement, "InsertedProjectsLikeSummary")) : undefined,
+ multipleCriticalPaths: textContent(projectElement, "MultipleCriticalPaths") ? parseBoolean(textContent(projectElement, "MultipleCriticalPaths")) : undefined,
+ taskUpdatesResource: textContent(projectElement, "TaskUpdatesResource") ? parseBoolean(textContent(projectElement, "TaskUpdatesResource")) : undefined,
+ updateManuallyScheduledTasksWhenEditingLinks: textContent(projectElement, "UpdateManuallyScheduledTasksWhenEditingLinks") ? parseBoolean(textContent(projectElement, "UpdateManuallyScheduledTasksWhenEditingLinks")) : undefined,
+ calendarUID: textContent(projectElement, "CalendarUID") || undefined,
+ outlineCodes: Array.from(projectElement.getElementsByTagName("OutlineCodes")[0]?.getElementsByTagName("OutlineCode") || []).map((outlineCode) => ({
+ fieldID: textContent(outlineCode, "FieldID") || undefined,
+ fieldName: textContent(outlineCode, "FieldName") || undefined,
+ alias: textContent(outlineCode, "Alias") || undefined,
+ onlyTableValues: textContent(outlineCode, "OnlyTableValues") ? parseBoolean(textContent(outlineCode, "OnlyTableValues")) : undefined,
+ enterprise: textContent(outlineCode, "Enterprise") ? parseBoolean(textContent(outlineCode, "Enterprise")) : undefined,
+ resourceSubstitutionEnabled: textContent(outlineCode, "ResourceSubstitutionEnabled") ? parseBoolean(textContent(outlineCode, "ResourceSubstitutionEnabled")) : undefined,
+ leafOnly: textContent(outlineCode, "LeafOnly") ? parseBoolean(textContent(outlineCode, "LeafOnly")) : undefined,
+ allLevelsRequired: textContent(outlineCode, "AllLevelsRequired") ? parseBoolean(textContent(outlineCode, "AllLevelsRequired")) : undefined,
+ masks: parseOutlineCodeMasks(outlineCode),
+ values: parseOutlineCodeValues(outlineCode)
+ })),
+ wbsMasks: Array.from(projectElement.getElementsByTagName("WBSMasks")[0]?.getElementsByTagName("WBSMask") || []).map((wbsMask) => ({
+ level: parseNumber(textContent(wbsMask, "Level"), 0),
+ mask: textContent(wbsMask, "Mask") || undefined,
+ length: textContent(wbsMask, "Length") ? parseNumber(textContent(wbsMask, "Length"), 0) : undefined,
+ sequence: textContent(wbsMask, "Sequence") ? parseNumber(textContent(wbsMask, "Sequence"), 0) : undefined
+ })),
+ extendedAttributes: Array.from(projectElement.getElementsByTagName("ExtendedAttributes")[0]?.getElementsByTagName("ExtendedAttribute") || []).map((attribute) => ({
+ fieldID: textContent(attribute, "FieldID") || undefined,
+ fieldName: textContent(attribute, "FieldName") || undefined,
+ alias: textContent(attribute, "Alias") || undefined,
+ calculationType: textContent(attribute, "CalculationType") ? parseNumber(textContent(attribute, "CalculationType"), 0) : undefined,
+ restrictValues: textContent(attribute, "RestrictValues") ? parseBoolean(textContent(attribute, "RestrictValues")) : undefined,
+ appendNewValues: textContent(attribute, "AppendNewValues") ? parseBoolean(textContent(attribute, "AppendNewValues")) : undefined
+ }))
+ },
+ calendars: calendars.map((calendar) => ({
+ uid: textContent(calendar, "UID"),
+ name: textContent(calendar, "Name"),
+ isBaseCalendar: parseBoolean(textContent(calendar, "IsBaseCalendar")),
+ isBaselineCalendar: textContent(calendar, "IsBaselineCalendar") ? parseBoolean(textContent(calendar, "IsBaselineCalendar")) : undefined,
+ baseCalendarUID: textContent(calendar, "BaseCalendarUID") || undefined,
+ weekDays: parseWeekDays(calendar),
+ exceptions: Array.from(calendar.getElementsByTagName("Exceptions")[0]?.getElementsByTagName("Exception") || []).map((exception) => ({
+ name: textContent(exception, "Name") || undefined,
+ fromDate: textContent(exception, "FromDate") || undefined,
+ toDate: textContent(exception, "ToDate") || undefined,
+ dayWorking: textContent(exception, "DayWorking") ? parseBoolean(textContent(exception, "DayWorking")) : undefined,
+ workingTimes: parseWorkingTimes(exception)
+ })),
+ workWeeks: Array.from(calendar.getElementsByTagName("WorkWeeks")[0]?.getElementsByTagName("WorkWeek") || []).map((workWeek) => ({
+ name: textContent(workWeek, "Name") || undefined,
+ fromDate: textContent(workWeek, "FromDate") || undefined,
+ toDate: textContent(workWeek, "ToDate") || undefined,
+ weekDays: parseWeekDays(workWeek)
+ }))
+ })),
+ tasks: tasks.map((task) => ({
+ uid: textContent(task, "UID"),
+ id: textContent(task, "ID"),
+ name: textContent(task, "Name"),
+ outlineLevel: parseNumber(textContent(task, "OutlineLevel"), 1),
+ outlineNumber: textContent(task, "OutlineNumber"),
+ wbs: textContent(task, "WBS") || undefined,
+ type: textContent(task, "Type") ? parseNumber(textContent(task, "Type"), 0) : undefined,
+ calendarUID: textContent(task, "CalendarUID") || undefined,
+ priority: textContent(task, "Priority") ? parseNumber(textContent(task, "Priority"), 0) : undefined,
+ start: textContent(task, "Start"),
+ finish: textContent(task, "Finish"),
+ duration: textContent(task, "Duration"),
+ actualStart: textContent(task, "ActualStart") || undefined,
+ actualFinish: textContent(task, "ActualFinish") || undefined,
+ deadline: textContent(task, "Deadline") || undefined,
+ startVariance: textContent(task, "StartVariance") || undefined,
+ finishVariance: textContent(task, "FinishVariance") || undefined,
+ work: textContent(task, "Work") || undefined,
+ workVariance: textContent(task, "WorkVariance") || undefined,
+ totalSlack: textContent(task, "TotalSlack") || undefined,
+ freeSlack: textContent(task, "FreeSlack") || undefined,
+ cost: textContent(task, "Cost") ? parseNumber(textContent(task, "Cost"), 0) : undefined,
+ actualCost: textContent(task, "ActualCost") ? parseNumber(textContent(task, "ActualCost"), 0) : undefined,
+ remainingCost: textContent(task, "RemainingCost") ? parseNumber(textContent(task, "RemainingCost"), 0) : undefined,
+ remainingWork: textContent(task, "RemainingWork") || undefined,
+ actualWork: textContent(task, "ActualWork") || undefined,
+ milestone: parseBoolean(textContent(task, "Milestone")),
+ summary: parseBoolean(textContent(task, "Summary")),
+ critical: textContent(task, "Critical") ? parseBoolean(textContent(task, "Critical")) : undefined,
+ percentComplete: parseNumber(textContent(task, "PercentComplete"), 0),
+ percentWorkComplete: textContent(task, "PercentWorkComplete") ? parseNumber(textContent(task, "PercentWorkComplete"), 0) : undefined,
+ notes: textContent(task, "Notes") || undefined,
+ constraintType: textContent(task, "ConstraintType") ? parseNumber(textContent(task, "ConstraintType"), 0) : undefined,
+ constraintDate: textContent(task, "ConstraintDate") || undefined,
+ extendedAttributes: Array.from(task.getElementsByTagName("ExtendedAttribute")).map((attribute) => ({
+ fieldID: textContent(attribute, "FieldID") || undefined,
+ value: textContent(attribute, "Value") || undefined
+ })),
+ baselines: Array.from(task.getElementsByTagName("Baseline")).map((baseline) => ({
+ number: textContent(baseline, "Number") ? parseNumber(textContent(baseline, "Number"), 0) : undefined,
+ start: textContent(baseline, "Start") || undefined,
+ finish: textContent(baseline, "Finish") || undefined,
+ work: textContent(baseline, "Work") || undefined,
+ cost: textContent(baseline, "Cost") ? parseNumber(textContent(baseline, "Cost"), 0) : undefined
+ })),
+ timephasedData: Array.from(task.getElementsByTagName("TimephasedData")).map((timephasedData) => ({
+ type: textContent(timephasedData, "Type") ? parseNumber(textContent(timephasedData, "Type"), 0) : undefined,
+ uid: textContent(timephasedData, "UID") || undefined,
+ start: textContent(timephasedData, "Start") || undefined,
+ finish: textContent(timephasedData, "Finish") || undefined,
+ unit: textContent(timephasedData, "Unit") ? parseNumber(textContent(timephasedData, "Unit"), 0) : undefined,
+ value: textContent(timephasedData, "Value") || undefined
+ })),
+ predecessors: Array.from(task.getElementsByTagName("PredecessorLink")).map((link) => ({
+ predecessorUid: textContent(link, "PredecessorUID"),
+ type: parseNumber(textContent(link, "Type"), 0),
+ linkLag: textContent(link, "LinkLag") || undefined
+ }))
+ })),
+ resources: resources.map((resource) => ({
+ uid: textContent(resource, "UID"),
+ id: textContent(resource, "ID"),
+ name: textContent(resource, "Name"),
+ type: parseNumber(textContent(resource, "Type"), 0),
+ initials: textContent(resource, "Initials") || undefined,
+ group: textContent(resource, "Group") || undefined,
+ workGroup: textContent(resource, "WorkGroup") ? parseNumber(textContent(resource, "WorkGroup"), 0) : undefined,
+ maxUnits: textContent(resource, "MaxUnits") ? parseNumber(textContent(resource, "MaxUnits"), 0) : undefined,
+ calendarUID: textContent(resource, "CalendarUID") || undefined,
+ standardRate: textContent(resource, "StandardRate") || undefined,
+ standardRateFormat: textContent(resource, "StandardRateFormat") ? parseNumber(textContent(resource, "StandardRateFormat"), 0) : undefined,
+ overtimeRate: textContent(resource, "OvertimeRate") || undefined,
+ overtimeRateFormat: textContent(resource, "OvertimeRateFormat") ? parseNumber(textContent(resource, "OvertimeRateFormat"), 0) : undefined,
+ costPerUse: textContent(resource, "CostPerUse") ? parseNumber(textContent(resource, "CostPerUse"), 0) : undefined,
+ work: textContent(resource, "Work") || undefined,
+ actualWork: textContent(resource, "ActualWork") || undefined,
+ remainingWork: textContent(resource, "RemainingWork") || undefined,
+ cost: textContent(resource, "Cost") ? parseNumber(textContent(resource, "Cost"), 0) : undefined,
+ actualCost: textContent(resource, "ActualCost") ? parseNumber(textContent(resource, "ActualCost"), 0) : undefined,
+ remainingCost: textContent(resource, "RemainingCost") ? parseNumber(textContent(resource, "RemainingCost"), 0) : undefined,
+ percentWorkComplete: textContent(resource, "PercentWorkComplete") ? parseNumber(textContent(resource, "PercentWorkComplete"), 0) : undefined,
+ extendedAttributes: Array.from(resource.getElementsByTagName("ExtendedAttribute")).map((attribute) => ({
+ fieldID: textContent(attribute, "FieldID") || undefined,
+ value: textContent(attribute, "Value") || undefined
+ })),
+ baselines: Array.from(resource.getElementsByTagName("Baseline")).map((baseline) => ({
+ number: textContent(baseline, "Number") ? parseNumber(textContent(baseline, "Number"), 0) : undefined,
+ start: textContent(baseline, "Start") || undefined,
+ finish: textContent(baseline, "Finish") || undefined,
+ work: textContent(baseline, "Work") || undefined,
+ cost: textContent(baseline, "Cost") ? parseNumber(textContent(baseline, "Cost"), 0) : undefined
+ })),
+ timephasedData: Array.from(resource.getElementsByTagName("TimephasedData")).map((timephasedData) => ({
+ type: textContent(timephasedData, "Type") ? parseNumber(textContent(timephasedData, "Type"), 0) : undefined,
+ uid: textContent(timephasedData, "UID") || undefined,
+ start: textContent(timephasedData, "Start") || undefined,
+ finish: textContent(timephasedData, "Finish") || undefined,
+ unit: textContent(timephasedData, "Unit") ? parseNumber(textContent(timephasedData, "Unit"), 0) : undefined,
+ value: textContent(timephasedData, "Value") || undefined
+ }))
+ })),
+ assignments: assignments.map((assignment) => ({
+ uid: textContent(assignment, "UID"),
+ taskUid: textContent(assignment, "TaskUID"),
+ resourceUid: textContent(assignment, "ResourceUID"),
+ start: textContent(assignment, "Start") || undefined,
+ finish: textContent(assignment, "Finish") || undefined,
+ startVariance: textContent(assignment, "StartVariance") || undefined,
+ finishVariance: textContent(assignment, "FinishVariance") || undefined,
+ delay: textContent(assignment, "Delay") || undefined,
+ milestone: textContent(assignment, "Milestone") ? parseBoolean(textContent(assignment, "Milestone")) : undefined,
+ workContour: textContent(assignment, "WorkContour") ? parseNumber(textContent(assignment, "WorkContour"), 0) : undefined,
+ units: parseNumber(textContent(assignment, "Units"), 0),
+ work: textContent(assignment, "Work") || undefined,
+ cost: textContent(assignment, "Cost") ? parseNumber(textContent(assignment, "Cost"), 0) : undefined,
+ actualCost: textContent(assignment, "ActualCost") ? parseNumber(textContent(assignment, "ActualCost"), 0) : undefined,
+ remainingCost: textContent(assignment, "RemainingCost") ? parseNumber(textContent(assignment, "RemainingCost"), 0) : undefined,
+ percentWorkComplete: textContent(assignment, "PercentWorkComplete") ? parseNumber(textContent(assignment, "PercentWorkComplete"), 0) : undefined,
+ overtimeWork: textContent(assignment, "OvertimeWork") || undefined,
+ actualOvertimeWork: textContent(assignment, "ActualOvertimeWork") || undefined,
+ actualWork: textContent(assignment, "ActualWork") || undefined,
+ remainingWork: textContent(assignment, "RemainingWork") || undefined,
+ extendedAttributes: Array.from(assignment.getElementsByTagName("ExtendedAttribute")).map((attribute) => ({
+ fieldID: textContent(attribute, "FieldID") || undefined,
+ value: textContent(attribute, "Value") || undefined
+ })),
+ baselines: Array.from(assignment.getElementsByTagName("Baseline")).map((baseline) => ({
+ number: textContent(baseline, "Number") ? parseNumber(textContent(baseline, "Number"), 0) : undefined,
+ start: textContent(baseline, "Start") || undefined,
+ finish: textContent(baseline, "Finish") || undefined,
+ work: textContent(baseline, "Work") || undefined,
+ cost: textContent(baseline, "Cost") ? parseNumber(textContent(baseline, "Cost"), 0) : undefined
+ })),
+ timephasedData: Array.from(assignment.getElementsByTagName("TimephasedData")).map((timephasedData) => ({
+ type: textContent(timephasedData, "Type") ? parseNumber(textContent(timephasedData, "Type"), 0) : undefined,
+ uid: textContent(timephasedData, "UID") || undefined,
+ start: textContent(timephasedData, "Start") || undefined,
+ finish: textContent(timephasedData, "Finish") || undefined,
+ unit: textContent(timephasedData, "Unit") ? parseNumber(textContent(timephasedData, "Unit"), 0) : undefined,
+ value: textContent(timephasedData, "Value") || undefined
+ }))
+ }))
+ }));
+ }
+
+ function appendTextElement(doc: XMLDocument, parent: Element, name: string, value: string | number | boolean | undefined): void {
+ if (value === undefined || value === "") {
+ return;
+ }
+ const element = doc.createElement(name);
+ if (typeof value === "boolean") {
+ element.textContent = value ? "1" : "0";
+ } else {
+ element.textContent = String(value);
+ }
+ parent.appendChild(element);
+ }
+
+ function formatXml(xml: string): string {
+ const normalized = xml.replace(/>\s*<").trim();
+ const tokens = normalized.replace(/>\n<").split("\n");
+ let indentLevel = 0;
+ const formatted: string[] = [];
+
+ for (const rawToken of tokens) {
+ const token = rawToken.trim();
+ if (!token) {
+ continue;
+ }
+
+ if (/^<\//.test(token)) {
+ indentLevel = Math.max(indentLevel - 1, 0);
+ }
+
+ formatted.push(`${" ".repeat(indentLevel)}${token}`);
+
+ if (/^<[^!?/][^>]*[^/]>\s*$/.test(token) && !/<\/[^>]+>$/.test(token)) {
+ indentLevel += 1;
+ }
+ }
+
+ return formatted.join("\n");
+ }
+
+ function exportMsProjectXml(model: ProjectModel): string {
+ const normalizedModel = ensureDefaultProjectCalendar(normalizeProjectModel(model));
+ const doc = document.implementation.createDocument("", "Project", null);
+ const project = doc.documentElement;
+ project.setAttribute("xmlns", "http://schemas.microsoft.com/project");
+
+ appendTextElement(doc, project, "Name", normalizedModel.project.name);
+ appendTextElement(doc, project, "Title", normalizedModel.project.title);
+ appendTextElement(doc, project, "Company", normalizedModel.project.company);
+ appendTextElement(doc, project, "Author", normalizedModel.project.author);
+ appendTextElement(doc, project, "CreationDate", normalizedModel.project.creationDate);
+ appendTextElement(doc, project, "LastSaved", normalizedModel.project.lastSaved);
+ appendTextElement(doc, project, "SaveVersion", normalizedModel.project.saveVersion);
+ appendTextElement(doc, project, "CurrentDate", normalizedModel.project.currentDate);
+ appendTextElement(doc, project, "StartDate", normalizedModel.project.startDate);
+ appendTextElement(doc, project, "FinishDate", normalizedModel.project.finishDate);
+ appendTextElement(doc, project, "ScheduleFromStart", normalizedModel.project.scheduleFromStart);
+ appendTextElement(doc, project, "DefaultStartTime", normalizedModel.project.defaultStartTime);
+ appendTextElement(doc, project, "DefaultFinishTime", normalizedModel.project.defaultFinishTime);
+ appendTextElement(doc, project, "MinutesPerDay", normalizedModel.project.minutesPerDay);
+ appendTextElement(doc, project, "MinutesPerWeek", normalizedModel.project.minutesPerWeek);
+ appendTextElement(doc, project, "DaysPerMonth", normalizedModel.project.daysPerMonth);
+ appendTextElement(doc, project, "StatusDate", normalizedModel.project.statusDate);
+ appendTextElement(doc, project, "WeekStartDay", normalizedModel.project.weekStartDay);
+ appendTextElement(doc, project, "WorkFormat", normalizedModel.project.workFormat);
+ appendTextElement(doc, project, "DurationFormat", normalizedModel.project.durationFormat);
+ appendTextElement(doc, project, "CurrencyCode", normalizedModel.project.currencyCode);
+ appendTextElement(doc, project, "CurrencyDigits", normalizedModel.project.currencyDigits);
+ appendTextElement(doc, project, "CurrencySymbol", normalizedModel.project.currencySymbol);
+ appendTextElement(doc, project, "CurrencySymbolPosition", normalizedModel.project.currencySymbolPosition);
+ appendTextElement(doc, project, "FYStartDate", normalizedModel.project.fyStartDate);
+ appendTextElement(doc, project, "FiscalYearStart", normalizedModel.project.fiscalYearStart);
+ appendTextElement(doc, project, "CriticalSlackLimit", normalizedModel.project.criticalSlackLimit);
+ appendTextElement(doc, project, "DefaultTaskType", normalizedModel.project.defaultTaskType);
+ appendTextElement(doc, project, "DefaultFixedCostAccrual", normalizedModel.project.defaultFixedCostAccrual);
+ appendTextElement(doc, project, "DefaultStandardRate", normalizedModel.project.defaultStandardRate);
+ appendTextElement(doc, project, "DefaultOvertimeRate", normalizedModel.project.defaultOvertimeRate);
+ appendTextElement(doc, project, "DefaultTaskEVMethod", normalizedModel.project.defaultTaskEVMethod);
+ appendTextElement(doc, project, "NewTaskStartDate", normalizedModel.project.newTaskStartDate);
+ appendTextElement(doc, project, "NewTasksAreManual", normalizedModel.project.newTasksAreManual);
+ appendTextElement(doc, project, "NewTasksEffortDriven", normalizedModel.project.newTasksEffortDriven);
+ appendTextElement(doc, project, "NewTasksEstimated", normalizedModel.project.newTasksEstimated);
+ appendTextElement(doc, project, "ActualsInSync", normalizedModel.project.actualsInSync);
+ appendTextElement(doc, project, "EditableActualCosts", normalizedModel.project.editableActualCosts);
+ appendTextElement(doc, project, "HonorConstraints", normalizedModel.project.honorConstraints);
+ appendTextElement(doc, project, "InsertedProjectsLikeSummary", normalizedModel.project.insertedProjectsLikeSummary);
+ appendTextElement(doc, project, "MultipleCriticalPaths", normalizedModel.project.multipleCriticalPaths);
+ appendTextElement(doc, project, "TaskUpdatesResource", normalizedModel.project.taskUpdatesResource);
+ appendTextElement(doc, project, "UpdateManuallyScheduledTasksWhenEditingLinks", normalizedModel.project.updateManuallyScheduledTasksWhenEditingLinks);
+ appendTextElement(doc, project, "CalendarUID", normalizedModel.project.calendarUID);
+ if (normalizedModel.project.outlineCodes.length > 0) {
+ const outlineCodesElement = doc.createElement("OutlineCodes");
+ for (const outlineCode of normalizedModel.project.outlineCodes) {
+ const outlineCodeElement = doc.createElement("OutlineCode");
+ appendTextElement(doc, outlineCodeElement, "FieldID", outlineCode.fieldID);
+ appendTextElement(doc, outlineCodeElement, "FieldName", outlineCode.fieldName);
+ appendTextElement(doc, outlineCodeElement, "Alias", outlineCode.alias);
+ appendTextElement(doc, outlineCodeElement, "OnlyTableValues", outlineCode.onlyTableValues);
+ appendTextElement(doc, outlineCodeElement, "Enterprise", outlineCode.enterprise);
+ appendTextElement(doc, outlineCodeElement, "ResourceSubstitutionEnabled", outlineCode.resourceSubstitutionEnabled);
+ appendTextElement(doc, outlineCodeElement, "LeafOnly", outlineCode.leafOnly);
+ appendTextElement(doc, outlineCodeElement, "AllLevelsRequired", outlineCode.allLevelsRequired);
+ if (outlineCode.masks.length > 0) {
+ const masksElement = doc.createElement("Masks");
+ for (const mask of outlineCode.masks) {
+ const maskElement = doc.createElement("Mask");
+ appendTextElement(doc, maskElement, "Level", mask.level);
+ appendTextElement(doc, maskElement, "Mask", mask.mask);
+ appendTextElement(doc, maskElement, "Length", mask.length);
+ appendTextElement(doc, maskElement, "Sequence", mask.sequence);
+ masksElement.appendChild(maskElement);
+ }
+ outlineCodeElement.appendChild(masksElement);
+ }
+ if (outlineCode.values.length > 0) {
+ const valuesElement = doc.createElement("Values");
+ for (const value of outlineCode.values) {
+ const valueElement = doc.createElement("Value");
+ appendTextElement(doc, valueElement, "Value", value.value);
+ appendTextElement(doc, valueElement, "Description", value.description);
+ valuesElement.appendChild(valueElement);
+ }
+ outlineCodeElement.appendChild(valuesElement);
+ }
+ outlineCodesElement.appendChild(outlineCodeElement);
+ }
+ project.appendChild(outlineCodesElement);
+ }
+ if (normalizedModel.project.wbsMasks.length > 0) {
+ const wbsMasksElement = doc.createElement("WBSMasks");
+ for (const wbsMask of normalizedModel.project.wbsMasks) {
+ const wbsMaskElement = doc.createElement("WBSMask");
+ appendTextElement(doc, wbsMaskElement, "Level", wbsMask.level);
+ appendTextElement(doc, wbsMaskElement, "Mask", wbsMask.mask);
+ appendTextElement(doc, wbsMaskElement, "Length", wbsMask.length);
+ appendTextElement(doc, wbsMaskElement, "Sequence", wbsMask.sequence);
+ wbsMasksElement.appendChild(wbsMaskElement);
+ }
+ project.appendChild(wbsMasksElement);
+ }
+ if (normalizedModel.project.extendedAttributes.length > 0) {
+ const extendedAttributesElement = doc.createElement("ExtendedAttributes");
+ for (const attribute of normalizedModel.project.extendedAttributes) {
+ const extendedAttributeElement = doc.createElement("ExtendedAttribute");
+ appendTextElement(doc, extendedAttributeElement, "FieldID", attribute.fieldID);
+ appendTextElement(doc, extendedAttributeElement, "FieldName", attribute.fieldName);
+ appendTextElement(doc, extendedAttributeElement, "Alias", attribute.alias);
+ appendTextElement(doc, extendedAttributeElement, "CalculationType", attribute.calculationType);
+ appendTextElement(doc, extendedAttributeElement, "RestrictValues", attribute.restrictValues);
+ appendTextElement(doc, extendedAttributeElement, "AppendNewValues", attribute.appendNewValues);
+ extendedAttributesElement.appendChild(extendedAttributeElement);
+ }
+ project.appendChild(extendedAttributesElement);
+ }
+
+ const calendarsElement = doc.createElement("Calendars");
+ for (const calendar of normalizedModel.calendars) {
+ const calendarElement = doc.createElement("Calendar");
+ appendTextElement(doc, calendarElement, "UID", calendar.uid);
+ appendTextElement(doc, calendarElement, "Name", calendar.name);
+ appendTextElement(doc, calendarElement, "IsBaseCalendar", calendar.isBaseCalendar);
+ appendTextElement(doc, calendarElement, "IsBaselineCalendar", calendar.isBaselineCalendar);
+ appendTextElement(doc, calendarElement, "BaseCalendarUID", calendar.baseCalendarUID);
+ if (calendar.exceptions.length > 0) {
+ const exceptionsElement = doc.createElement("Exceptions");
+ for (const exception of calendar.exceptions) {
+ const exceptionElement = doc.createElement("Exception");
+ appendTextElement(doc, exceptionElement, "Name", exception.name);
+ appendTextElement(doc, exceptionElement, "FromDate", exception.fromDate);
+ appendTextElement(doc, exceptionElement, "ToDate", exception.toDate);
+ appendTextElement(doc, exceptionElement, "DayWorking", exception.dayWorking);
+ appendWorkingTimes(doc, exceptionElement, exception.workingTimes);
+ exceptionsElement.appendChild(exceptionElement);
+ }
+ calendarElement.appendChild(exceptionsElement);
+ }
+ if (calendar.workWeeks.length > 0) {
+ const workWeeksElement = doc.createElement("WorkWeeks");
+ for (const workWeek of calendar.workWeeks) {
+ const workWeekElement = doc.createElement("WorkWeek");
+ appendTextElement(doc, workWeekElement, "Name", workWeek.name);
+ appendTextElement(doc, workWeekElement, "FromDate", workWeek.fromDate);
+ appendTextElement(doc, workWeekElement, "ToDate", workWeek.toDate);
+ appendWeekDays(doc, workWeekElement, workWeek.weekDays);
+ workWeeksElement.appendChild(workWeekElement);
+ }
+ calendarElement.appendChild(workWeeksElement);
+ }
+ appendWeekDays(doc, calendarElement, calendar.weekDays);
+ calendarsElement.appendChild(calendarElement);
+ }
+ project.appendChild(calendarsElement);
+
+ const tasksElement = doc.createElement("Tasks");
+ for (const task of normalizedModel.tasks) {
+ const taskElement = doc.createElement("Task");
+ appendTextElement(doc, taskElement, "UID", task.uid);
+ appendTextElement(doc, taskElement, "ID", task.id);
+ appendTextElement(doc, taskElement, "Name", task.name);
+ appendTextElement(doc, taskElement, "OutlineLevel", task.outlineLevel);
+ appendTextElement(doc, taskElement, "OutlineNumber", task.outlineNumber);
+ appendTextElement(doc, taskElement, "WBS", task.wbs);
+ appendTextElement(doc, taskElement, "Type", task.type);
+ appendTextElement(doc, taskElement, "CalendarUID", task.calendarUID);
+ appendTextElement(doc, taskElement, "Priority", task.priority);
+ appendTextElement(doc, taskElement, "Start", task.start);
+ appendTextElement(doc, taskElement, "Finish", task.finish);
+ appendTextElement(doc, taskElement, "Duration", task.duration);
+ appendTextElement(doc, taskElement, "ActualStart", task.actualStart);
+ appendTextElement(doc, taskElement, "ActualFinish", task.actualFinish);
+ appendTextElement(doc, taskElement, "Deadline", task.deadline);
+ appendTextElement(doc, taskElement, "StartVariance", task.startVariance);
+ appendTextElement(doc, taskElement, "FinishVariance", task.finishVariance);
+ appendTextElement(doc, taskElement, "Work", task.work);
+ appendTextElement(doc, taskElement, "WorkVariance", task.workVariance);
+ appendTextElement(doc, taskElement, "TotalSlack", task.totalSlack);
+ appendTextElement(doc, taskElement, "FreeSlack", task.freeSlack);
+ appendTextElement(doc, taskElement, "Cost", task.cost);
+ appendTextElement(doc, taskElement, "ActualCost", task.actualCost);
+ appendTextElement(doc, taskElement, "RemainingCost", task.remainingCost);
+ appendTextElement(doc, taskElement, "RemainingWork", task.remainingWork);
+ appendTextElement(doc, taskElement, "ActualWork", task.actualWork);
+ appendTextElement(doc, taskElement, "ConstraintType", task.constraintType);
+ appendTextElement(doc, taskElement, "ConstraintDate", task.constraintDate);
+ appendTextElement(doc, taskElement, "Milestone", task.milestone);
+ appendTextElement(doc, taskElement, "Summary", task.summary);
+ appendTextElement(doc, taskElement, "Critical", task.critical);
+ appendTextElement(doc, taskElement, "PercentComplete", task.percentComplete);
+ appendTextElement(doc, taskElement, "PercentWorkComplete", task.percentWorkComplete);
+ appendTextElement(doc, taskElement, "Notes", task.notes);
+ for (const attribute of task.extendedAttributes) {
+ const extendedAttributeElement = doc.createElement("ExtendedAttribute");
+ appendTextElement(doc, extendedAttributeElement, "FieldID", attribute.fieldID);
+ appendTextElement(doc, extendedAttributeElement, "Value", attribute.value);
+ taskElement.appendChild(extendedAttributeElement);
+ }
+ for (const baseline of task.baselines) {
+ const baselineElement = doc.createElement("Baseline");
+ appendTextElement(doc, baselineElement, "Number", baseline.number);
+ appendTextElement(doc, baselineElement, "Start", baseline.start);
+ appendTextElement(doc, baselineElement, "Finish", baseline.finish);
+ appendTextElement(doc, baselineElement, "Work", baseline.work);
+ appendTextElement(doc, baselineElement, "Cost", baseline.cost);
+ taskElement.appendChild(baselineElement);
+ }
+ for (const timephasedData of task.timephasedData) {
+ const timephasedDataElement = doc.createElement("TimephasedData");
+ appendTextElement(doc, timephasedDataElement, "Type", timephasedData.type);
+ appendTextElement(doc, timephasedDataElement, "UID", timephasedData.uid);
+ appendTextElement(doc, timephasedDataElement, "Start", timephasedData.start);
+ appendTextElement(doc, timephasedDataElement, "Finish", timephasedData.finish);
+ appendTextElement(doc, timephasedDataElement, "Unit", timephasedData.unit);
+ appendTextElement(doc, timephasedDataElement, "Value", timephasedData.value);
+ taskElement.appendChild(timephasedDataElement);
+ }
+ for (const predecessor of task.predecessors) {
+ const predecessorElement = doc.createElement("PredecessorLink");
+ appendTextElement(doc, predecessorElement, "PredecessorUID", predecessor.predecessorUid);
+ appendTextElement(doc, predecessorElement, "Type", predecessor.type);
+ appendTextElement(doc, predecessorElement, "LinkLag", predecessor.linkLag);
+ taskElement.appendChild(predecessorElement);
+ }
+ tasksElement.appendChild(taskElement);
+ }
+ project.appendChild(tasksElement);
+
+ const resourcesElement = doc.createElement("Resources");
+ for (const resource of normalizedModel.resources) {
+ const resourceElement = doc.createElement("Resource");
+ appendTextElement(doc, resourceElement, "UID", resource.uid);
+ appendTextElement(doc, resourceElement, "ID", resource.id);
+ appendTextElement(doc, resourceElement, "Name", resource.name);
+ appendTextElement(doc, resourceElement, "Type", resource.type);
+ appendTextElement(doc, resourceElement, "Initials", resource.initials);
+ appendTextElement(doc, resourceElement, "Group", resource.group);
+ appendTextElement(doc, resourceElement, "WorkGroup", resource.workGroup);
+ appendTextElement(doc, resourceElement, "MaxUnits", resource.maxUnits);
+ appendTextElement(doc, resourceElement, "CalendarUID", resource.calendarUID);
+ appendTextElement(doc, resourceElement, "StandardRate", resource.standardRate);
+ appendTextElement(doc, resourceElement, "StandardRateFormat", resource.standardRateFormat);
+ appendTextElement(doc, resourceElement, "OvertimeRate", resource.overtimeRate);
+ appendTextElement(doc, resourceElement, "OvertimeRateFormat", resource.overtimeRateFormat);
+ appendTextElement(doc, resourceElement, "CostPerUse", resource.costPerUse);
+ appendTextElement(doc, resourceElement, "Work", resource.work);
+ appendTextElement(doc, resourceElement, "ActualWork", resource.actualWork);
+ appendTextElement(doc, resourceElement, "RemainingWork", resource.remainingWork);
+ appendTextElement(doc, resourceElement, "Cost", resource.cost);
+ appendTextElement(doc, resourceElement, "ActualCost", resource.actualCost);
+ appendTextElement(doc, resourceElement, "RemainingCost", resource.remainingCost);
+ appendTextElement(doc, resourceElement, "PercentWorkComplete", resource.percentWorkComplete);
+ for (const attribute of resource.extendedAttributes) {
+ const extendedAttributeElement = doc.createElement("ExtendedAttribute");
+ appendTextElement(doc, extendedAttributeElement, "FieldID", attribute.fieldID);
+ appendTextElement(doc, extendedAttributeElement, "Value", attribute.value);
+ resourceElement.appendChild(extendedAttributeElement);
+ }
+ for (const baseline of resource.baselines) {
+ const baselineElement = doc.createElement("Baseline");
+ appendTextElement(doc, baselineElement, "Number", baseline.number);
+ appendTextElement(doc, baselineElement, "Start", baseline.start);
+ appendTextElement(doc, baselineElement, "Finish", baseline.finish);
+ appendTextElement(doc, baselineElement, "Work", baseline.work);
+ appendTextElement(doc, baselineElement, "Cost", baseline.cost);
+ resourceElement.appendChild(baselineElement);
+ }
+ for (const timephasedData of resource.timephasedData) {
+ const timephasedDataElement = doc.createElement("TimephasedData");
+ appendTextElement(doc, timephasedDataElement, "Type", timephasedData.type);
+ appendTextElement(doc, timephasedDataElement, "UID", timephasedData.uid);
+ appendTextElement(doc, timephasedDataElement, "Start", timephasedData.start);
+ appendTextElement(doc, timephasedDataElement, "Finish", timephasedData.finish);
+ appendTextElement(doc, timephasedDataElement, "Unit", timephasedData.unit);
+ appendTextElement(doc, timephasedDataElement, "Value", timephasedData.value);
+ resourceElement.appendChild(timephasedDataElement);
+ }
+ resourcesElement.appendChild(resourceElement);
+ }
+ project.appendChild(resourcesElement);
+
+ const assignmentsElement = doc.createElement("Assignments");
+ for (const assignment of normalizedModel.assignments) {
+ const assignmentElement = doc.createElement("Assignment");
+ appendTextElement(doc, assignmentElement, "UID", assignment.uid);
+ appendTextElement(doc, assignmentElement, "TaskUID", assignment.taskUid);
+ appendTextElement(doc, assignmentElement, "ResourceUID", assignment.resourceUid);
+ appendTextElement(doc, assignmentElement, "Start", assignment.start);
+ appendTextElement(doc, assignmentElement, "Finish", assignment.finish);
+ appendTextElement(doc, assignmentElement, "StartVariance", assignment.startVariance);
+ appendTextElement(doc, assignmentElement, "FinishVariance", assignment.finishVariance);
+ appendTextElement(doc, assignmentElement, "Delay", assignment.delay);
+ appendTextElement(doc, assignmentElement, "Milestone", assignment.milestone);
+ appendTextElement(doc, assignmentElement, "WorkContour", assignment.workContour);
+ appendTextElement(doc, assignmentElement, "Units", assignment.units);
+ appendTextElement(doc, assignmentElement, "Work", assignment.work);
+ appendTextElement(doc, assignmentElement, "Cost", assignment.cost);
+ appendTextElement(doc, assignmentElement, "ActualCost", assignment.actualCost);
+ appendTextElement(doc, assignmentElement, "RemainingCost", assignment.remainingCost);
+ appendTextElement(doc, assignmentElement, "PercentWorkComplete", assignment.percentWorkComplete);
+ appendTextElement(doc, assignmentElement, "OvertimeWork", assignment.overtimeWork);
+ appendTextElement(doc, assignmentElement, "ActualOvertimeWork", assignment.actualOvertimeWork);
+ appendTextElement(doc, assignmentElement, "ActualWork", assignment.actualWork);
+ appendTextElement(doc, assignmentElement, "RemainingWork", assignment.remainingWork);
+ for (const attribute of assignment.extendedAttributes) {
+ const extendedAttributeElement = doc.createElement("ExtendedAttribute");
+ appendTextElement(doc, extendedAttributeElement, "FieldID", attribute.fieldID);
+ appendTextElement(doc, extendedAttributeElement, "Value", attribute.value);
+ assignmentElement.appendChild(extendedAttributeElement);
+ }
+ for (const baseline of assignment.baselines) {
+ const baselineElement = doc.createElement("Baseline");
+ appendTextElement(doc, baselineElement, "Number", baseline.number);
+ appendTextElement(doc, baselineElement, "Start", baseline.start);
+ appendTextElement(doc, baselineElement, "Finish", baseline.finish);
+ appendTextElement(doc, baselineElement, "Work", baseline.work);
+ appendTextElement(doc, baselineElement, "Cost", baseline.cost);
+ assignmentElement.appendChild(baselineElement);
+ }
+ for (const timephasedData of assignment.timephasedData) {
+ const timephasedDataElement = doc.createElement("TimephasedData");
+ appendTextElement(doc, timephasedDataElement, "Type", timephasedData.type);
+ appendTextElement(doc, timephasedDataElement, "UID", timephasedData.uid);
+ appendTextElement(doc, timephasedDataElement, "Start", timephasedData.start);
+ appendTextElement(doc, timephasedDataElement, "Finish", timephasedData.finish);
+ appendTextElement(doc, timephasedDataElement, "Unit", timephasedData.unit);
+ appendTextElement(doc, timephasedDataElement, "Value", timephasedData.value);
+ assignmentElement.appendChild(timephasedDataElement);
+ }
+ assignmentsElement.appendChild(assignmentElement);
+ }
+ project.appendChild(assignmentsElement);
+
+ const serializer = new XMLSerializer();
+ const serialized = serializer.serializeToString(doc);
+ return `\n${formatXml(serialized)}\n`;
+ }
+
+ function normalizeProjectModel(model: ProjectModel): ProjectModel {
+ return JSON.parse(JSON.stringify(model)) as ProjectModel;
+ }
+
+ function validateProjectModel(model: ProjectModel): ValidationIssue[] {
+ const issues: ValidationIssue[] = [];
+ const taskUidSet = new Set();
+ const taskIdSet = new Set();
+ const resourceUidSet = new Set();
+ const calendarUidSet = new Set();
+
+ if (!model.project.name) {
+ issues.push({ level: "warning", scope: "project", message: "Project Name が空です" });
+ }
+ if (model.project.saveVersion !== undefined && model.project.saveVersion < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project SaveVersion は 0 以上が望ましいです" });
+ }
+ if (!model.project.startDate) {
+ issues.push({ level: "warning", scope: "project", message: "Project StartDate が空です" });
+ }
+ if (!model.project.finishDate) {
+ issues.push({ level: "warning", scope: "project", message: "Project FinishDate が空です" });
+ }
+ if (model.project.minutesPerDay !== undefined && model.project.minutesPerDay <= 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project MinutesPerDay は正の値が望ましいです" });
+ }
+ if (model.project.minutesPerWeek !== undefined && model.project.minutesPerWeek <= 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project MinutesPerWeek は正の値が望ましいです" });
+ }
+ if (model.project.daysPerMonth !== undefined && model.project.daysPerMonth <= 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project DaysPerMonth は正の値が望ましいです" });
+ }
+ if (model.project.weekStartDay !== undefined && (model.project.weekStartDay < 1 || model.project.weekStartDay > 7)) {
+ issues.push({ level: "warning", scope: "project", message: "Project WeekStartDay は 1..7 が望ましいです" });
+ }
+ if (model.project.workFormat !== undefined && model.project.workFormat < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project WorkFormat は 0 以上が望ましいです" });
+ }
+ if (model.project.durationFormat !== undefined && model.project.durationFormat < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project DurationFormat は 0 以上が望ましいです" });
+ }
+ if (model.project.currencyDigits !== undefined && model.project.currencyDigits < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project CurrencyDigits は 0 以上が望ましいです" });
+ }
+ if (model.project.currencySymbolPosition !== undefined && model.project.currencySymbolPosition < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project CurrencySymbolPosition は 0 以上が望ましいです" });
+ }
+ if (model.project.fyStartDate !== undefined && !parseDateValue(model.project.fyStartDate)) {
+ issues.push({ level: "warning", scope: "project", message: "Project FYStartDate の日付形式が解釈できません" });
+ }
+ if (model.project.criticalSlackLimit !== undefined && model.project.criticalSlackLimit < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project CriticalSlackLimit は 0 以上が望ましいです" });
+ }
+ if (model.project.defaultTaskType !== undefined && model.project.defaultTaskType < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project DefaultTaskType は 0 以上が望ましいです" });
+ }
+ if (model.project.defaultFixedCostAccrual !== undefined && model.project.defaultFixedCostAccrual < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project DefaultFixedCostAccrual は 0 以上が望ましいです" });
+ }
+ if (model.project.defaultTaskEVMethod !== undefined && model.project.defaultTaskEVMethod < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project DefaultTaskEVMethod は 0 以上が望ましいです" });
+ }
+ if (model.project.newTaskStartDate !== undefined && model.project.newTaskStartDate < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project NewTaskStartDate は 0 以上が望ましいです" });
+ }
+ for (const outlineCode of model.project.outlineCodes) {
+ if (!outlineCode.fieldID && !outlineCode.fieldName) {
+ issues.push({ level: "warning", scope: "project", message: "Project OutlineCode は FieldID または FieldName を持つことが望ましいです" });
+ }
+ for (const mask of outlineCode.masks) {
+ if (mask.level < 1) {
+ issues.push({ level: "warning", scope: "project", message: "Project OutlineCode Mask Level は 1 以上が望ましいです" });
+ }
+ }
+ }
+ for (const wbsMask of model.project.wbsMasks) {
+ if (wbsMask.level < 1) {
+ issues.push({ level: "warning", scope: "project", message: "Project WBSMask Level は 1 以上が望ましいです" });
+ }
+ }
+ for (const attribute of model.project.extendedAttributes) {
+ if (!attribute.fieldID && !attribute.fieldName) {
+ issues.push({ level: "warning", scope: "project", message: "Project ExtendedAttribute は FieldID または FieldName を持つことが望ましいです" });
+ }
+ if (attribute.calculationType !== undefined && attribute.calculationType < 0) {
+ issues.push({ level: "warning", scope: "project", message: "Project ExtendedAttribute CalculationType は 0 以上が望ましいです" });
+ }
+ }
+
+ for (const calendar of model.calendars) {
+ if (!calendar.uid) {
+ issues.push({ level: "error", scope: "calendars", message: "Calendar UID が空です" });
+ }
+ if (calendar.isBaselineCalendar !== undefined && !calendar.isBaseCalendar && calendar.isBaselineCalendar) {
+ issues.push({
+ level: "warning",
+ scope: "calendars",
+ message: `Calendar IsBaselineCalendar は通常 BaseCalendar と整合していることが望ましいです: ${describeCalendar(calendar)}`
+ });
+ }
+ if (calendarUidSet.has(calendar.uid)) {
+ issues.push({ level: "error", scope: "calendars", message: `Calendar UID が重複しています: ${calendar.uid}` });
+ }
+ calendarUidSet.add(calendar.uid);
+ for (const weekDay of calendar.weekDays) {
+ if (weekDay.dayType < 1 || weekDay.dayType > 7) {
+ issues.push({
+ level: "warning",
+ scope: "calendars",
+ message: `Calendar WeekDay DayType が 1..7 の範囲外です: ${describeCalendar(calendar)}`
+ });
+ }
+ for (const workingTime of weekDay.workingTimes) {
+ if (!workingTime.fromTime || !workingTime.toTime) {
+ issues.push({
+ level: "warning",
+ scope: "calendars",
+ message: `Calendar WorkingTime の時刻が不足しています: ${describeCalendar(calendar)}`
+ });
+ }
+ }
+ }
+ for (const exception of calendar.exceptions) {
+ const exceptionFrom = parseDateValue(exception.fromDate);
+ const exceptionTo = parseDateValue(exception.toDate);
+ if (exceptionFrom !== null && exceptionTo !== null && exceptionFrom > exceptionTo) {
+ issues.push({
+ level: "warning",
+ scope: "calendars",
+ message: `Calendar Exception FromDate が ToDate より後です: ${describeCalendar(calendar)}`
+ });
+ }
+ for (const workingTime of exception.workingTimes) {
+ if (!workingTime.fromTime || !workingTime.toTime) {
+ issues.push({
+ level: "warning",
+ scope: "calendars",
+ message: `Calendar Exception WorkingTime の時刻が不足しています: ${describeCalendar(calendar)}`
+ });
+ }
+ }
+ }
+ for (const workWeek of calendar.workWeeks) {
+ const workWeekFrom = parseDateValue(workWeek.fromDate);
+ const workWeekTo = parseDateValue(workWeek.toDate);
+ if (workWeekFrom !== null && workWeekTo !== null && workWeekFrom > workWeekTo) {
+ issues.push({
+ level: "warning",
+ scope: "calendars",
+ message: `Calendar WorkWeek FromDate が ToDate より後です: ${describeCalendar(calendar)}`
+ });
+ }
+ for (const weekDay of workWeek.weekDays) {
+ if (weekDay.dayType < 1 || weekDay.dayType > 7) {
+ issues.push({
+ level: "warning",
+ scope: "calendars",
+ message: `Calendar WorkWeek DayType が 1..7 の範囲外です: ${describeCalendar(calendar)}`
+ });
+ }
+ }
+ }
+ }
+
+ if (model.project.calendarUID && !calendarUidSet.has(model.project.calendarUID)) {
+ issues.push({
+ level: "error",
+ scope: "project",
+ message: `Project CalendarUID が既存 Calendar を指していません: ${model.project.calendarUID}`
+ });
+ }
+
+ for (const calendar of model.calendars) {
+ if (calendar.baseCalendarUID && !calendarUidSet.has(calendar.baseCalendarUID)) {
+ issues.push({
+ level: "warning",
+ scope: "calendars",
+ message: `Calendar BaseCalendarUID が既存 Calendar を指していません: ${describeCalendar(calendar)}`
+ });
+ }
+ if (calendar.baseCalendarUID && calendar.baseCalendarUID === calendar.uid) {
+ issues.push({
+ level: "warning",
+ scope: "calendars",
+ message: `Calendar BaseCalendarUID が自身を指しています: ${describeCalendar(calendar)}`
+ });
+ }
+ }
+
+ for (const task of model.tasks) {
+ if (!task.uid) {
+ issues.push({ level: "error", scope: "tasks", message: "Task UID が空です" });
+ }
+ if (!task.id) {
+ issues.push({ level: "error", scope: "tasks", message: `Task ID が空です: ${task.name || "(無名)"}` });
+ }
+ if (!task.name) {
+ if (!isPlaceholderUid(task.uid)) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task Name が空です: ${describeTask(task)}` });
+ }
+ }
+ if (taskIdSet.has(task.id)) {
+ issues.push({ level: "error", scope: "tasks", message: `Task ID が重複しています: ${task.id}` });
+ }
+ taskIdSet.add(task.id);
+ if (!task.start) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task Start が空です: ${describeTask(task)}` });
+ }
+ if (!task.finish) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task Finish が空です: ${describeTask(task)}` });
+ }
+ if (task.outlineLevel < 1 && !isPlaceholderUid(task.uid)) {
+ issues.push({ level: "error", scope: "tasks", message: `Task OutlineLevel が不正です: ${describeTask(task)}` });
+ }
+ if (task.calendarUID && !calendarUidSet.has(task.calendarUID)) {
+ issues.push({
+ level: "warning",
+ scope: "tasks",
+ message: `Task CalendarUID が既存 Calendar を指していません: ${describeTask(task)}`
+ });
+ }
+ if (task.outlineNumber && !isPlaceholderUid(task.uid)) {
+ const outlineParts = task.outlineNumber.split(".").filter(Boolean);
+ if (outlineParts.length !== task.outlineLevel) {
+ issues.push({
+ level: "warning",
+ scope: "tasks",
+ message: `Task OutlineNumber と OutlineLevel の整合が取れていません: ${describeTask(task)}`
+ });
+ }
+ }
+ if (task.percentComplete < 0 || task.percentComplete > 100) {
+ issues.push({
+ level: "warning",
+ scope: "tasks",
+ message: `Task PercentComplete が 0..100 の範囲外です: ${describeTask(task)}`
+ });
+ }
+ if (
+ task.percentWorkComplete !== undefined &&
+ (task.percentWorkComplete < 0 || task.percentWorkComplete > 100)
+ ) {
+ issues.push({
+ level: "warning",
+ scope: "tasks",
+ message: `Task PercentWorkComplete が 0..100 の範囲外です: ${describeTask(task)}`
+ });
+ }
+ const taskStart = parseDateValue(task.start);
+ const taskFinish = parseDateValue(task.finish);
+ const taskActualStart = parseDateValue(task.actualStart);
+ const taskActualFinish = parseDateValue(task.actualFinish);
+ const taskDeadline = parseDateValue(task.deadline);
+ if (taskStart !== null && taskFinish !== null && taskStart > taskFinish) {
+ issues.push({
+ level: "warning",
+ scope: "tasks",
+ message: `Task Start が Finish より後です: ${describeTask(task)}`
+ });
+ }
+ if (taskFinish !== null && taskDeadline !== null && taskFinish > taskDeadline) {
+ issues.push({
+ level: "warning",
+ scope: "tasks",
+ message: `Task Finish が Deadline より後です: ${describeTask(task)}`
+ });
+ }
+ if (taskActualStart !== null && taskActualFinish !== null && taskActualStart > taskActualFinish) {
+ issues.push({
+ level: "warning",
+ scope: "tasks",
+ message: `Task ActualStart が ActualFinish より後です: ${describeTask(task)}`
+ });
+ }
+ if (taskUidSet.has(task.uid)) {
+ issues.push({ level: "error", scope: "tasks", message: `Task UID が重複しています: ${task.uid}` });
+ }
+ for (const attribute of task.extendedAttributes) {
+ if (!attribute.fieldID) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task ExtendedAttribute に FieldID がありません: ${describeTask(task)}` });
+ }
+ }
+ for (const baseline of task.baselines) {
+ if (baseline.number !== undefined && baseline.number < 0) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task Baseline Number は 0 以上が望ましいです: ${describeTask(task)}` });
+ }
+ const baselineStart = parseDateValue(baseline.start);
+ const baselineFinish = parseDateValue(baseline.finish);
+ if (baselineStart !== null && baselineFinish !== null && baselineStart > baselineFinish) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task Baseline Start が Finish より後です: ${describeTask(task)}` });
+ }
+ }
+ for (const timephasedData of task.timephasedData) {
+ if (timephasedData.type !== undefined && timephasedData.type < 0) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task TimephasedData Type は 0 以上が望ましいです: ${describeTask(task)}` });
+ }
+ const timephasedStart = parseDateValue(timephasedData.start);
+ const timephasedFinish = parseDateValue(timephasedData.finish);
+ if (timephasedStart !== null && timephasedFinish !== null && timephasedStart > timephasedFinish) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task TimephasedData Start が Finish より後です: ${describeTask(task)}` });
+ }
+ }
+ taskUidSet.add(task.uid);
+ if (task.priority !== undefined && (task.priority < 0 || task.priority > 1000)) {
+ issues.push({
+ level: "warning",
+ scope: "tasks",
+ message: `Task Priority が 0..1000 の範囲外です: ${describeTask(task)}`
+ });
+ }
+ if (task.cost !== undefined && task.cost < 0) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task Cost が負値です: ${describeTask(task)}` });
+ }
+ if (task.actualCost !== undefined && task.actualCost < 0) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task ActualCost が負値です: ${describeTask(task)}` });
+ }
+ if (task.remainingCost !== undefined && task.remainingCost < 0) {
+ issues.push({ level: "warning", scope: "tasks", message: `Task RemainingCost が負値です: ${describeTask(task)}` });
+ }
+ }
+ const taskOrderIssue = detectTaskOrderIssue(model.tasks);
+ if (taskOrderIssue) {
+ issues.push({
+ level: "warning",
+ scope: "tasks",
+ message: `Task の並び順が OutlineNumber 順と一致していない可能性があります: ${describeTask(taskOrderIssue.current)} (直前: ${describeTask(taskOrderIssue.previous)})`
+ });
+ }
+
+ for (const resource of model.resources) {
+ if (!resource.uid) {
+ issues.push({ level: "error", scope: "resources", message: "Resource UID が空です" });
+ }
+ if (!resource.name) {
+ if (!isPlaceholderUid(resource.uid)) {
+ issues.push({ level: "warning", scope: "resources", message: `Resource Name が空です: ${describeResource(resource)}` });
+ }
+ }
+ if (resourceUidSet.has(resource.uid)) {
+ issues.push({ level: "error", scope: "resources", message: `Resource UID が重複しています: ${resource.uid}` });
+ }
+ resourceUidSet.add(resource.uid);
+ if (resource.calendarUID && !calendarUidSet.has(resource.calendarUID)) {
+ issues.push({
+ level: "warning",
+ scope: "resources",
+ message: `Resource CalendarUID が既存 Calendar を指していません: ${describeResource(resource)}`
+ });
+ }
+ if (resource.workGroup !== undefined && resource.workGroup < 0) {
+ issues.push({
+ level: "warning",
+ scope: "resources",
+ message: `Resource WorkGroup は 0 以上が望ましいです: ${describeResource(resource)}`
+ });
+ }
+ if (resource.overtimeRateFormat !== undefined && resource.overtimeRateFormat < 0) {
+ issues.push({
+ level: "warning",
+ scope: "resources",
+ message: `Resource OvertimeRateFormat は 0 以上が望ましいです: ${describeResource(resource)}`
+ });
+ }
+ if (resource.cost !== undefined && resource.cost < 0) {
+ issues.push({ level: "warning", scope: "resources", message: `Resource Cost が負値です: ${describeResource(resource)}` });
+ }
+ if (resource.actualCost !== undefined && resource.actualCost < 0) {
+ issues.push({ level: "warning", scope: "resources", message: `Resource ActualCost が負値です: ${describeResource(resource)}` });
+ }
+ if (resource.remainingCost !== undefined && resource.remainingCost < 0) {
+ issues.push({ level: "warning", scope: "resources", message: `Resource RemainingCost が負値です: ${describeResource(resource)}` });
+ }
+ if (
+ resource.percentWorkComplete !== undefined &&
+ (resource.percentWorkComplete < 0 || resource.percentWorkComplete > 100)
+ ) {
+ issues.push({
+ level: "warning",
+ scope: "resources",
+ message: `Resource PercentWorkComplete が 0..100 の範囲外です: ${describeResource(resource)}`
+ });
+ }
+ for (const attribute of resource.extendedAttributes) {
+ if (!attribute.fieldID) {
+ issues.push({ level: "warning", scope: "resources", message: `Resource ExtendedAttribute に FieldID がありません: ${describeResource(resource)}` });
+ }
+ }
+ for (const baseline of resource.baselines) {
+ if (baseline.number !== undefined && baseline.number < 0) {
+ issues.push({
+ level: "warning",
+ scope: "resources",
+ message: `Resource Baseline Number は 0 以上が望ましいです: ${describeResource(resource)}`
+ });
+ }
+ const baselineStart = parseDateValue(baseline.start);
+ const baselineFinish = parseDateValue(baseline.finish);
+ if (baselineStart !== null && baselineFinish !== null && baselineStart > baselineFinish) {
+ issues.push({
+ level: "warning",
+ scope: "resources",
+ message: `Resource Baseline Start が Finish より後です: ${describeResource(resource)}`
+ });
+ }
+ }
+ for (const timephasedData of resource.timephasedData) {
+ if (timephasedData.type !== undefined && timephasedData.type < 0) {
+ issues.push({
+ level: "warning",
+ scope: "resources",
+ message: `Resource TimephasedData Type は 0 以上が望ましいです: ${describeResource(resource)}`
+ });
+ }
+ const timephasedStart = parseDateValue(timephasedData.start);
+ const timephasedFinish = parseDateValue(timephasedData.finish);
+ if (timephasedStart !== null && timephasedFinish !== null && timephasedStart > timephasedFinish) {
+ issues.push({
+ level: "warning",
+ scope: "resources",
+ message: `Resource TimephasedData Start が Finish より後です: ${describeResource(resource)}`
+ });
+ }
+ }
+ }
+
+ for (const task of model.tasks) {
+ for (const predecessor of task.predecessors) {
+ if (!taskUidSet.has(predecessor.predecessorUid)) {
+ issues.push({
+ level: "error",
+ scope: "tasks",
+ message: `PredecessorUID が既存 Task を指していません: ${describeTask(task)}, ${describeTaskRef(model, predecessor.predecessorUid)}`
+ });
+ }
+ }
+ }
+
+ for (const assignment of model.assignments) {
+ if (!assignment.uid) {
+ issues.push({ level: "warning", scope: "assignments", message: "Assignment UID が空です" });
+ }
+ if (!taskUidSet.has(assignment.taskUid)) {
+ issues.push({
+ level: "error",
+ scope: "assignments",
+ message: `Assignment TaskUID が既存 Task を指していません: ${describeAssignment(assignment)}, ${describeTaskRef(model, assignment.taskUid)}`
+ });
+ }
+ if (!resourceUidSet.has(assignment.resourceUid) && !isUnassignedResourceUid(assignment.resourceUid)) {
+ issues.push({
+ level: "error",
+ scope: "assignments",
+ message: `Assignment ResourceUID が既存 Resource を指していません: ${describeAssignment(assignment)}, ${describeTaskRef(model, assignment.taskUid)}, ${describeResourceRef(model, assignment.resourceUid)}`
+ });
+ }
+ if (!assignment.start) {
+ issues.push({ level: "warning", scope: "assignments", message: `Assignment Start が空です: ${describeAssignment(assignment)}` });
+ }
+ if (!assignment.finish) {
+ issues.push({ level: "warning", scope: "assignments", message: `Assignment Finish が空です: ${describeAssignment(assignment)}` });
+ }
+ const assignmentStart = parseDateValue(assignment.start);
+ const assignmentFinish = parseDateValue(assignment.finish);
+ if (assignmentStart !== null && assignmentFinish !== null && assignmentStart > assignmentFinish) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment Start が Finish より後です: ${describeAssignment(assignment)}`
+ });
+ }
+ if (assignment.units !== undefined && assignment.units < 0) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment Units が負値です: ${describeAssignment(assignment)}`
+ });
+ }
+ if (assignment.cost !== undefined && assignment.cost < 0) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment Cost が負値です: ${describeAssignment(assignment)}`
+ });
+ }
+ if (assignment.actualCost !== undefined && assignment.actualCost < 0) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment ActualCost が負値です: ${describeAssignment(assignment)}`
+ });
+ }
+ if (assignment.remainingCost !== undefined && assignment.remainingCost < 0) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment RemainingCost が負値です: ${describeAssignment(assignment)}`
+ });
+ }
+ if (
+ assignment.percentWorkComplete !== undefined &&
+ (assignment.percentWorkComplete < 0 || assignment.percentWorkComplete > 100)
+ ) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment PercentWorkComplete が 0..100 の範囲外です: ${describeAssignment(assignment)}`
+ });
+ }
+ if (assignment.overtimeWork !== undefined && !assignment.overtimeWork) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment OvertimeWork が空です: ${describeAssignment(assignment)}`
+ });
+ }
+ if (assignment.actualOvertimeWork !== undefined && !assignment.actualOvertimeWork) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment ActualOvertimeWork が空です: ${describeAssignment(assignment)}`
+ });
+ }
+ if (assignment.workContour !== undefined && assignment.workContour < 0) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment WorkContour は 0 以上が望ましいです: ${describeAssignment(assignment)}`
+ });
+ }
+ if (assignment.startVariance !== undefined && !assignment.startVariance) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment StartVariance が空です: ${describeAssignment(assignment)}`
+ });
+ }
+ for (const attribute of assignment.extendedAttributes) {
+ if (!attribute.fieldID) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment ExtendedAttribute に FieldID がありません: ${describeAssignment(assignment)}`
+ });
+ }
+ }
+ for (const baseline of assignment.baselines) {
+ if (baseline.number !== undefined && baseline.number < 0) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment Baseline Number は 0 以上が望ましいです: ${describeAssignment(assignment)}`
+ });
+ }
+ const baselineStart = parseDateValue(baseline.start);
+ const baselineFinish = parseDateValue(baseline.finish);
+ if (baselineStart !== null && baselineFinish !== null && baselineStart > baselineFinish) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment Baseline Start が Finish より後です: ${describeAssignment(assignment)}`
+ });
+ }
+ }
+ for (const timephasedData of assignment.timephasedData) {
+ if (timephasedData.type !== undefined && timephasedData.type < 0) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment TimephasedData Type は 0 以上が望ましいです: ${describeAssignment(assignment)}`
+ });
+ }
+ const timephasedStart = parseDateValue(timephasedData.start);
+ const timephasedFinish = parseDateValue(timephasedData.finish);
+ if (timephasedStart !== null && timephasedFinish !== null && timephasedStart > timephasedFinish) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment TimephasedData Start が Finish より後です: ${describeAssignment(assignment)}`
+ });
+ }
+ }
+ if (assignment.finishVariance !== undefined && !assignment.finishVariance) {
+ issues.push({
+ level: "warning",
+ scope: "assignments",
+ message: `Assignment FinishVariance が空です: ${describeAssignment(assignment)}`
+ });
+ }
+ }
+
+ return issues;
+ }
+
+ (globalThis as typeof globalThis & {
+ __mikuprojectXml?: {
+ SAMPLE_XML: string;
+ SAMPLE_PROJECT_DRAFT_VIEW: unknown;
+ parseXmlDocument: (xmlText: string) => XMLDocument;
+ importMsProjectXml: (xmlText: string) => ProjectModel;
+ importCsvParentId: (csvText: string) => ProjectModel;
+ exportMsProjectXml: (model: ProjectModel) => string;
+ exportMermaidGantt: (model: ProjectModel) => string;
+ buildProjectDraftRequest: (input: {
+ name: string;
+ plannedStart?: string;
+ goal?: string;
+ teamCount?: number;
+ mustHavePhases?: string[];
+ mustHaveMilestones?: string[];
+ }) => unknown;
+ importProjectDraftView: (draft: unknown) => ProjectModel;
+ exportProjectOverviewView: (model: ProjectModel) => unknown;
+ exportPhaseDetailView: (model: ProjectModel, phaseUid?: string) => unknown;
+ exportCsvParentId: (model: ProjectModel) => string;
+ normalizeProjectModel: (model: ProjectModel) => ProjectModel;
+ validateProjectModel: (model: ProjectModel) => ValidationIssue[];
+ };
+ }).__mikuprojectXml = {
+ SAMPLE_XML,
+ SAMPLE_PROJECT_DRAFT_VIEW,
+ parseXmlDocument,
+ importMsProjectXml,
+ importCsvParentId,
+ exportMsProjectXml,
+ exportMermaidGantt,
+ buildProjectDraftRequest,
+ importProjectDraftView,
+ exportProjectOverviewView,
+ exportPhaseDetailView,
+ exportCsvParentId,
+ normalizeProjectModel,
+ validateProjectModel
+ };
+})();
diff --git a/src/ts/native-svg.ts b/src/ts/native-svg.ts
new file mode 100644
index 0000000..26947a9
--- /dev/null
+++ b/src/ts/native-svg.ts
@@ -0,0 +1,418 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ type NativeSvgExportOptions = {
+ holidayDates?: string[];
+ displayDaysBeforeBaseDate?: number;
+ displayDaysAfterBaseDate?: number;
+ useBusinessDaysForDisplayRange?: boolean;
+ useBusinessDaysForProgressBand?: boolean;
+ labelMode?: "near" | "list";
+ };
+
+ type NativeSvgTaskRow = {
+ task: TaskModel;
+ label: string;
+ kind: "phase" | "task" | "milestone";
+ startIndex: number | null;
+ endIndex: number | null;
+ y: number;
+ };
+
+ type NativeSvgLabelPlacement = {
+ x: number;
+ anchor: "start" | "end";
+ width: number;
+ };
+
+ const DAY_WIDTH = 56;
+ const LIST_LABEL_WIDTH = 360;
+ const NEAR_LEFT_LABEL_WIDTH = 0;
+ const NEAR_RIGHT_LABEL_WIDTH = 0;
+ const HEADER_HEIGHT = 82;
+ const ROW_HEIGHT = 38;
+ const LEFT_PADDING = 0;
+ const TOP_PADDING = 22;
+ const RIGHT_PADDING = 0;
+ const BOTTOM_PADDING = 28;
+
+ function exportNativeSvg(model: ProjectModel, options: NativeSvgExportOptions = {}): string {
+ const labelMode = options.labelMode || "near";
+ const holidaySet = new Set([
+ ...collectWbsHolidayDates(model),
+ ...(options.holidayDates || []).map((day) => String(day || "").slice(0, 10)).filter(Boolean)
+ ]);
+ const nonWorkingDayTypes = collectProjectNonWorkingDayTypes(model);
+ const dateBand = buildDisplayDateBand(
+ model.project.startDate,
+ model.project.finishDate,
+ model.project.currentDate,
+ options.displayDaysBeforeBaseDate,
+ options.displayDaysAfterBaseDate,
+ holidaySet,
+ nonWorkingDayTypes,
+ options.useBusinessDaysForDisplayRange
+ );
+ const rows = buildTaskRows(model.tasks, dateBand);
+ const chartWidth = dateBand.length * DAY_WIDTH;
+ const leftLabelWidth = labelMode === "list" ? LIST_LABEL_WIDTH : NEAR_LEFT_LABEL_WIDTH;
+ const rightLabelWidth = labelMode === "list" ? 0 : NEAR_RIGHT_LABEL_WIDTH;
+ const chartOriginXBase = LEFT_PADDING + leftLabelWidth;
+ const labelPlacements = rows.map((row) => resolveLabelPlacement(row, chartOriginXBase, chartWidth, labelMode));
+ const labelMinX = labelPlacements.reduce((min, placement) => {
+ const placementMinX = placement.anchor === "start" ? placement.x : placement.x - placement.width;
+ return Math.min(min, placementMinX);
+ }, chartOriginXBase);
+ const labelMaxX = labelPlacements.reduce((max, placement) => {
+ const placementMaxX = placement.anchor === "start" ? placement.x + placement.width : placement.x;
+ return Math.max(max, placementMaxX);
+ }, chartOriginXBase + chartWidth + rightLabelWidth);
+ const shiftX = labelMode === "near" ? Math.max(0, -labelMinX) : 0;
+ const chartOriginX = chartOriginXBase + shiftX;
+ const svgWidth = (labelMaxX + shiftX) + RIGHT_PADDING;
+ const svgHeight = TOP_PADDING + HEADER_HEIGHT + (rows.length * ROW_HEIGHT) + BOTTOM_PADDING;
+ const todayIndex = indexOfDate(dateBand, model.project.currentDate);
+
+ const parts: string[] = [
+ ``,
+ "",
+ ` `,
+ `${escapeXml(model.project.name || "-")} `
+ ];
+
+ const chartOriginY = TOP_PADDING + HEADER_HEIGHT;
+
+ for (let index = 0; index < dateBand.length; index += 1) {
+ const day = dateBand[index];
+ const x = chartOriginX + (index * DAY_WIDTH);
+ const isHoliday = holidaySet.has(day);
+ const isWeekend = isWeeklyNonWorkingDay(day, nonWorkingDayTypes);
+ const fill = isHoliday ? "#fce4ec" : (isWeekend ? "#eef3f8" : "#ffffff");
+ parts.push(` `);
+ parts.push(` `);
+ parts.push(`${escapeXml(formatSvgAxisDate(day))} `);
+ }
+ parts.push(` `);
+
+ if (todayIndex >= 0) {
+ const todayX = chartOriginX + (todayIndex * DAY_WIDTH) + (DAY_WIDTH / 2);
+ parts.push(` `);
+ }
+
+ for (const row of rows) {
+ const rowY = chartOriginY + row.y;
+ if (row.startIndex !== null && row.endIndex !== null) {
+ const barX = chartOriginX + (row.startIndex * DAY_WIDTH) + 6;
+ const barWidth = Math.max(12, ((row.endIndex - row.startIndex + 1) * DAY_WIDTH) - 12);
+ const barY = rowY + 8;
+ if (row.kind === "milestone") {
+ const centerX = chartOriginX + (row.startIndex * DAY_WIDTH) + (DAY_WIDTH / 2);
+ const centerY = rowY + (ROW_HEIGHT / 2);
+ const isCompleted = (row.task.percentComplete || 0) >= 100;
+ const fill = isCompleted ? "#d9efff" : "#ffffff";
+ parts.push(` `);
+ } else if (row.kind === "phase") {
+ const lineY = rowY + (ROW_HEIGHT / 2);
+ const startX = barX;
+ const endX = barX + barWidth;
+ const trackStroke = "#8eb9ea";
+ const progressStroke = "#2f79d0";
+ const phaseStrokeWidth = 3;
+ const progressEndX = startX + Math.max(0, Math.min(barWidth, Math.round(barWidth * (Math.max(0, Math.min(100, row.task.percentComplete || 0)) / 100))));
+ parts.push(` `);
+ if (progressEndX > startX) {
+ parts.push(` `);
+ }
+ } else {
+ const trackFill = "#d9efff";
+ const trackStroke = "#4f95d6";
+ const progressFill = "#3f86d8";
+ parts.push(` `);
+ const progressWidth = Math.max(0, Math.min(barWidth, Math.round(barWidth * (Math.max(0, Math.min(100, row.task.percentComplete || 0)) / 100))));
+ if (progressWidth > 0) {
+ parts.push(` `);
+ }
+ }
+ }
+ const labelPlacement = labelPlacements[rows.indexOf(row)];
+ parts.push(`${escapeXml(formatTaskLabel(row.task, labelMode))} `);
+ }
+
+ parts.push(" ");
+ return parts.join("");
+ }
+
+ function buildTaskRows(tasks: TaskModel[], dateBand: string[]): NativeSvgTaskRow[] {
+ return tasks.map((task, index) => ({
+ task,
+ label: task.name || "-",
+ kind: task.summary ? "phase" : (task.milestone ? "milestone" : "task"),
+ startIndex: indexOfDate(dateBand, task.start),
+ endIndex: indexOfDate(dateBand, task.finish),
+ y: index * ROW_HEIGHT
+ }));
+ }
+
+ function formatTaskLabel(task: TaskModel, labelMode: "near" | "list"): string {
+ if (labelMode === "list") {
+ return `${" ".repeat(Math.max(0, task.outlineLevel - 1))}${task.name || "-"}`;
+ }
+ return task.name || "-";
+ }
+
+ function resolveLabelPlacement(
+ row: NativeSvgTaskRow,
+ chartOriginX: number,
+ chartWidth: number,
+ labelMode: "near" | "list"
+ ): NativeSvgLabelPlacement {
+ if (labelMode === "list" || row.startIndex === null || row.endIndex === null) {
+ return { x: LEFT_PADDING + 10, anchor: "start", width: estimateLabelWidth(row.label, row.kind === "phase") };
+ }
+
+ const textWidth = estimateLabelWidth(row.label, row.kind === "phase");
+ const gap = 12;
+ const shapeStartX = row.kind === "milestone"
+ ? chartOriginX + (row.startIndex * DAY_WIDTH) + (DAY_WIDTH / 2) - 13
+ : chartOriginX + (row.startIndex * DAY_WIDTH) + 6;
+ const shapeEndX = row.kind === "milestone"
+ ? chartOriginX + (row.startIndex * DAY_WIDTH) + (DAY_WIDTH / 2) + 13
+ : chartOriginX + (row.endIndex * DAY_WIDTH) + DAY_WIDTH - 6;
+ const chartMidX = chartOriginX + (chartWidth / 2);
+ if (shapeEndX <= chartMidX) {
+ return { x: shapeEndX + gap, anchor: "start", width: textWidth };
+ }
+ return { x: shapeStartX - gap, anchor: "end", width: textWidth };
+ }
+
+ function estimateLabelWidth(label: string, isPhase: boolean): number {
+ const basePerChar = isPhase ? 14 : 13;
+ return Math.max(48, Math.ceil(String(label || "").length * basePerChar));
+ }
+
+ function formatSvgAxisDate(day: string): string {
+ const match = day.match(/^(\d{4})-(\d{2})-(\d{2})$/);
+ if (!match) {
+ return day;
+ }
+ return `${Number(match[2])}/${Number(match[3])}`;
+ }
+
+ function indexOfDate(dateBand: string[], value: string | undefined): number | null {
+ const key = String(value || "").slice(0, 10);
+ if (!key) {
+ return null;
+ }
+ const index = dateBand.indexOf(key);
+ return index >= 0 ? index : null;
+ }
+
+ function collectWbsHolidayDates(model: ProjectModel): string[] {
+ const holidaySet = new Set();
+ for (const calendar of model.calendars) {
+ for (const exception of calendar.exceptions || []) {
+ if (exception.dayWorking !== false && (exception.workingTimes || []).length > 0) {
+ continue;
+ }
+ for (const day of expandExceptionDays(exception)) {
+ holidaySet.add(day);
+ }
+ }
+ }
+ return Array.from(holidaySet).sort();
+ }
+
+ function expandExceptionDays(exception: CalendarExceptionModel): string[] {
+ const singleDay = exception.fromDate ? formatDateOnly(parseDateOnly(exception.fromDate)) : "";
+ if (!exception.fromDate || !exception.toDate) {
+ return singleDay ? [singleDay] : [];
+ }
+ return buildDateBand(exception.fromDate, exception.toDate);
+ }
+
+ function resolveProjectCalendar(model: ProjectModel): CalendarModel | undefined {
+ if (model.project.calendarUID) {
+ const projectCalendar = model.calendars.find((calendar) => calendar.uid === model.project.calendarUID);
+ if (projectCalendar) {
+ return projectCalendar;
+ }
+ }
+ return model.calendars.find((calendar) => calendar.isBaseCalendar) || model.calendars[0];
+ }
+
+ function resolveCalendarDayWorking(
+ calendarByUid: Map,
+ calendar: CalendarModel | undefined,
+ dayType: number,
+ visiting = new Set()
+ ): boolean | undefined {
+ if (!calendar) {
+ return undefined;
+ }
+ if (visiting.has(calendar.uid)) {
+ return undefined;
+ }
+ visiting.add(calendar.uid);
+ const weekDay = calendar.weekDays.find((item) => item.dayType === dayType);
+ if (weekDay) {
+ return weekDay.dayWorking;
+ }
+ if (calendar.baseCalendarUID) {
+ return resolveCalendarDayWorking(calendarByUid, calendarByUid.get(calendar.baseCalendarUID), dayType, visiting);
+ }
+ return undefined;
+ }
+
+ function collectProjectNonWorkingDayTypes(model: ProjectModel): Set {
+ const calendarByUid = new Map(model.calendars.map((calendar) => [calendar.uid, calendar]));
+ const projectCalendar = resolveProjectCalendar(model);
+ const nonWorkingDayTypes = new Set();
+ for (let dayType = 1; dayType <= 7; dayType += 1) {
+ const dayWorking = resolveCalendarDayWorking(calendarByUid, projectCalendar, dayType);
+ if (dayWorking === false) {
+ nonWorkingDayTypes.add(dayType);
+ continue;
+ }
+ if (dayWorking === undefined && (dayType === 1 || dayType === 7)) {
+ nonWorkingDayTypes.add(dayType);
+ }
+ }
+ return nonWorkingDayTypes;
+ }
+
+ function buildDateBand(startDate: string | undefined, finishDate: string | undefined): string[] {
+ const start = parseDateOnly(startDate);
+ const finish = parseDateOnly(finishDate);
+ if (!start || !finish || start.getTime() > finish.getTime()) {
+ return [];
+ }
+ const days: string[] = [];
+ const cursor = new Date(start.getTime());
+ while (cursor.getTime() <= finish.getTime()) {
+ days.push(formatDateOnly(cursor));
+ cursor.setDate(cursor.getDate() + 1);
+ }
+ return days;
+ }
+
+ function buildDisplayDateBand(
+ startDate: string | undefined,
+ finishDate: string | undefined,
+ baseDate: string | undefined,
+ displayDaysBeforeBaseDate: number | undefined,
+ displayDaysAfterBaseDate: number | undefined,
+ holidaySet: Set,
+ nonWorkingDayTypes: Set,
+ useBusinessDaysForDisplayRange: boolean | undefined
+ ): string[] {
+ const fullBand = buildDateBand(startDate, finishDate);
+ const before = normalizeDisplayDayCount(displayDaysBeforeBaseDate);
+ const after = normalizeDisplayDayCount(displayDaysAfterBaseDate);
+ if (before === null && after === null) {
+ return fullBand;
+ }
+ const base = parseDateOnly(baseDate);
+ if (!base || fullBand.length === 0) {
+ return fullBand;
+ }
+ const projectStart = parseDateOnly(startDate);
+ const projectFinish = parseDateOnly(finishDate);
+ if (!projectStart || !projectFinish) {
+ return fullBand;
+ }
+ const from = useBusinessDaysForDisplayRange
+ ? shiftBusinessDays(base, -(before || 0), holidaySet, nonWorkingDayTypes)
+ : shiftCalendarDays(base, -(before || 0));
+ const to = useBusinessDaysForDisplayRange
+ ? shiftBusinessDays(base, after || 0, holidaySet, nonWorkingDayTypes)
+ : shiftCalendarDays(base, after || 0);
+ const clampedStart = from.getTime() < projectStart.getTime() ? projectStart : from;
+ const clampedFinish = to.getTime() > projectFinish.getTime() ? projectFinish : to;
+ if (clampedStart.getTime() > clampedFinish.getTime()) {
+ return fullBand;
+ }
+ return buildDateBand(formatDateOnly(clampedStart), formatDateOnly(clampedFinish));
+ }
+
+ function normalizeDisplayDayCount(value: number | undefined): number | null {
+ if (value === undefined || value === null || !Number.isFinite(value)) {
+ return null;
+ }
+ return Math.max(0, Math.floor(value));
+ }
+
+ function shiftCalendarDays(base: Date, offset: number): Date {
+ const result = new Date(base.getTime());
+ result.setDate(result.getDate() + offset);
+ return result;
+ }
+
+ function shiftBusinessDays(base: Date, offset: number, holidaySet: Set, nonWorkingDayTypes: Set): Date {
+ const result = new Date(base.getTime());
+ const direction = offset < 0 ? -1 : 1;
+ let remaining = Math.abs(offset);
+ while (remaining > 0) {
+ result.setDate(result.getDate() + direction);
+ const day = formatDateOnly(result);
+ if (isWeeklyNonWorkingDay(day, nonWorkingDayTypes) || holidaySet.has(day)) {
+ continue;
+ }
+ remaining -= 1;
+ }
+ return result;
+ }
+
+ function isWeeklyNonWorkingDay(day: string, nonWorkingDayTypes: Set): boolean {
+ const date = parseDateOnly(day);
+ if (!date) {
+ return false;
+ }
+ const dayType = date.getDay() === 0 ? 1 : date.getDay() + 1;
+ return nonWorkingDayTypes.has(dayType);
+ }
+
+ function parseDateOnly(value: string | undefined): Date | null {
+ const text = String(value || "").trim().slice(0, 10);
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) {
+ return null;
+ }
+ const parsed = new Date(`${text}T00:00:00`);
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+ }
+
+ function formatDateOnly(value: Date | null): string {
+ if (!value) {
+ return "";
+ }
+ const year = value.getFullYear();
+ const month = String(value.getMonth() + 1).padStart(2, "0");
+ const day = String(value.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+ }
+
+ function escapeXml(value: string): string {
+ return String(value || "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll("\"", """);
+ }
+
+ (globalThis as typeof globalThis & {
+ __mikuprojectNativeSvg?: {
+ exportNativeSvg: typeof exportNativeSvg;
+ };
+ }).__mikuprojectNativeSvg = {
+ exportNativeSvg
+ };
+})();
diff --git a/src/ts/project-workbook-json.ts b/src/ts/project-workbook-json.ts
new file mode 100644
index 0000000..b0a76c5
--- /dev/null
+++ b/src/ts/project-workbook-json.ts
@@ -0,0 +1,297 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ const workbookSchema = (globalThis as typeof globalThis & {
+ __mikuprojectProjectWorkbookSchema?: {
+ SHEET_NAMES: readonly ["Project", "Tasks", "Resources", "Assignments", "Calendars", "NonWorkingDays"];
+ HEADER_ROW_INDEX: number;
+ DATA_ROW_START_INDEX: number;
+ PROJECT_FIELD_ORDER: readonly string[];
+ SHEET_HEADERS: {
+ Tasks: readonly string[];
+ Resources: readonly string[];
+ Assignments: readonly string[];
+ Calendars: readonly string[];
+ NonWorkingDays: readonly string[];
+ };
+ };
+ }).__mikuprojectProjectWorkbookSchema;
+
+ if (!workbookSchema) {
+ throw new Error("mikuproject Project Workbook Schema module is not loaded");
+ }
+
+ const {
+ SHEET_NAMES,
+ HEADER_ROW_INDEX,
+ DATA_ROW_START_INDEX,
+ PROJECT_FIELD_ORDER,
+ SHEET_HEADERS
+ } = workbookSchema;
+
+ type XlsxCellLike = {
+ value?: string | number | boolean;
+ };
+
+ type XlsxWorkbookLike = {
+ sheets: Array<{
+ name: string;
+ rows: Array<{
+ cells: XlsxCellLike[];
+ }>;
+ }>;
+ };
+
+ type ImportChange = {
+ scope: "project" | "tasks" | "resources" | "assignments" | "calendars";
+ uid: string;
+ label: string;
+ field: string;
+ before: string | number | boolean | undefined;
+ after: string | number | boolean;
+ };
+
+ type WorkbookJsonWarning = {
+ message: string;
+ };
+
+ type WorkbookJsonRow = Record;
+
+ type WorkbookJsonDocument = {
+ format: "mikuproject_workbook_json";
+ version: 1;
+ sheets: {
+ Project?: WorkbookJsonRow[];
+ Tasks?: WorkbookJsonRow[];
+ Resources?: WorkbookJsonRow[];
+ Assignments?: WorkbookJsonRow[];
+ Calendars?: WorkbookJsonRow[];
+ NonWorkingDays?: WorkbookJsonRow[];
+ [sheetName: string]: WorkbookJsonRow[] | undefined;
+ };
+ };
+
+ const projectXlsx = (globalThis as typeof globalThis & {
+ __mikuprojectProjectXlsx?: {
+ exportProjectWorkbook: (model: ProjectModel) => XlsxWorkbookLike;
+ importProjectWorkbookDetailed: (workbook: XlsxWorkbookLike, baseModel: ProjectModel) => {
+ model: ProjectModel;
+ changes: ImportChange[];
+ };
+ };
+ }).__mikuprojectProjectXlsx;
+
+ if (!projectXlsx) {
+ throw new Error("mikuproject Project XLSX module is not loaded");
+ }
+
+ function exportProjectWorkbookJson(model: ProjectModel): WorkbookJsonDocument {
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ return {
+ format: "mikuproject_workbook_json",
+ version: 1,
+ sheets: {
+ Project: exportProjectSheetRows(workbook),
+ Tasks: exportTabularSheetRows(workbook, "Tasks"),
+ Resources: exportTabularSheetRows(workbook, "Resources"),
+ Assignments: exportTabularSheetRows(workbook, "Assignments"),
+ Calendars: exportTabularSheetRows(workbook, "Calendars"),
+ NonWorkingDays: exportTabularSheetRows(workbook, "NonWorkingDays")
+ }
+ };
+ }
+
+ function importProjectWorkbookJson(documentLike: unknown, baseModel: ProjectModel): {
+ model: ProjectModel;
+ changes: ImportChange[];
+ warnings: WorkbookJsonWarning[];
+ } {
+ const validation = validateWorkbookJsonDocument(documentLike);
+ const document = validation.document;
+ const workbook = {
+ sheets: [
+ buildProjectSheet(document.sheets.Project || []),
+ buildTabularSheet("Tasks", document.sheets.Tasks || [], SHEET_HEADERS.Tasks),
+ buildTabularSheet("Resources", document.sheets.Resources || [], SHEET_HEADERS.Resources),
+ buildTabularSheet("Assignments", document.sheets.Assignments || [], SHEET_HEADERS.Assignments),
+ buildTabularSheet("Calendars", document.sheets.Calendars || [], SHEET_HEADERS.Calendars),
+ buildTabularSheet("NonWorkingDays", document.sheets.NonWorkingDays || [], SHEET_HEADERS.NonWorkingDays)
+ ]
+ };
+ const result = projectXlsx.importProjectWorkbookDetailed(workbook, baseModel);
+ return {
+ ...result,
+ warnings: validation.warnings
+ };
+ }
+
+ function validateWorkbookJsonDocument(documentLike: unknown): {
+ document: WorkbookJsonDocument;
+ warnings: WorkbookJsonWarning[];
+ } {
+ if (!documentLike || typeof documentLike !== "object") {
+ throw new Error("workbook JSON がオブジェクトではありません");
+ }
+ const document = documentLike as Partial;
+ if (document.format !== "mikuproject_workbook_json") {
+ throw new Error("format が mikuproject_workbook_json ではありません");
+ }
+ if (document.version !== 1) {
+ throw new Error("version は 1 である必要があります");
+ }
+ if (!document.sheets || typeof document.sheets !== "object") {
+ throw new Error("sheets がありません");
+ }
+ const warnings: WorkbookJsonWarning[] = [];
+ for (const [sheetName, rows] of Object.entries(document.sheets)) {
+ if (!SHEET_NAMES.includes(sheetName as typeof SHEET_NAMES[number])) {
+ warnings.push({ message: `未知の sheet は無視します: ${sheetName}` });
+ continue;
+ }
+ if (!Array.isArray(rows)) {
+ throw new Error(`sheets.${sheetName} は配列である必要があります`);
+ }
+ for (const [rowIndex, row] of rows.entries()) {
+ if (!row || typeof row !== "object" || Array.isArray(row)) {
+ throw new Error(`sheets.${sheetName} にオブジェクトではない行があります`);
+ }
+ for (const key of Object.keys(row)) {
+ if (!isKnownColumn(sheetName as typeof SHEET_NAMES[number], key)) {
+ warnings.push({ message: `未知の列は無視します: ${sheetName}[${rowIndex}].${key}` });
+ }
+ }
+ }
+ }
+ return {
+ document: document as WorkbookJsonDocument,
+ warnings
+ };
+ }
+
+ function exportProjectSheetRows(workbook: XlsxWorkbookLike): WorkbookJsonRow[] {
+ const sheet = workbook.sheets.find((item) => item.name === "Project");
+ if (!sheet) {
+ return [];
+ }
+ const rows: WorkbookJsonRow[] = [];
+ for (const row of sheet.rows.slice(DATA_ROW_START_INDEX)) {
+ const field = toJsonScalar(row.cells[0]?.value);
+ if (typeof field !== "string" || !PROJECT_FIELD_ORDER.includes(field as typeof PROJECT_FIELD_ORDER[number])) {
+ continue;
+ }
+ rows.push({
+ Field: field,
+ Value: toJsonScalar(row.cells[1]?.value)
+ });
+ }
+ return rows;
+ }
+
+ function exportTabularSheetRows(workbook: XlsxWorkbookLike, sheetName: keyof typeof SHEET_HEADERS): WorkbookJsonRow[] {
+ const sheet = workbook.sheets.find((item) => item.name === sheetName);
+ if (!sheet) {
+ return [];
+ }
+ const headers = readHeaderRow(sheet);
+ return sheet.rows.slice(DATA_ROW_START_INDEX).map((row) => {
+ const item: WorkbookJsonRow = {};
+ headers.forEach((header, index) => {
+ item[header] = toJsonScalar(row.cells[index]?.value);
+ });
+ return item;
+ });
+ }
+
+ function buildProjectSheet(rows: WorkbookJsonRow[]): XlsxWorkbookLike["sheets"][number] {
+ const valueByField = new Map();
+ for (const row of rows) {
+ const field = typeof row.Field === "string" ? row.Field : "";
+ if (!PROJECT_FIELD_ORDER.includes(field as typeof PROJECT_FIELD_ORDER[number])) {
+ continue;
+ }
+ valueByField.set(field, toWorkbookScalar(row.Value));
+ }
+ return {
+ name: "Project",
+ rows: [
+ { cells: [{ value: "Project" }, {}] },
+ { cells: [{ value: "Basic Info" }, {}] },
+ { cells: [{ value: "Field" }, { value: "Value" }] },
+ ...PROJECT_FIELD_ORDER.map((field) => ({
+ cells: [
+ { value: field },
+ { value: valueByField.get(field) }
+ ]
+ }))
+ ]
+ };
+ }
+
+ function buildTabularSheet(
+ sheetName: keyof typeof SHEET_HEADERS,
+ rows: WorkbookJsonRow[],
+ headers: readonly string[]
+ ): XlsxWorkbookLike["sheets"][number] {
+ return {
+ name: sheetName,
+ rows: [
+ { cells: [{ value: sheetName }] },
+ { cells: [{ value: `${sheetName} List` }] },
+ { cells: headers.map((header) => ({ value: header })) },
+ ...rows.map((row) => ({
+ cells: headers.map((header) => ({
+ value: toWorkbookScalar(row[header])
+ }))
+ }))
+ ]
+ };
+ }
+
+ function readHeaderRow(sheet: XlsxWorkbookLike["sheets"][number]): string[] {
+ return (sheet.rows[HEADER_ROW_INDEX]?.cells || [])
+ .map((cell) => (typeof cell.value === "string" ? cell.value : ""))
+ .filter((value) => value !== "");
+ }
+
+ function toJsonScalar(value: string | number | boolean | undefined): string | number | boolean | null {
+ if (value === undefined) {
+ return null;
+ }
+ return value;
+ }
+
+ function toWorkbookScalar(value: string | number | boolean | null | undefined): string | number | boolean | undefined {
+ if (value === null || value === undefined) {
+ return undefined;
+ }
+ return value;
+ }
+
+ function isKnownColumn(sheetName: typeof SHEET_NAMES[number], key: string): boolean {
+ if (sheetName === "Project") {
+ return key === "Field" || key === "Value";
+ }
+ return (SHEET_HEADERS[sheetName as keyof typeof SHEET_HEADERS] || []).includes(key);
+ }
+
+ (globalThis as typeof globalThis & {
+ __mikuprojectProjectWorkbookJson?: {
+ exportProjectWorkbookJson: (model: ProjectModel) => WorkbookJsonDocument;
+ importProjectWorkbookJson: (documentLike: unknown, baseModel: ProjectModel) => {
+ model: ProjectModel;
+ changes: ImportChange[];
+ warnings: WorkbookJsonWarning[];
+ };
+ validateWorkbookJsonDocument: (documentLike: unknown) => {
+ document: WorkbookJsonDocument;
+ warnings: WorkbookJsonWarning[];
+ };
+ };
+ }).__mikuprojectProjectWorkbookJson = {
+ exportProjectWorkbookJson,
+ importProjectWorkbookJson,
+ validateWorkbookJsonDocument
+ };
+})();
diff --git a/src/ts/project-workbook-schema.ts b/src/ts/project-workbook-schema.ts
new file mode 100644
index 0000000..f30592c
--- /dev/null
+++ b/src/ts/project-workbook-schema.ts
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ const SHEET_NAMES = [
+ "Project",
+ "Tasks",
+ "Resources",
+ "Assignments",
+ "Calendars",
+ "NonWorkingDays"
+ ] as const;
+
+ const HEADER_ROW_INDEX = 2;
+ const DATA_ROW_START_INDEX = 3;
+
+ const PROJECT_FIELD_ORDER = [
+ "Name",
+ "Title",
+ "Author",
+ "Company",
+ "StartDate",
+ "FinishDate",
+ "CurrentDate",
+ "StatusDate",
+ "CalendarUID",
+ "MinutesPerDay",
+ "MinutesPerWeek",
+ "DaysPerMonth",
+ "ScheduleFromStart",
+ "OutlineCodes",
+ "WBSMasks",
+ "ExtendedAttributes"
+ ] as const;
+
+ const PROJECT_EDITABLE_FIELDS = [
+ "Name",
+ "Title",
+ "Author",
+ "Company",
+ "StartDate",
+ "FinishDate",
+ "CurrentDate",
+ "StatusDate",
+ "CalendarUID",
+ "MinutesPerDay",
+ "MinutesPerWeek",
+ "DaysPerMonth",
+ "ScheduleFromStart"
+ ] as const;
+
+ const SHEET_HEADERS = {
+ Tasks: [
+ "UID", "ID", "Name", "OutlineLevel", "OutlineNumber", "WBS",
+ "Start", "Finish", "Duration", "PercentComplete", "PercentWorkComplete",
+ "Milestone", "Summary", "Critical", "CalendarUID", "Predecessors", "Notes"
+ ],
+ Resources: [
+ "UID", "ID", "Name", "Type", "Initials", "Group", "MaxUnits",
+ "CalendarUID", "StandardRate", "OvertimeRate", "CostPerUse",
+ "Work", "ActualWork", "RemainingWork"
+ ],
+ Assignments: [
+ "UID", "TaskUID", "TaskName", "ResourceUID", "ResourceName", "Start",
+ "Finish", "Units", "Work", "ActualWork", "RemainingWork", "PercentWorkComplete"
+ ],
+ Calendars: [
+ "UID", "Name", "IsBaseCalendar", "BaseCalendarUID", "WeekDays", "Exceptions", "WorkWeeks"
+ ],
+ NonWorkingDays: [
+ "CalendarUID", "Index", "CalendarName", "Name", "Date", "FromDate", "ToDate", "DayWorking"
+ ]
+ } as const;
+
+ const IMPORTABLE_FIELDS = {
+ Project: PROJECT_EDITABLE_FIELDS,
+ Tasks: ["Name", "Start", "Finish", "PercentComplete", "PercentWorkComplete", "Notes"] as const,
+ Resources: ["Name", "Group", "MaxUnits"] as const,
+ Assignments: ["Units", "Work", "PercentWorkComplete"] as const,
+ Calendars: ["Name", "IsBaseCalendar", "BaseCalendarUID"] as const,
+ NonWorkingDays: ["Name", "Date", "FromDate", "ToDate", "DayWorking"] as const
+ } as const;
+
+ (globalThis as typeof globalThis & {
+ __mikuprojectProjectWorkbookSchema?: {
+ SHEET_NAMES: typeof SHEET_NAMES;
+ HEADER_ROW_INDEX: typeof HEADER_ROW_INDEX;
+ DATA_ROW_START_INDEX: typeof DATA_ROW_START_INDEX;
+ PROJECT_FIELD_ORDER: typeof PROJECT_FIELD_ORDER;
+ PROJECT_EDITABLE_FIELDS: typeof PROJECT_EDITABLE_FIELDS;
+ SHEET_HEADERS: typeof SHEET_HEADERS;
+ IMPORTABLE_FIELDS: typeof IMPORTABLE_FIELDS;
+ };
+ }).__mikuprojectProjectWorkbookSchema = {
+ SHEET_NAMES,
+ HEADER_ROW_INDEX,
+ DATA_ROW_START_INDEX,
+ PROJECT_FIELD_ORDER,
+ PROJECT_EDITABLE_FIELDS,
+ SHEET_HEADERS,
+ IMPORTABLE_FIELDS
+ };
+})();
diff --git a/src/ts/project-xlsx.ts b/src/ts/project-xlsx.ts
new file mode 100644
index 0000000..7fe6367
--- /dev/null
+++ b/src/ts/project-xlsx.ts
@@ -0,0 +1,995 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ const workbookSchema = (globalThis as typeof globalThis & {
+ __mikuprojectProjectWorkbookSchema?: {
+ HEADER_ROW_INDEX: number;
+ DATA_ROW_START_INDEX: number;
+ PROJECT_FIELD_ORDER: readonly string[];
+ PROJECT_EDITABLE_FIELDS: readonly string[];
+ SHEET_HEADERS: {
+ Tasks: readonly string[];
+ Resources: readonly string[];
+ Assignments: readonly string[];
+ Calendars: readonly string[];
+ NonWorkingDays: readonly string[];
+ };
+ };
+ }).__mikuprojectProjectWorkbookSchema;
+
+ if (!workbookSchema) {
+ throw new Error("mikuproject Project Workbook Schema module is not loaded");
+ }
+
+ const {
+ HEADER_ROW_INDEX,
+ DATA_ROW_START_INDEX,
+ PROJECT_FIELD_ORDER,
+ PROJECT_EDITABLE_FIELDS,
+ SHEET_HEADERS
+ } = workbookSchema;
+
+ type XlsxCellLike = {
+ value?: string | number | boolean;
+ numberFormat?: "general" | "integer" | "decimal" | "date" | "datetime" | "percent";
+ horizontalAlign?: "left" | "center" | "right";
+ bold?: boolean;
+ fontSize?: number;
+ fillColor?: string;
+ border?: "thin";
+ };
+
+ type XlsxWorkbookLike = {
+ sheets: Array<{
+ name: string;
+ columns?: Array<{ width?: number }>;
+ mergedRanges?: string[];
+ rows: Array<{
+ height?: number;
+ cells: XlsxCellLike[];
+ }>;
+ }>;
+ };
+
+ type ImportChange = {
+ scope: "project" | "tasks" | "resources" | "assignments" | "calendars";
+ uid: string;
+ label: string;
+ field: string;
+ before: string | number | boolean | undefined;
+ after: string | number | boolean;
+ };
+
+ const HEADER_FILL = "#D9EAF7";
+ const SECTION_FILL = "#BFD7EA";
+ const LABEL_FILL = "#EDF5FB";
+ const ALT_ROW_FILL = "#F9FBFD";
+ const DATE_FILL = "#FFF4E8";
+ const PERCENT_FILL = "#FCECF3";
+ const REFERENCE_FILL = "#EEF7F4";
+ const COUNT_FILL = "#F2F5F8";
+ const EDITABLE_FILL = "#FDE7C7";
+ const DURATION_FILL = "#FBF6ED";
+ const NOTES_FILL = "#FFFBEA";
+ const NAME_FILL = "#FAF6FF";
+ const WORK_FILL = "#F1F8FD";
+ const SUMMARY_FILL = "#E6F2E0";
+ const MILESTONE_FILL = "#FFF0CF";
+ const CRITICAL_FILL = "#F8DDE6";
+ const SHEET_THEMES = {
+ project: { section: "#BFD7EA", header: "#D9EAF7", label: "#EDF5FB" },
+ tasks: { section: "#D4E0EC", header: "#E6EDF4", label: "#F2F6FA" },
+ resources: { section: "#C8E3D8", header: "#DDF0E8", label: "#EFF8F4" },
+ assignments: { section: "#D7D2EC", header: "#E7E3F5", label: "#F2F0FA" },
+ calendars: { section: "#D7E3C4", header: "#E7F0DA", label: "#F2F7EA" },
+ nonWorkingDays: { section: "#E9C7D5", header: "#F4DDE6", label: "#FBEEF3" }
+ } as const;
+
+ function exportProjectWorkbook(model: ProjectModel): XlsxWorkbookLike {
+ return {
+ sheets: [
+ buildProjectSheet(model),
+ buildTasksSheet(model),
+ buildResourcesSheet(model),
+ buildAssignmentsSheet(model),
+ buildCalendarsSheet(model),
+ buildNonWorkingDaysSheet(model)
+ ]
+ };
+ }
+
+ function importProjectWorkbook(workbook: XlsxWorkbookLike, baseModel: ProjectModel): ProjectModel {
+ return importProjectWorkbookDetailed(workbook, baseModel).model;
+ }
+
+ function importProjectWorkbookDetailed(workbook: XlsxWorkbookLike, baseModel: ProjectModel): {
+ model: ProjectModel;
+ changes: ImportChange[];
+ } {
+ const nextModel = cloneProjectModel(baseModel);
+ const changes: ImportChange[] = [];
+ importProjectSheet(workbook, nextModel, changes);
+ importTasksSheet(workbook, nextModel, changes);
+ importResourcesSheet(workbook, nextModel, changes);
+ importAssignmentsSheet(workbook, nextModel, changes);
+ importCalendarsSheet(workbook, nextModel, changes);
+ importNonWorkingDaysSheet(workbook, nextModel, changes);
+ return {
+ model: nextModel,
+ changes
+ };
+ }
+
+ function importProjectSheet(workbook: XlsxWorkbookLike, model: ProjectModel, changes: ImportChange[]): void {
+ const projectSheet = workbook.sheets.find((sheet) => sheet.name === "Project");
+ if (!projectSheet) {
+ return;
+ }
+ const valueByField = new Map();
+ for (const row of projectSheet.rows.slice(DATA_ROW_START_INDEX)) {
+ const field = readStringCell(row.cells[0]);
+ if (!field) {
+ continue;
+ }
+ valueByField.set(field, row.cells[1]);
+ }
+ const projectLabel = model.project.name;
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "name", "Name", readStringCell(valueByField.get("Name")));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "title", "Title", readStringCell(valueByField.get("Title")));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "author", "Author", readStringCell(valueByField.get("Author")));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "company", "Company", readStringCell(valueByField.get("Company")));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "startDate", "StartDate", normalizeDateTimeInput(readStringCell(valueByField.get("StartDate"))));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "finishDate", "FinishDate", normalizeDateTimeInput(readStringCell(valueByField.get("FinishDate"))));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "currentDate", "CurrentDate", normalizeDateTimeInput(readStringCell(valueByField.get("CurrentDate"))));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "statusDate", "StatusDate", normalizeDateTimeInput(readStringCell(valueByField.get("StatusDate"))));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "calendarUID", "CalendarUID", readStringCell(valueByField.get("CalendarUID")));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "minutesPerDay", "MinutesPerDay", readNumberCell(valueByField.get("MinutesPerDay")));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "minutesPerWeek", "MinutesPerWeek", readNumberCell(valueByField.get("MinutesPerWeek")));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "daysPerMonth", "DaysPerMonth", readNumberCell(valueByField.get("DaysPerMonth")));
+ assignIfChanged(changes, "project", "project", projectLabel, model.project, "scheduleFromStart", "ScheduleFromStart", readBooleanCell(valueByField.get("ScheduleFromStart")));
+ }
+
+ function buildProjectSheet(model: ProjectModel) {
+ const project = model.project;
+ const rows = [
+ titleRow("Project", SHEET_THEMES.project.section),
+ titleRow("Basic Info", SHEET_THEMES.project.section),
+ headerRow(["Field", "Value"], SHEET_THEMES.project.header),
+ ...PROJECT_FIELD_ORDER.slice(0, 8).map((field) => keyValueRow(field, readProjectFieldValue(project, field), SHEET_THEMES.project.label)),
+ titleRow("Settings", SHEET_THEMES.project.section),
+ ...PROJECT_FIELD_ORDER.slice(8).map((field) => keyValueRow(field, readProjectFieldValue(project, field), SHEET_THEMES.project.label))
+ ];
+
+ return {
+ name: "Project",
+ columns: [{ width: 26 }, { width: 42 }],
+ mergedRanges: ["A11:B11"],
+ rows
+ };
+ }
+
+ function buildTasksSheet(model: ProjectModel) {
+ return {
+ name: "Tasks",
+ columns: [
+ { width: 10 }, { width: 8 }, { width: 28 }, { width: 12 },
+ { width: 14 }, { width: 12 }, { width: 20 }, { width: 20 },
+ { width: 14 }, { width: 16 }, { width: 18 }, { width: 12 },
+ { width: 12 }, { width: 12 }, { width: 12 }, { width: 20 },
+ { width: 34 }
+ ],
+ mergedRanges: [],
+ rows: [
+ sectionTitleRow("Tasks", 17, SHEET_THEMES.tasks.section),
+ sectionTitleRow("Task List", 17, SHEET_THEMES.tasks.section),
+ headerRow([...SHEET_HEADERS.Tasks], SHEET_THEMES.tasks.header),
+ ...model.tasks.map((task, index) => ({
+ cells: [
+ countCell(task.uid, index),
+ countCell(task.id, index),
+ editableCell(taskNameCell(task, index)),
+ countCell(task.outlineLevel, index),
+ textCell(task.outlineNumber, index),
+ textCell(task.wbs, index),
+ editableCell(dateTimeCell(task.start, index)),
+ editableCell(dateTimeCell(task.finish, index)),
+ durationCell(task.duration, index),
+ editableCell(percentCell(task.percentComplete, index)),
+ editableCell(percentCell(task.percentWorkComplete, index)),
+ taskFlagCell(task.milestone, index, MILESTONE_FILL),
+ taskFlagCell(task.summary, index, SUMMARY_FILL),
+ taskFlagCell(task.critical, index, CRITICAL_FILL),
+ referenceCell(task.calendarUID, index),
+ predecessorCell(task.predecessors.map((item) => item.predecessorUid).join(", "), index),
+ editableCell(notesCell(task.notes, index))
+ ]
+ }))
+ ]
+ };
+ }
+
+ function buildResourcesSheet(model: ProjectModel) {
+ const resourceRows = model.resources.length > 0
+ ? model.resources.map((resource, index) => ({
+ cells: [
+ countCell(resource.uid, index),
+ countCell(resource.id, index),
+ editableCell(entityNameCell(resource.name, index)),
+ countCell(resource.type, index),
+ textCell(resource.initials, index),
+ editableCell(textCell(resource.group, index)),
+ editableCell(countCell(resource.maxUnits, index)),
+ referenceCell(resource.calendarUID, index),
+ textCell(resource.standardRate, index),
+ textCell(resource.overtimeRate, index),
+ countCell(resource.costPerUse, index),
+ workCell(resource.work, index),
+ workCell(resource.actualWork, index),
+ workCell(resource.remainingWork, index)
+ ]
+ }))
+ : [{
+ cells: [
+ countCell(undefined, 0),
+ countCell(undefined, 0),
+ editableCell(entityNameCell(undefined, 0)),
+ countCell(undefined, 0),
+ textCell(undefined, 0),
+ editableCell(textCell(undefined, 0)),
+ editableCell(countCell(undefined, 0)),
+ referenceCell(undefined, 0),
+ textCell(undefined, 0),
+ textCell(undefined, 0),
+ countCell(undefined, 0),
+ workCell(undefined, 0),
+ workCell(undefined, 0),
+ workCell(undefined, 0)
+ ]
+ }];
+
+ return {
+ name: "Resources",
+ columns: [
+ { width: 10 }, { width: 8 }, { width: 24 }, { width: 10 },
+ { width: 12 }, { width: 18 }, { width: 12 }, { width: 12 },
+ { width: 14 }, { width: 14 }, { width: 12 }, { width: 14 },
+ { width: 14 }, { width: 14 }
+ ],
+ mergedRanges: [],
+ rows: [
+ sectionTitleRow("Resources", 14, SHEET_THEMES.resources.section),
+ sectionTitleRow("Resource List", 14, SHEET_THEMES.resources.section),
+ headerRow([...SHEET_HEADERS.Resources], SHEET_THEMES.resources.header),
+ ...resourceRows
+ ]
+ };
+ }
+
+ function buildAssignmentsSheet(model: ProjectModel) {
+ const taskNameByUid = new Map(model.tasks.map((task) => [task.uid, task.name]));
+ const resourceNameByUid = new Map(model.resources.map((resource) => [resource.uid, resource.name]));
+ const assignmentRows = model.assignments.length > 0
+ ? model.assignments.map((assignment, index) => ({
+ cells: [
+ countCell(assignment.uid, index),
+ referenceCell(assignment.taskUid, index),
+ entityNameCell(taskNameByUid.get(assignment.taskUid), index),
+ referenceCell(assignment.resourceUid, index),
+ entityNameCell(resourceNameByUid.get(assignment.resourceUid), index),
+ dateTimeCell(assignment.start, index),
+ dateTimeCell(assignment.finish, index),
+ editableCell(countCell(assignment.units, index)),
+ editableCell(workCell(assignment.work, index)),
+ workCell(assignment.actualWork, index),
+ workCell(assignment.remainingWork, index),
+ editableCell(percentCell(assignment.percentWorkComplete, index))
+ ]
+ }))
+ : [{
+ cells: [
+ countCell(undefined, 0),
+ referenceCell(undefined, 0),
+ entityNameCell(undefined, 0),
+ referenceCell(undefined, 0),
+ entityNameCell(undefined, 0),
+ dateTimeCell(undefined, 0),
+ dateTimeCell(undefined, 0),
+ editableCell(countCell(undefined, 0)),
+ editableCell(workCell(undefined, 0)),
+ workCell(undefined, 0),
+ workCell(undefined, 0),
+ editableCell(percentCell(undefined, 0))
+ ]
+ }];
+
+ return {
+ name: "Assignments",
+ columns: [
+ { width: 10 }, { width: 10 }, { width: 24 }, { width: 12 },
+ { width: 24 }, { width: 20 }, { width: 20 }, { width: 10 },
+ { width: 14 }, { width: 14 }, { width: 14 }, { width: 18 }
+ ],
+ mergedRanges: [],
+ rows: [
+ sectionTitleRow("Assignments", 12, SHEET_THEMES.assignments.section),
+ sectionTitleRow("Assignment List", 12, SHEET_THEMES.assignments.section),
+ headerRow([...SHEET_HEADERS.Assignments], SHEET_THEMES.assignments.header),
+ ...assignmentRows
+ ]
+ };
+ }
+
+ function buildCalendarsSheet(model: ProjectModel) {
+ return {
+ name: "Calendars",
+ columns: [
+ { width: 10 }, { width: 24 }, { width: 14 }, { width: 16 },
+ { width: 10 }, { width: 12 }, { width: 10 }
+ ],
+ mergedRanges: [],
+ rows: [
+ sectionTitleRow("Calendars", 7, SHEET_THEMES.calendars.section),
+ sectionTitleRow("Calendar List", 7, SHEET_THEMES.calendars.section),
+ headerRow([...SHEET_HEADERS.Calendars], SHEET_THEMES.calendars.header),
+ ...model.calendars.map((calendar, index) => ({
+ cells: [
+ countCell(calendar.uid, index),
+ editableCell(entityNameCell(calendar.name, index)),
+ editableCell(countCell(calendar.isBaseCalendar, index)),
+ editableCell(referenceCell(calendar.baseCalendarUID, index)),
+ countCell(calendar.weekDays.length, index),
+ countCell(calendar.exceptions.length, index),
+ countCell(calendar.workWeeks.length, index)
+ ]
+ }))
+ ]
+ };
+ }
+
+ function buildNonWorkingDaysSheet(model: ProjectModel) {
+ const rows = model.calendars.flatMap((calendar) => calendar.exceptions.map((exception, index) => ({
+ cells: [
+ countCell(calendar.uid, index),
+ countCell(index + 1, index),
+ textCell(calendar.name, index),
+ editableCell(entityNameCell(exception.name, index)),
+ editableCell(dateOnlyCell(formatExceptionDate(exception), index)),
+ editableCell(dateOnlyCell(formatExceptionBoundaryDate(exception.fromDate), index)),
+ editableCell(dateOnlyCell(formatExceptionBoundaryDate(exception.toDate), index)),
+ editableCell(countCell(exception.dayWorking, index))
+ ]
+ })));
+
+ return {
+ name: "NonWorkingDays",
+ columns: [
+ { width: 12 }, { width: 10 }, { width: 22 }, { width: 24 },
+ { width: 14 }, { width: 22 }, { width: 22 }, { width: 12 }
+ ],
+ mergedRanges: [],
+ rows: [
+ sectionTitleRow("NonWorkingDays", 8, SHEET_THEMES.nonWorkingDays.section),
+ sectionTitleRow("Calendar Exceptions", 8, SHEET_THEMES.nonWorkingDays.section),
+ headerRow([...SHEET_HEADERS.NonWorkingDays], SHEET_THEMES.nonWorkingDays.header),
+ ...rows
+ ]
+ };
+ }
+
+ function headerRow(labels: string[], fillColor = HEADER_FILL) {
+ return {
+ height: 24,
+ cells: labels.map((label) => ({
+ value: label,
+ bold: true,
+ fillColor,
+ border: "thin",
+ horizontalAlign: "center"
+ }))
+ };
+ }
+
+ function titleRow(title: string, fillColor = SECTION_FILL) {
+ return {
+ height: 28,
+ cells: [
+ {
+ value: title,
+ bold: true,
+ fontSize: 16,
+ fillColor,
+ horizontalAlign: "left"
+ },
+ {
+ fillColor
+ }
+ ]
+ };
+ }
+
+ function sectionTitleRow(title: string, columnCount: number, fillColor = SECTION_FILL) {
+ return {
+ height: 26,
+ cells: [
+ {
+ value: title,
+ bold: true,
+ fontSize: 14,
+ fillColor,
+ horizontalAlign: "left"
+ },
+ ...Array.from({ length: Math.max(0, columnCount - 1) }, () => ({
+ fillColor
+ }))
+ ]
+ };
+ }
+
+ function keyValueRow(label: string, value: string | number | boolean | undefined, labelFill = LABEL_FILL) {
+ return {
+ cells: [
+ {
+ value: label,
+ bold: true,
+ fillColor: labelFill,
+ border: "thin"
+ },
+ keyValueCell(label, value)
+ ]
+ };
+ }
+
+ function cell(value: string | number | boolean | undefined): XlsxCellLike {
+ if (value === undefined) {
+ return {};
+ }
+ return {
+ value: stringifyCellValue(value),
+ border: "thin"
+ };
+ }
+
+ function stringifyCellValue(value: string | number | boolean): string {
+ return typeof value === "string" ? value : String(value);
+ }
+
+ function keyValueCell(label: string, value: string | number | boolean | undefined): XlsxCellLike {
+ if (isEditableProjectLabel(label)) {
+ return editableCell(buildProjectValueCell(label, value));
+ }
+ return buildProjectValueCell(label, value);
+ }
+
+ function buildProjectValueCell(label: string, value: string | number | boolean | undefined): XlsxCellLike {
+ if (isDateTimeLabel(label)) {
+ return {
+ ...cell(formatDateTimeDisplay(value)),
+ fillColor: DATE_FILL
+ };
+ }
+ if (label === "Name" || label === "Title") {
+ return {
+ ...cell(value),
+ fillColor: NAME_FILL,
+ bold: true
+ };
+ }
+ if (label === "Author" || label === "Company") {
+ return {
+ ...cell(value),
+ fillColor: NAME_FILL
+ };
+ }
+ if (label === "CalendarUID") {
+ return {
+ ...cell(value),
+ fillColor: REFERENCE_FILL,
+ horizontalAlign: "center"
+ };
+ }
+ if (label === "ScheduleFromStart") {
+ return {
+ ...cell(value),
+ fillColor: COUNT_FILL,
+ horizontalAlign: "center"
+ };
+ }
+ return {
+ ...cell(value),
+ fillColor: isNumericSummaryLabel(label) ? COUNT_FILL : undefined
+ };
+ }
+
+ function textCell(value: string | number | boolean | undefined, rowIndex: number): XlsxCellLike {
+ return styledCell(value, rowIndex);
+ }
+
+ function taskNameCell(task: TaskModel, rowIndex: number): XlsxCellLike {
+ const fillColor = task.summary ? SUMMARY_FILL : (task.critical ? CRITICAL_FILL : undefined);
+ return {
+ ...styledCell(task.name, rowIndex, { fillColor }),
+ bold: task.summary || task.milestone
+ };
+ }
+
+ function taskFlagCell(value: string | number | boolean | undefined, rowIndex: number, activeFillColor: string): XlsxCellLike {
+ const isActive = value === true || value === 1 || value === "1";
+ return styledCell(value, rowIndex, {
+ fillColor: isActive ? activeFillColor : COUNT_FILL,
+ horizontalAlign: "center"
+ });
+ }
+
+ function referenceCell(value: string | number | boolean | undefined, rowIndex: number): XlsxCellLike {
+ return styledCell(value, rowIndex, {
+ fillColor: REFERENCE_FILL,
+ horizontalAlign: "center"
+ });
+ }
+
+ function countCell(value: string | number | boolean | undefined, rowIndex: number): XlsxCellLike {
+ return styledCell(value, rowIndex, {
+ fillColor: COUNT_FILL,
+ horizontalAlign: "center"
+ });
+ }
+
+ function percentCell(value: string | number | boolean | undefined, rowIndex: number): XlsxCellLike {
+ return styledCell(value, rowIndex, {
+ fillColor: PERCENT_FILL,
+ horizontalAlign: "center"
+ });
+ }
+
+ function durationCell(value: string | number | boolean | undefined, rowIndex: number): XlsxCellLike {
+ return styledCell(value, rowIndex, {
+ fillColor: DURATION_FILL,
+ horizontalAlign: "center"
+ });
+ }
+
+ function predecessorCell(value: string | number | boolean | undefined, rowIndex: number): XlsxCellLike {
+ return styledCell(value, rowIndex, {
+ fillColor: REFERENCE_FILL
+ });
+ }
+
+ function notesCell(value: string | number | boolean | undefined, rowIndex: number): XlsxCellLike {
+ return styledCell(value, rowIndex, {
+ fillColor: NOTES_FILL
+ });
+ }
+
+ function entityNameCell(value: string | number | boolean | undefined, rowIndex: number): XlsxCellLike {
+ return styledCell(value, rowIndex, {
+ fillColor: NAME_FILL
+ });
+ }
+
+ function workCell(value: string | number | boolean | undefined, rowIndex: number): XlsxCellLike {
+ return styledCell(value, rowIndex, {
+ fillColor: WORK_FILL
+ });
+ }
+
+ function editableCell(cellLike: XlsxCellLike): XlsxCellLike {
+ return {
+ ...cellLike,
+ border: cellLike.border || "thin",
+ fillColor: EDITABLE_FILL
+ };
+ }
+
+ function dateTimeCell(value: string | number | boolean | undefined, rowIndex: number): XlsxCellLike {
+ return styledCell(formatDateTimeDisplay(value), rowIndex, {
+ fillColor: DATE_FILL
+ });
+ }
+
+ function dateOnlyCell(value: string | number | boolean | undefined, rowIndex: number): XlsxCellLike {
+ return styledCell(value, rowIndex, {
+ fillColor: DATE_FILL,
+ horizontalAlign: "center"
+ });
+ }
+
+ function styledCell(
+ value: string | number | boolean | undefined,
+ rowIndex: number,
+ overrides: Partial = {}
+ ): XlsxCellLike {
+ const base = cell(value);
+ if (base.value === undefined) {
+ return base;
+ }
+ return {
+ ...base,
+ fillColor: overrides.fillColor || (rowIndex % 2 === 0 ? ALT_ROW_FILL : undefined),
+ horizontalAlign: overrides.horizontalAlign,
+ numberFormat: overrides.numberFormat
+ };
+ }
+
+ function formatDateTimeDisplay(value: string | number | boolean | undefined): string | number | boolean | undefined {
+ if (typeof value !== "string") {
+ return value;
+ }
+ return value.replace("T", " ");
+ }
+
+ function isDateTimeLabel(label: string): boolean {
+ return ["StartDate", "FinishDate", "CurrentDate", "StatusDate"].includes(label);
+ }
+
+ function isNumericSummaryLabel(label: string): boolean {
+ return ["OutlineCodes", "WBSMasks", "ExtendedAttributes", "MinutesPerDay", "MinutesPerWeek", "DaysPerMonth"].includes(label);
+ }
+
+ function isEditableProjectLabel(label: string): boolean {
+ return PROJECT_EDITABLE_FIELDS.includes(label);
+ }
+
+ function readProjectFieldValue(project: ProjectModel["project"], field: string): string | number | boolean | undefined {
+ switch (field) {
+ case "Name":
+ return project.name;
+ case "Title":
+ return project.title;
+ case "Author":
+ return project.author;
+ case "Company":
+ return project.company;
+ case "StartDate":
+ return project.startDate;
+ case "FinishDate":
+ return project.finishDate;
+ case "CurrentDate":
+ return project.currentDate;
+ case "StatusDate":
+ return project.statusDate;
+ case "CalendarUID":
+ return project.calendarUID;
+ case "MinutesPerDay":
+ return project.minutesPerDay;
+ case "MinutesPerWeek":
+ return project.minutesPerWeek;
+ case "DaysPerMonth":
+ return project.daysPerMonth;
+ case "ScheduleFromStart":
+ return project.scheduleFromStart;
+ case "OutlineCodes":
+ return project.outlineCodes.length;
+ case "WBSMasks":
+ return project.wbsMasks.length;
+ case "ExtendedAttributes":
+ return project.extendedAttributes.length;
+ default:
+ return undefined;
+ }
+ }
+
+ function cloneProjectModel(model: ProjectModel): ProjectModel {
+ return JSON.parse(JSON.stringify(model)) as ProjectModel;
+ }
+
+ function importTasksSheet(workbook: XlsxWorkbookLike, model: ProjectModel, changes: ImportChange[]): void {
+ const tasksSheet = workbook.sheets.find((sheet) => sheet.name === "Tasks");
+ if (!tasksSheet) {
+ return;
+ }
+ const columnIndexByLabel = readHeaderMap(tasksSheet, HEADER_ROW_INDEX);
+ const uidColumnIndex = columnIndexByLabel.get("UID");
+ if (uidColumnIndex === undefined) {
+ return;
+ }
+ const taskByUid = new Map(model.tasks.map((task) => [task.uid, task]));
+ for (const row of tasksSheet.rows.slice(DATA_ROW_START_INDEX)) {
+ const uid = readStringCell(row.cells[uidColumnIndex]);
+ if (!uid) {
+ continue;
+ }
+ const task = taskByUid.get(uid);
+ if (!task) {
+ continue;
+ }
+ const taskLabel = task.name;
+ assignIfChanged(changes, "tasks", task.uid, taskLabel, task, "name", "Name", readStringCellAt(row.cells, columnIndexByLabel.get("Name")));
+ assignIfChanged(changes, "tasks", task.uid, taskLabel, task, "start", "Start", normalizeDateTimeInput(readStringCellAt(row.cells, columnIndexByLabel.get("Start"))));
+ assignIfChanged(changes, "tasks", task.uid, taskLabel, task, "finish", "Finish", normalizeDateTimeInput(readStringCellAt(row.cells, columnIndexByLabel.get("Finish"))));
+ assignIfChanged(changes, "tasks", task.uid, taskLabel, task, "percentComplete", "PercentComplete", readNumberCellAt(row.cells, columnIndexByLabel.get("PercentComplete")));
+ assignIfChanged(changes, "tasks", task.uid, taskLabel, task, "percentWorkComplete", "PercentWorkComplete", readNumberCellAt(row.cells, columnIndexByLabel.get("PercentWorkComplete")));
+ assignIfChanged(changes, "tasks", task.uid, taskLabel, task, "notes", "Notes", readStringCellAt(row.cells, columnIndexByLabel.get("Notes")));
+ }
+ }
+
+ function importResourcesSheet(workbook: XlsxWorkbookLike, model: ProjectModel, changes: ImportChange[]): void {
+ const resourcesSheet = workbook.sheets.find((sheet) => sheet.name === "Resources");
+ if (!resourcesSheet) {
+ return;
+ }
+ const columnIndexByLabel = readHeaderMap(resourcesSheet, HEADER_ROW_INDEX);
+ const uidColumnIndex = columnIndexByLabel.get("UID");
+ if (uidColumnIndex === undefined) {
+ return;
+ }
+ const resourceByUid = new Map(model.resources.map((resource) => [resource.uid, resource]));
+ for (const row of resourcesSheet.rows.slice(DATA_ROW_START_INDEX)) {
+ const uid = readStringCell(row.cells[uidColumnIndex]);
+ if (!uid) {
+ continue;
+ }
+ const resource = resourceByUid.get(uid);
+ if (!resource) {
+ continue;
+ }
+ const resourceLabel = resource.name;
+ assignIfChanged(changes, "resources", resource.uid, resourceLabel, resource, "name", "Name", readStringCellAt(row.cells, columnIndexByLabel.get("Name")));
+ assignIfChanged(changes, "resources", resource.uid, resourceLabel, resource, "group", "Group", readStringCellAt(row.cells, columnIndexByLabel.get("Group")));
+ assignIfChanged(changes, "resources", resource.uid, resourceLabel, resource, "maxUnits", "MaxUnits", readNumberCellAt(row.cells, columnIndexByLabel.get("MaxUnits")));
+ }
+ }
+
+ function importAssignmentsSheet(workbook: XlsxWorkbookLike, model: ProjectModel, changes: ImportChange[]): void {
+ const assignmentsSheet = workbook.sheets.find((sheet) => sheet.name === "Assignments");
+ if (!assignmentsSheet) {
+ return;
+ }
+ const columnIndexByLabel = readHeaderMap(assignmentsSheet, HEADER_ROW_INDEX);
+ const uidColumnIndex = columnIndexByLabel.get("UID");
+ if (uidColumnIndex === undefined) {
+ return;
+ }
+ const assignmentByUid = new Map(model.assignments.map((assignment) => [assignment.uid, assignment]));
+ for (const row of assignmentsSheet.rows.slice(DATA_ROW_START_INDEX)) {
+ const uid = readStringCell(row.cells[uidColumnIndex]);
+ if (!uid) {
+ continue;
+ }
+ const assignment = assignmentByUid.get(uid);
+ if (!assignment) {
+ continue;
+ }
+ const assignmentLabel = `TaskUID=${assignment.taskUid}`;
+ assignIfChanged(changes, "assignments", assignment.uid, assignmentLabel, assignment, "units", "Units", readNumberCellAt(row.cells, columnIndexByLabel.get("Units")));
+ assignIfChanged(changes, "assignments", assignment.uid, assignmentLabel, assignment, "work", "Work", readStringCellAt(row.cells, columnIndexByLabel.get("Work")));
+ assignIfChanged(changes, "assignments", assignment.uid, assignmentLabel, assignment, "percentWorkComplete", "PercentWorkComplete", readNumberCellAt(row.cells, columnIndexByLabel.get("PercentWorkComplete")));
+ }
+ }
+
+ function importCalendarsSheet(workbook: XlsxWorkbookLike, model: ProjectModel, changes: ImportChange[]): void {
+ const calendarsSheet = workbook.sheets.find((sheet) => sheet.name === "Calendars");
+ if (!calendarsSheet) {
+ return;
+ }
+ const columnIndexByLabel = readHeaderMap(calendarsSheet, HEADER_ROW_INDEX);
+ const uidColumnIndex = columnIndexByLabel.get("UID");
+ if (uidColumnIndex === undefined) {
+ return;
+ }
+ const calendarByUid = new Map(model.calendars.map((calendar) => [calendar.uid, calendar]));
+ for (const row of calendarsSheet.rows.slice(DATA_ROW_START_INDEX)) {
+ const uid = readStringCell(row.cells[uidColumnIndex]);
+ if (!uid) {
+ continue;
+ }
+ const calendar = calendarByUid.get(uid);
+ if (!calendar) {
+ continue;
+ }
+ const calendarLabel = calendar.name;
+ assignIfChanged(changes, "calendars", calendar.uid, calendarLabel, calendar, "name", "Name", readStringCellAt(row.cells, columnIndexByLabel.get("Name")));
+ assignIfChanged(changes, "calendars", calendar.uid, calendarLabel, calendar, "isBaseCalendar", "IsBaseCalendar", readBooleanCellAt(row.cells, columnIndexByLabel.get("IsBaseCalendar")));
+ assignIfChanged(changes, "calendars", calendar.uid, calendarLabel, calendar, "baseCalendarUID", "BaseCalendarUID", readStringCellAt(row.cells, columnIndexByLabel.get("BaseCalendarUID")));
+ }
+ }
+
+ function importNonWorkingDaysSheet(workbook: XlsxWorkbookLike, model: ProjectModel, changes: ImportChange[]): void {
+ const nonWorkingDaysSheet = workbook.sheets.find((sheet) => sheet.name === "NonWorkingDays");
+ if (!nonWorkingDaysSheet) {
+ return;
+ }
+ const columnIndexByLabel = readHeaderMap(nonWorkingDaysSheet, HEADER_ROW_INDEX);
+ const calendarUidColumnIndex = columnIndexByLabel.get("CalendarUID");
+ const indexColumnIndex = columnIndexByLabel.get("Index");
+ if (calendarUidColumnIndex === undefined || indexColumnIndex === undefined) {
+ return;
+ }
+ const calendarByUid = new Map(model.calendars.map((calendar) => [calendar.uid, calendar]));
+ for (const row of nonWorkingDaysSheet.rows.slice(DATA_ROW_START_INDEX)) {
+ const calendarUid = readStringCell(row.cells[calendarUidColumnIndex]);
+ const indexValue = readNumberCell(row.cells[indexColumnIndex]);
+ if (!calendarUid || !indexValue) {
+ continue;
+ }
+ const calendar = calendarByUid.get(calendarUid);
+ if (!calendar) {
+ continue;
+ }
+ const exception = calendar.exceptions[indexValue - 1];
+ if (!exception) {
+ continue;
+ }
+ const exceptionLabel = exception.name || `Exception ${indexValue}`;
+ assignIfChanged(changes, "calendars", calendar.uid, calendar.name, exception, "name", `Exception${indexValue}.Name`, readStringCellAt(row.cells, columnIndexByLabel.get("Name")));
+
+ const dateValue = readStringCellAt(row.cells, columnIndexByLabel.get("Date"));
+ if (dateValue) {
+ const normalizedDate = normalizeDateOnly(dateValue);
+ if (normalizedDate) {
+ assignIfChanged(changes, "calendars", calendar.uid, calendar.name, exception, "fromDate", `Exception${indexValue}.FromDate`, `${normalizedDate}T00:00:00`);
+ assignIfChanged(changes, "calendars", calendar.uid, calendar.name, exception, "toDate", `Exception${indexValue}.ToDate`, `${normalizedDate}T23:59:59`);
+ }
+ } else {
+ assignIfChanged(changes, "calendars", calendar.uid, calendar.name, exception, "fromDate", `Exception${indexValue}.FromDate`, normalizeExceptionBoundaryInput(readStringCellAt(row.cells, columnIndexByLabel.get("FromDate")), false));
+ assignIfChanged(changes, "calendars", calendar.uid, calendar.name, exception, "toDate", `Exception${indexValue}.ToDate`, normalizeExceptionBoundaryInput(readStringCellAt(row.cells, columnIndexByLabel.get("ToDate")), true));
+ }
+ assignIfChanged(changes, "calendars", calendar.uid, calendar.name, exception, "dayWorking", `Exception${indexValue}.DayWorking`, readBooleanCellAt(row.cells, columnIndexByLabel.get("DayWorking")));
+ }
+ }
+
+ function formatExceptionDate(exception: CalendarExceptionModel): string | undefined {
+ const fromDate = exception.fromDate?.slice(0, 10);
+ const toDate = exception.toDate?.slice(0, 10);
+ if (!fromDate || !toDate) {
+ return undefined;
+ }
+ return fromDate === toDate ? fromDate : undefined;
+ }
+
+ function formatExceptionBoundaryDate(value: string | undefined): string | undefined {
+ return value?.slice(0, 10);
+ }
+
+ function normalizeDateOnly(value: string): string | undefined {
+ const trimmed = value.trim();
+ const match = trimmed.match(/^(\d{4}-\d{2}-\d{2})/);
+ return match ? match[1] : undefined;
+ }
+
+ function normalizeDateTimeInput(value: string | undefined): string | undefined {
+ if (!value) {
+ return value;
+ }
+ const trimmed = value.trim();
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(trimmed)) {
+ return trimmed.replace(" ", "T");
+ }
+ return trimmed;
+ }
+
+ function normalizeExceptionBoundaryInput(value: string | undefined, isEndOfDay: boolean): string | undefined {
+ const normalizedDate = value ? normalizeDateOnly(value) : undefined;
+ if (normalizedDate && normalizedDate === value?.trim()) {
+ return `${normalizedDate}${isEndOfDay ? "T23:59:59" : "T00:00:00"}`;
+ }
+ return normalizeDateTimeInput(value);
+ }
+
+ function readHeaderMap(sheet: XlsxWorkbookLike["sheets"][number], headerRowIndex: number): Map {
+ const headerRow = sheet.rows[headerRowIndex];
+ const columnIndexByLabel = new Map();
+ if (!headerRow) {
+ return columnIndexByLabel;
+ }
+ headerRow.cells.forEach((cell, index) => {
+ if (typeof cell.value === "string" && cell.value) {
+ columnIndexByLabel.set(cell.value, index);
+ }
+ });
+ return columnIndexByLabel;
+ }
+
+ function readStringCellAt(cells: XlsxCellLike[], index: number | undefined): string | undefined {
+ if (index === undefined) {
+ return undefined;
+ }
+ return readStringCell(cells[index]);
+ }
+
+ function readNumberCellAt(cells: XlsxCellLike[], index: number | undefined): number | undefined {
+ if (index === undefined) {
+ return undefined;
+ }
+ return readNumberCell(cells[index]);
+ }
+
+ function readBooleanCellAt(cells: XlsxCellLike[], index: number | undefined): boolean | undefined {
+ if (index === undefined) {
+ return undefined;
+ }
+ return readBooleanCell(cells[index]);
+ }
+
+ function readStringCell(cell: XlsxCellLike | undefined): string | undefined {
+ if (!cell || cell.value === undefined) {
+ return undefined;
+ }
+ if (typeof cell.value === "string") {
+ return cell.value;
+ }
+ if (typeof cell.value === "number" || typeof cell.value === "boolean") {
+ return String(cell.value);
+ }
+ return undefined;
+ }
+
+ function readNumberCell(cell: XlsxCellLike | undefined): number | undefined {
+ if (!cell || cell.value === undefined) {
+ return undefined;
+ }
+ if (typeof cell.value === "number" && Number.isFinite(cell.value)) {
+ return cell.value;
+ }
+ if (typeof cell.value === "string" && cell.value.trim() !== "") {
+ const parsed = Number(cell.value);
+ return Number.isFinite(parsed) ? parsed : undefined;
+ }
+ return undefined;
+ }
+
+ function readBooleanCell(cell: XlsxCellLike | undefined): boolean | undefined {
+ if (!cell || cell.value === undefined) {
+ return undefined;
+ }
+ if (typeof cell.value === "boolean") {
+ return cell.value;
+ }
+ if (typeof cell.value === "number") {
+ return cell.value !== 0;
+ }
+ if (typeof cell.value === "string") {
+ if (cell.value === "true" || cell.value === "TRUE" || cell.value === "1") {
+ return true;
+ }
+ if (cell.value === "false" || cell.value === "FALSE" || cell.value === "0") {
+ return false;
+ }
+ }
+ return undefined;
+ }
+
+ function assignIfChanged(
+ changes: ImportChange[],
+ scope: ImportChange["scope"],
+ uid: string,
+ label: string,
+ target: T,
+ key: K,
+ field: string,
+ value: T[K] | undefined
+ ): void {
+ if (value === undefined) {
+ return;
+ }
+ const before = target[key];
+ if (before === value) {
+ return;
+ }
+ target[key] = value;
+ changes.push({
+ scope,
+ uid,
+ label,
+ field,
+ before: before as string | number | boolean | undefined,
+ after: value as string | number | boolean
+ });
+ }
+
+ (globalThis as typeof globalThis & {
+ __mikuprojectProjectXlsx?: {
+ exportProjectWorkbook: (model: ProjectModel) => XlsxWorkbookLike;
+ importProjectWorkbook: (workbook: XlsxWorkbookLike, baseModel: ProjectModel) => ProjectModel;
+ importProjectWorkbookDetailed: (workbook: XlsxWorkbookLike, baseModel: ProjectModel) => {
+ model: ProjectModel;
+ changes: ImportChange[];
+ };
+ };
+ }).__mikuprojectProjectXlsx = {
+ exportProjectWorkbook,
+ importProjectWorkbook,
+ importProjectWorkbookDetailed
+ };
+})();
diff --git a/src/ts/types.ts b/src/ts/types.ts
new file mode 100644
index 0000000..8393c47
--- /dev/null
+++ b/src/ts/types.ts
@@ -0,0 +1,321 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ type ProjectInfo = {
+ name: string;
+ title?: string;
+ author?: string;
+ company?: string;
+ creationDate?: string;
+ lastSaved?: string;
+ saveVersion?: number;
+ startDate: string;
+ finishDate: string;
+ scheduleFromStart: boolean;
+ currentDate?: string;
+ defaultStartTime?: string;
+ defaultFinishTime?: string;
+ minutesPerDay?: number;
+ minutesPerWeek?: number;
+ daysPerMonth?: number;
+ statusDate?: string;
+ weekStartDay?: number;
+ workFormat?: number;
+ durationFormat?: number;
+ currencyCode?: string;
+ currencyDigits?: number;
+ currencySymbol?: string;
+ currencySymbolPosition?: number;
+ fyStartDate?: string;
+ fiscalYearStart?: boolean;
+ criticalSlackLimit?: number;
+ defaultTaskType?: number;
+ defaultFixedCostAccrual?: number;
+ defaultStandardRate?: string;
+ defaultOvertimeRate?: string;
+ defaultTaskEVMethod?: number;
+ newTaskStartDate?: number;
+ newTasksAreManual?: boolean;
+ newTasksEffortDriven?: boolean;
+ newTasksEstimated?: boolean;
+ actualsInSync?: boolean;
+ editableActualCosts?: boolean;
+ honorConstraints?: boolean;
+ insertedProjectsLikeSummary?: boolean;
+ multipleCriticalPaths?: boolean;
+ taskUpdatesResource?: boolean;
+ updateManuallyScheduledTasksWhenEditingLinks?: boolean;
+ calendarUID?: string;
+ outlineCodes: OutlineCodeModel[];
+ wbsMasks: WBSMaskModel[];
+ extendedAttributes: ProjectExtendedAttributeModel[];
+ };
+
+ type OutlineCodeValueModel = {
+ value: string;
+ description?: string;
+ };
+
+ type OutlineCodeMaskModel = {
+ level: number;
+ mask?: string;
+ length?: number;
+ sequence?: number;
+ };
+
+ type OutlineCodeModel = {
+ fieldID?: string;
+ fieldName?: string;
+ alias?: string;
+ onlyTableValues?: boolean;
+ enterprise?: boolean;
+ resourceSubstitutionEnabled?: boolean;
+ leafOnly?: boolean;
+ allLevelsRequired?: boolean;
+ masks: OutlineCodeMaskModel[];
+ values: OutlineCodeValueModel[];
+ };
+
+ type WBSMaskModel = {
+ level: number;
+ mask?: string;
+ length?: number;
+ sequence?: number;
+ };
+
+ type ProjectExtendedAttributeModel = {
+ fieldID?: string;
+ fieldName?: string;
+ alias?: string;
+ calculationType?: number;
+ restrictValues?: boolean;
+ appendNewValues?: boolean;
+ };
+
+ type PredecessorModel = {
+ predecessorUid: string;
+ type?: number;
+ linkLag?: string;
+ };
+
+ type TaskExtendedAttributeModel = {
+ fieldID?: string;
+ value?: string;
+ };
+
+ type TaskBaselineModel = {
+ number?: number;
+ start?: string;
+ finish?: string;
+ work?: string;
+ cost?: number;
+ };
+
+ type TaskTimephasedDataModel = {
+ type?: number;
+ uid?: string;
+ start?: string;
+ finish?: string;
+ unit?: number;
+ value?: string;
+ };
+
+ type ResourceExtendedAttributeModel = {
+ fieldID?: string;
+ value?: string;
+ };
+
+ type ResourceBaselineModel = {
+ number?: number;
+ start?: string;
+ finish?: string;
+ work?: string;
+ cost?: number;
+ };
+
+ type ResourceTimephasedDataModel = {
+ type?: number;
+ uid?: string;
+ start?: string;
+ finish?: string;
+ unit?: number;
+ value?: string;
+ };
+
+ type AssignmentExtendedAttributeModel = {
+ fieldID?: string;
+ value?: string;
+ };
+
+ type AssignmentBaselineModel = {
+ number?: number;
+ start?: string;
+ finish?: string;
+ work?: string;
+ cost?: number;
+ };
+
+ type AssignmentTimephasedDataModel = {
+ type?: number;
+ uid?: string;
+ start?: string;
+ finish?: string;
+ unit?: number;
+ value?: string;
+ };
+
+ type TaskModel = {
+ uid: string;
+ id: string;
+ name: string;
+ outlineLevel: number;
+ outlineNumber: string;
+ wbs?: string;
+ type?: number;
+ calendarUID?: string;
+ priority?: number;
+ start: string;
+ finish: string;
+ duration: string;
+ actualStart?: string;
+ actualFinish?: string;
+ deadline?: string;
+ startVariance?: string;
+ finishVariance?: string;
+ work?: string;
+ workVariance?: string;
+ totalSlack?: string;
+ freeSlack?: string;
+ cost?: number;
+ actualCost?: number;
+ remainingCost?: number;
+ remainingWork?: string;
+ actualWork?: string;
+ milestone: boolean;
+ summary: boolean;
+ critical?: boolean;
+ percentComplete: number;
+ percentWorkComplete?: number;
+ notes?: string;
+ constraintType?: number;
+ constraintDate?: string;
+ extendedAttributes: TaskExtendedAttributeModel[];
+ baselines: TaskBaselineModel[];
+ timephasedData: TaskTimephasedDataModel[];
+ predecessors: PredecessorModel[];
+ };
+
+ type ResourceModel = {
+ uid: string;
+ id: string;
+ name: string;
+ type?: number;
+ initials?: string;
+ group?: string;
+ workGroup?: number;
+ maxUnits?: number;
+ calendarUID?: string;
+ standardRate?: string;
+ standardRateFormat?: number;
+ overtimeRate?: string;
+ overtimeRateFormat?: number;
+ costPerUse?: number;
+ work?: string;
+ actualWork?: string;
+ remainingWork?: string;
+ cost?: number;
+ actualCost?: number;
+ remainingCost?: number;
+ percentWorkComplete?: number;
+ extendedAttributes: ResourceExtendedAttributeModel[];
+ baselines: ResourceBaselineModel[];
+ timephasedData: ResourceTimephasedDataModel[];
+ };
+
+ type AssignmentModel = {
+ uid: string;
+ taskUid: string;
+ resourceUid: string;
+ start?: string;
+ finish?: string;
+ startVariance?: string;
+ finishVariance?: string;
+ delay?: string;
+ milestone?: boolean;
+ workContour?: number;
+ units?: number;
+ work?: string;
+ cost?: number;
+ actualCost?: number;
+ remainingCost?: number;
+ percentWorkComplete?: number;
+ overtimeWork?: string;
+ actualOvertimeWork?: string;
+ actualWork?: string;
+ remainingWork?: string;
+ extendedAttributes: AssignmentExtendedAttributeModel[];
+ baselines: AssignmentBaselineModel[];
+ timephasedData: AssignmentTimephasedDataModel[];
+ };
+
+ type WorkingTimeModel = {
+ fromTime: string;
+ toTime: string;
+ };
+
+ type WeekDayModel = {
+ dayType: number;
+ dayWorking: boolean;
+ workingTimes: WorkingTimeModel[];
+ };
+
+ type CalendarExceptionModel = {
+ name?: string;
+ fromDate?: string;
+ toDate?: string;
+ dayWorking?: boolean;
+ workingTimes: WorkingTimeModel[];
+ };
+
+ type WorkWeekModel = {
+ name?: string;
+ fromDate?: string;
+ toDate?: string;
+ weekDays: WeekDayModel[];
+ };
+
+ type CalendarModel = {
+ uid: string;
+ name: string;
+ isBaseCalendar: boolean;
+ isBaselineCalendar?: boolean;
+ baseCalendarUID?: string;
+ weekDays: WeekDayModel[];
+ exceptions: CalendarExceptionModel[];
+ workWeeks: WorkWeekModel[];
+ };
+
+ type ProjectModel = {
+ project: ProjectInfo;
+ tasks: TaskModel[];
+ resources: ResourceModel[];
+ assignments: AssignmentModel[];
+ calendars: CalendarModel[];
+ };
+
+ type ValidationIssue = {
+ level: "error" | "warning";
+ scope: "project" | "tasks" | "resources" | "assignments" | "calendars";
+ message: string;
+ };
+
+ (globalThis as typeof globalThis & {
+ __mikuprojectTypes?: {
+ __ready: true;
+ };
+ }).__mikuprojectTypes = {
+ __ready: true
+ };
+})();
diff --git a/src/ts/wbs-markdown.ts b/src/ts/wbs-markdown.ts
new file mode 100644
index 0000000..46e0893
--- /dev/null
+++ b/src/ts/wbs-markdown.ts
@@ -0,0 +1,544 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ const markdownEscape = (globalThis as typeof globalThis & {
+ __mikuprojectMarkdownEscape?: {
+ escapeMarkdownLiteralText: (text: string) => string;
+ escapeMarkdownTableCell: (text: string) => string;
+ };
+ }).__mikuprojectMarkdownEscape;
+ if (!markdownEscape) {
+ throw new Error("mikuproject markdown escape module is not loaded");
+ }
+
+ type WbsMarkdownExportOptions = {
+ holidayDates?: string[];
+ displayDaysBeforeBaseDate?: number;
+ displayDaysAfterBaseDate?: number;
+ useBusinessDaysForDisplayRange?: boolean;
+ useBusinessDaysForProgressBand?: boolean;
+ };
+
+ function exportWbsMarkdown(model: ProjectModel, options: WbsMarkdownExportOptions = {}): string {
+ const nonWorkingDayTypes = collectProjectNonWorkingDayTypes(model);
+ const holidaySet = new Set([
+ ...collectWbsHolidayDates(model),
+ ...(options.holidayDates || []).map((day) => String(day || "").slice(0, 10)).filter(Boolean)
+ ]);
+ const dateBand = buildDisplayDateBand(
+ model.project.startDate,
+ model.project.finishDate,
+ model.project.currentDate,
+ options.displayDaysBeforeBaseDate,
+ options.displayDaysAfterBaseDate,
+ holidaySet,
+ nonWorkingDayTypes,
+ options.useBusinessDaysForDisplayRange
+ );
+ const calendarNameByUid = new Map(model.calendars.map((calendar) => [calendar.uid, calendar.name]));
+ const resourceNameByUid = new Map(model.resources.map((resource) => [resource.uid, resource.name]));
+ const predecessorNameByUid = new Map(model.tasks.map((task) => [task.uid, task.name]));
+ const resourceNamesByTaskUid = new Map();
+ for (const assignment of model.assignments) {
+ const resourceName = resourceNameByUid.get(assignment.resourceUid);
+ if (!resourceName) {
+ continue;
+ }
+ const resourceNames = resourceNamesByTaskUid.get(assignment.taskUid) || [];
+ if (!resourceNames.includes(resourceName)) {
+ resourceNames.push(resourceName);
+ }
+ resourceNamesByTaskUid.set(assignment.taskUid, resourceNames);
+ }
+
+ const sections = [
+ "# プロジェクト情報",
+ "",
+ ...buildKeyValueTable([
+ ["プロジェクト名", model.project.name || "-"],
+ ["カレンダ", formatCalendarLabel(model.project.calendarUID, calendarNameByUid)],
+ ["開始日", formatWbsDate(model.project.startDate)],
+ ["終了日", formatWbsDate(model.project.finishDate)],
+ ["現在日", formatWbsDate(model.project.currentDate)],
+ ["祝日", String(holidaySet.size)]
+ ]),
+ "",
+ "# WBS ツリー",
+ "",
+ ...wrapFenceBlock(
+ buildTreeLines(model.tasks, holidaySet, nonWorkingDayTypes, options.useBusinessDaysForProgressBand),
+ "text"
+ ),
+ "",
+ "---",
+ "",
+ "# WBS テーブル",
+ "",
+ ...buildWbsTable(
+ model.tasks,
+ holidaySet,
+ nonWorkingDayTypes,
+ resourceNamesByTaskUid,
+ predecessorNameByUid,
+ options.useBusinessDaysForProgressBand
+ ),
+ "",
+ "---",
+ "",
+ "# サマリ",
+ "",
+ ...buildKeyValueTable([
+ ["表示日", String(dateBand.length)],
+ ["表示週", String(dateBand.length > 0 ? Math.ceil(dateBand.length / 7) : 0)],
+ ["営業日", String(countBusinessDays(dateBand, holidaySet, nonWorkingDayTypes))],
+ ["前日数", formatOptionalCount(options.displayDaysBeforeBaseDate)],
+ ["後日数", formatOptionalCount(options.displayDaysAfterBaseDate)],
+ ["表示", options.useBusinessDaysForDisplayRange ? "営業日" : "暦日"],
+ ["進捗", options.useBusinessDaysForProgressBand ? "営業日" : "暦日"],
+ ["基準日", formatWbsDate(model.project.currentDate)],
+ ["タスク", String(model.tasks.length)],
+ ["リソース", String(model.resources.length)],
+ ["割当", String(model.assignments.length)],
+ ["カレンダ", String(model.calendars.length)]
+ ]),
+ ""
+ ];
+ return sections.join("\n");
+ }
+
+ function buildKeyValueTable(rows: Array<[string, string]>): string[] {
+ return [
+ "| 項目 | 値 |",
+ "| --- | --- |",
+ ...rows.map(([label, value]) => `| ${escapeMarkdownCell(label)} | ${escapeMarkdownCell(value)} |`)
+ ];
+ }
+
+ function buildWbsTable(
+ tasks: TaskModel[],
+ holidaySet: Set,
+ nonWorkingDayTypes: Set,
+ resourceNamesByTaskUid: Map,
+ predecessorNameByUid: Map,
+ useBusinessDaysForProgressBand: boolean | undefined
+ ): string[] {
+ const header = [
+ "WBS",
+ "種別",
+ "階層",
+ "名称",
+ "開始",
+ "終了",
+ "期間",
+ "タスク詳細",
+ "進捗",
+ "担当",
+ "リソース",
+ "先行"
+ ];
+ const lines = [
+ `| ${header.join(" | ")} |`,
+ `| ${header.map(() => "---").join(" | ")} |`
+ ];
+ for (const task of tasks) {
+ const resourceNames = resourceNamesByTaskUid.get(task.uid) || [];
+ lines.push([
+ task.wbs || task.outlineNumber || "-",
+ classifyTaskKind(task),
+ String(task.outlineLevel || "-"),
+ formatTableTaskLabel(task),
+ formatWbsDate(task.start),
+ formatWbsDate(task.finish),
+ formatDurationLabel(task, holidaySet, nonWorkingDayTypes, useBusinessDaysForProgressBand),
+ formatNoteCell(task.notes),
+ formatPercentCell(task.percentComplete),
+ firstResourceName(resourceNames) || "-",
+ resourceNames.join(", ") || "-",
+ task.predecessors.map((item) => predecessorNameByUid.get(item.predecessorUid) || item.predecessorUid).join(", ") || "-"
+ ].map((value) => escapeMarkdownCell(value)).join(" | ").replace(/^/, "| ").concat(" |"));
+ }
+ return lines;
+ }
+
+ function buildTreeLines(
+ tasks: TaskModel[],
+ holidaySet: Set,
+ nonWorkingDayTypes: Set,
+ useBusinessDaysForProgressBand: boolean | undefined
+ ): string[] {
+ const lines: string[] = [];
+ for (const task of tasks) {
+ const indent = task.outlineLevel > 1 ? `${" ".repeat(Math.max(0, task.outlineLevel - 2))}┗ ` : "";
+ const taskLine = `${indent}${formatTreeInlineText(task.wbs || task.outlineNumber || "-")} ${formatTreeInlineText(task.name || "-")} (${formatTreeInlineText(formatTreeDateRange(task.start, task.finish))}): ${formatTreeInlineText(formatPercentCell(task.percentComplete))}`;
+ lines.push(taskLine);
+ if (task.notes && task.notes.trim()) {
+ const noteIndent = `${" ".repeat(Math.max(0, task.outlineLevel - 1))} `;
+ const noteLines = normalizeFenceText(task.notes)
+ .split("\n")
+ .filter(Boolean);
+ if (noteLines.length > 0) {
+ lines.push(`${noteIndent}詳細: ${noteLines[0]}`);
+ for (const line of noteLines.slice(1)) {
+ lines.push(`${noteIndent} ${line}`);
+ }
+ }
+ }
+ }
+ return lines.length > 0 ? lines : ["(task なし)"];
+ }
+
+ function classifyTaskKind(task: TaskModel): string {
+ if (task.summary) {
+ return "フェーズ";
+ }
+ if (task.milestone) {
+ return "マイル";
+ }
+ return "タスク";
+ }
+
+ function formatTableTaskLabel(task: TaskModel): string {
+ return task.name || "-";
+ }
+
+ function formatNoteCell(value: string | undefined): string {
+ const normalized = normalizeTextBlock(value);
+ return normalized ? normalized : "-";
+ }
+
+ function formatPercentCell(value: number | undefined): string {
+ if (value === undefined || value === null || !Number.isFinite(value)) {
+ return "-";
+ }
+ return `${Math.max(0, Math.min(100, Math.round(value)))}%`;
+ }
+
+ function formatFlagCell(flag: boolean | undefined, label: string): string {
+ return flag ? label : "-";
+ }
+
+ function formatOptionalCount(value: number | undefined): string {
+ if (value === undefined || value === null || !Number.isFinite(value)) {
+ return "-";
+ }
+ return String(Math.max(0, Math.floor(value)));
+ }
+
+ function escapeMarkdownCell(value: string): string {
+ return markdownEscape.escapeMarkdownTableCell(String(value || ""));
+ }
+
+ function formatTreeInlineText(value: string | undefined): string {
+ return normalizeFenceText(value).replace(/\n+/g, " / ");
+ }
+
+ // normalizeTextBlock only performs text normalization for export:
+ // newline normalization, control-character removal, tab expansion,
+ // trailing-space trimming, and blank-line compaction. It does not apply
+ // Markdown-specific escaping.
+ function normalizeTextBlock(value: string | undefined): string {
+ return String(value || "").trim()
+ .replace(/\r\n?/g, "\n")
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
+ .replace(/\t/g, " ")
+ .split("\n")
+ .map((line) => line.trimEnd())
+ .filter((line, index, lines) => !(line === "" && lines[index - 1] === ""))
+ .join("\n");
+ }
+
+ // Code-fence text does not need normal Markdown escaping, but it does need
+ // normalized content so fence selection and indentation stay predictable.
+ function normalizeFenceText(value: string | undefined): string {
+ return normalizeTextBlock(value);
+ }
+
+ function wrapFenceBlock(lines: string[], infoString: string): string[] {
+ const content = lines.join("\n");
+ const backtickRun = longestFenceRun(content, "`");
+ const tildeRun = longestFenceRun(content, "~");
+ const fenceChar = tildeRun <= backtickRun ? "~" : "`";
+ const fenceLength = Math.max(3, (fenceChar === "~" ? tildeRun : backtickRun) + 1);
+ const fence = fenceChar.repeat(fenceLength);
+ return [`${fence}${infoString}`, ...lines, fence];
+ }
+
+ function longestFenceRun(text: string, char: "`" | "~"): number {
+ let maxRun = 0;
+ let currentRun = 0;
+ for (const currentChar of text) {
+ if (currentChar === char) {
+ currentRun += 1;
+ if (currentRun > maxRun) {
+ maxRun = currentRun;
+ }
+ } else {
+ currentRun = 0;
+ }
+ }
+ return maxRun;
+ }
+
+ function collectWbsHolidayDates(model: ProjectModel): string[] {
+ const holidaySet = new Set();
+ for (const calendar of model.calendars) {
+ for (const exception of calendar.exceptions || []) {
+ if (exception.dayWorking !== false && (exception.workingTimes || []).length > 0) {
+ continue;
+ }
+ for (const day of expandExceptionDays(exception)) {
+ holidaySet.add(day);
+ }
+ }
+ }
+ return Array.from(holidaySet).sort();
+ }
+
+ function expandExceptionDays(exception: CalendarExceptionModel): string[] {
+ const singleDay = exception.fromDate ? formatDateOnly(parseDateOnly(exception.fromDate)) : "";
+ if (!exception.fromDate || !exception.toDate) {
+ return singleDay ? [singleDay] : [];
+ }
+ return buildDateBand(exception.fromDate, exception.toDate);
+ }
+
+ function resolveProjectCalendar(model: ProjectModel): CalendarModel | undefined {
+ if (model.project.calendarUID) {
+ const projectCalendar = model.calendars.find((calendar) => calendar.uid === model.project.calendarUID);
+ if (projectCalendar) {
+ return projectCalendar;
+ }
+ }
+ return model.calendars.find((calendar) => calendar.isBaseCalendar) || model.calendars[0];
+ }
+
+ function resolveCalendarDayWorking(
+ calendarByUid: Map,
+ calendar: CalendarModel | undefined,
+ dayType: number,
+ visiting = new Set()
+ ): boolean | undefined {
+ if (!calendar) {
+ return undefined;
+ }
+ if (visiting.has(calendar.uid)) {
+ return undefined;
+ }
+ visiting.add(calendar.uid);
+ const weekDay = calendar.weekDays.find((item) => item.dayType === dayType);
+ if (weekDay) {
+ return weekDay.dayWorking;
+ }
+ if (calendar.baseCalendarUID) {
+ return resolveCalendarDayWorking(calendarByUid, calendarByUid.get(calendar.baseCalendarUID), dayType, visiting);
+ }
+ return undefined;
+ }
+
+ function collectProjectNonWorkingDayTypes(model: ProjectModel): Set {
+ const calendarByUid = new Map(model.calendars.map((calendar) => [calendar.uid, calendar]));
+ const projectCalendar = resolveProjectCalendar(model);
+ const nonWorkingDayTypes = new Set();
+ for (let dayType = 1; dayType <= 7; dayType += 1) {
+ const dayWorking = resolveCalendarDayWorking(calendarByUid, projectCalendar, dayType);
+ if (dayWorking === false) {
+ nonWorkingDayTypes.add(dayType);
+ continue;
+ }
+ if (dayWorking === undefined && (dayType === 1 || dayType === 7)) {
+ nonWorkingDayTypes.add(dayType);
+ }
+ }
+ return nonWorkingDayTypes;
+ }
+
+ function buildDateBand(startDate: string | undefined, finishDate: string | undefined): string[] {
+ const start = parseDateOnly(startDate);
+ const finish = parseDateOnly(finishDate);
+ if (!start || !finish || start.getTime() > finish.getTime()) {
+ return [];
+ }
+ const days: string[] = [];
+ const cursor = new Date(start.getTime());
+ while (cursor.getTime() <= finish.getTime()) {
+ days.push(formatDateOnly(cursor));
+ cursor.setDate(cursor.getDate() + 1);
+ }
+ return days;
+ }
+
+ function buildDisplayDateBand(
+ startDate: string | undefined,
+ finishDate: string | undefined,
+ baseDate: string | undefined,
+ displayDaysBeforeBaseDate: number | undefined,
+ displayDaysAfterBaseDate: number | undefined,
+ holidaySet: Set,
+ nonWorkingDayTypes: Set,
+ useBusinessDaysForDisplayRange: boolean | undefined
+ ): string[] {
+ const fullBand = buildDateBand(startDate, finishDate);
+ const before = normalizeDisplayDayCount(displayDaysBeforeBaseDate);
+ const after = normalizeDisplayDayCount(displayDaysAfterBaseDate);
+ if (before === null && after === null) {
+ return fullBand;
+ }
+ const base = parseDateOnly(baseDate);
+ if (!base || fullBand.length === 0) {
+ return fullBand;
+ }
+ const projectStart = parseDateOnly(startDate);
+ const projectFinish = parseDateOnly(finishDate);
+ if (!projectStart || !projectFinish) {
+ return fullBand;
+ }
+ const from = useBusinessDaysForDisplayRange
+ ? shiftBusinessDays(base, -(before || 0), holidaySet, nonWorkingDayTypes)
+ : shiftCalendarDays(base, -(before || 0));
+ const to = useBusinessDaysForDisplayRange
+ ? shiftBusinessDays(base, after || 0, holidaySet, nonWorkingDayTypes)
+ : shiftCalendarDays(base, after || 0);
+ const clampedStart = from.getTime() < projectStart.getTime() ? projectStart : from;
+ const clampedFinish = to.getTime() > projectFinish.getTime() ? projectFinish : to;
+ if (clampedStart.getTime() > clampedFinish.getTime()) {
+ return fullBand;
+ }
+ return buildDateBand(formatDateOnly(clampedStart), formatDateOnly(clampedFinish));
+ }
+
+ function normalizeDisplayDayCount(value: number | undefined): number | null {
+ if (value === undefined || value === null || !Number.isFinite(value)) {
+ return null;
+ }
+ return Math.max(0, Math.floor(value));
+ }
+
+ function shiftCalendarDays(base: Date, offset: number): Date {
+ const result = new Date(base.getTime());
+ result.setDate(result.getDate() + offset);
+ return result;
+ }
+
+ function shiftBusinessDays(base: Date, offset: number, holidaySet: Set, nonWorkingDayTypes: Set): Date {
+ const result = new Date(base.getTime());
+ const direction = offset < 0 ? -1 : 1;
+ let remaining = Math.abs(offset);
+ while (remaining > 0) {
+ result.setDate(result.getDate() + direction);
+ const day = formatDateOnly(result);
+ if (isWeeklyNonWorkingDay(day, nonWorkingDayTypes) || holidaySet.has(day)) {
+ continue;
+ }
+ remaining -= 1;
+ }
+ return result;
+ }
+
+ function countBusinessDays(dateBand: string[], holidaySet: Set, nonWorkingDayTypes: Set): number {
+ return dateBand.filter((day) => !isWeeklyNonWorkingDay(day, nonWorkingDayTypes) && !holidaySet.has(day)).length;
+ }
+
+ function enumerateBusinessDays(
+ startDate: string | undefined,
+ finishDate: string | undefined,
+ holidaySet: Set,
+ nonWorkingDayTypes: Set
+ ): string[] {
+ return buildDateBand(startDate, finishDate).filter((day) => !isWeeklyNonWorkingDay(day, nonWorkingDayTypes) && !holidaySet.has(day));
+ }
+
+ function formatDurationLabel(
+ task: TaskModel,
+ holidaySet: Set,
+ nonWorkingDayTypes: Set,
+ useBusinessDaysForProgressBand: boolean | undefined
+ ): string {
+ if (useBusinessDaysForProgressBand) {
+ const businessDays = enumerateBusinessDays(task.start, task.finish, holidaySet, nonWorkingDayTypes).length;
+ return businessDays > 0 ? `${businessDays}営業日` : "-";
+ }
+ const calendarDays = buildDateBand(task.start, task.finish).length;
+ return calendarDays > 0 ? `${calendarDays}日` : "-";
+ }
+
+ function formatWbsDate(value: string | undefined): string {
+ return value ? value.slice(0, 10) : "-";
+ }
+
+ function formatTreeDate(value: string | undefined): string {
+ if (!value) {
+ return "-";
+ }
+ const match = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
+ if (!match) {
+ return String(value).slice(0, 10) || "-";
+ }
+ const [, _year, month, day] = match;
+ return `${Number(month)}/${Number(day)}`;
+ }
+
+ function formatTreeDateRange(start: string | undefined, finish: string | undefined): string {
+ const startLabel = formatTreeDate(start);
+ const finishLabel = formatTreeDate(finish);
+ if (startLabel === finishLabel) {
+ return startLabel;
+ }
+ return `${startLabel} - ${finishLabel}`;
+ }
+
+ function parseDateOnly(value: string | undefined): Date | null {
+ if (!value) {
+ return null;
+ }
+ const match = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
+ if (!match) {
+ return null;
+ }
+ const [, year, month, day] = match;
+ return new Date(Number(year), Number(month) - 1, Number(day));
+ }
+
+ function formatDateOnly(value: Date | null): string {
+ if (!value) {
+ return "";
+ }
+ const year = value.getFullYear();
+ const month = String(value.getMonth() + 1).padStart(2, "0");
+ const day = String(value.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+ }
+
+ function isWeeklyNonWorkingDay(day: string, nonWorkingDayTypes: Set): boolean {
+ const target = parseDateOnly(day);
+ if (!target) {
+ return false;
+ }
+ const dayType = target.getDay() === 0 ? 1 : target.getDay() + 1;
+ return nonWorkingDayTypes.has(dayType);
+ }
+
+ function firstResourceName(resourceNames: string[] | undefined): string {
+ if (!resourceNames || resourceNames.length === 0) {
+ return "";
+ }
+ return resourceNames[0];
+ }
+
+ function formatCalendarLabel(calendarUID: string | undefined, calendarNameByUid: Map): string {
+ if (!calendarUID) {
+ return "-";
+ }
+ const calendarName = calendarNameByUid.get(calendarUID);
+ return calendarName ? `${calendarUID} ${calendarName}` : calendarUID;
+ }
+
+ (globalThis as typeof globalThis & {
+ __mikuprojectWbsMarkdown?: {
+ exportWbsMarkdown: (model: ProjectModel, options?: WbsMarkdownExportOptions) => string;
+ };
+ }).__mikuprojectWbsMarkdown = {
+ exportWbsMarkdown
+ };
+})();
diff --git a/src/ts/wbs-xlsx.ts b/src/ts/wbs-xlsx.ts
new file mode 100644
index 0000000..88f350b
--- /dev/null
+++ b/src/ts/wbs-xlsx.ts
@@ -0,0 +1,1491 @@
+/*
+ * Copyright 2026 Toshiki Iga
+ * SPDX-License-Identifier: Apache-2.0
+ */
+(() => {
+ type WbsXlsxCellLike = {
+ value?: string | number | boolean;
+ numberFormat?: "general" | "integer" | "decimal" | "date" | "datetime" | "percent";
+ horizontalAlign?: "left" | "center" | "right";
+ verticalAlign?: "top" | "center" | "bottom";
+ wrapText?: boolean;
+ bold?: boolean;
+ fontSize?: number;
+ fillColor?: string;
+ border?: "thin";
+ };
+
+ type WbsXlsxWorkbookLike = {
+ sheets: Array<{
+ name: string;
+ columns?: Array<{ width?: number; hidden?: boolean }>;
+ mergedRanges?: string[];
+ rows: Array<{
+ height?: number;
+ cells: WbsXlsxCellLike[];
+ }>;
+ }>;
+ };
+
+ type WbsExportOptions = {
+ holidayDates?: string[];
+ displayDaysBeforeBaseDate?: number;
+ displayDaysAfterBaseDate?: number;
+ useBusinessDaysForDisplayRange?: boolean;
+ useBusinessDaysForProgressBand?: boolean;
+ };
+
+ type WbsSheetLayoutHelper = {
+ columnName(columnIndex: number): string;
+ columnIndex(columnReference: string): number;
+ reference(rowNumber: number, columnIndex: number): string;
+ parseCellReference(reference: string): {
+ reference: string;
+ rowNumber: number;
+ rowIndex: number;
+ columnName: string;
+ columnIndex: number;
+ };
+ range(startReference: string, endReference: string): string;
+ describeCell(reference: string): string;
+ logCell(reference: string, label?: string, logger?: (...args: unknown[]) => void): string;
+ };
+
+ const HEADER_FILL = "#D9EAF7";
+ const HEADER_ID_FILL = "#E1EDF8";
+ const HEADER_STRUCTURE_FILL = "#E6F0DF";
+ const HEADER_SCHEDULE_FILL = "#FDE7D3";
+ const HEADER_STATUS_FILL = "#FBE4EC";
+ const HEADER_ASSIGNMENT_FILL = "#E2F1EF";
+ const SUMMARY_SCHEDULE_FILL = "#FDF1E4";
+ const SUMMARY_ASSIGNMENT_FILL = "#E8F4F1";
+ const PHASE_FILL = "#EEF7E8";
+ const TASK_KIND_FILL = "#EEF2F6";
+ const MILESTONE_FILL = "#FFF4E0";
+ const IDENTIFIER_FILL = "#F7F9FC";
+ const PLACEHOLDER_FILL = "#F5F7FA";
+ const BAND_FILL = "#F4F7FB";
+ const ACTIVE_BAND_FILL = "#9FD5C9";
+ const PROGRESS_BAND_FILL = "#5BAE9C";
+ const WEEKEND_BAND_FILL = "#C9D3E1";
+ const WEEK_START_BAND_FILL = "#E3EEF9";
+ const MONTH_BOUNDARY_WEEK_FILL = "#D6E7F8";
+ const MONTH_START_HEADER_FILL = "#DCEAF7";
+ const SATURDAY_HEADER_FILL = "#8EA9DB";
+ const SUNDAY_HEADER_FILL = "#E6B8AF";
+ const TODAY_BAND_FILL = "#FFE6A7";
+ const TODAY_ACTIVE_BAND_FILL = "#F3C96B";
+ const TODAY_PROGRESS_BAND_FILL = "#D89A2B";
+ const HOLIDAY_BAND_FILL = "#FCE4EC";
+ const DIVIDER_FILL = "#D9E2EA";
+ const BASEDATE_GUIDE_TAIL_FILL = "#FFF8E1";
+ const NAME_COLUMN_FILL = "#FBFCFE";
+ const SCHEDULE_COLUMN_FILL = "#FCFAF7";
+ const PROGRESS_COLUMN_FILL = "#FCF8FB";
+ const REFERENCE_COLUMN_FILL = "#F8FBFB";
+ const WBS_LAYOUT = createWbsSheetLayoutHelper();
+ const pxWidth = (pixels: number) => Math.round((pixels / 7) * 100) / 100;
+
+ function collectWbsHolidayDates(model: ProjectModel): string[] {
+ const holidaySet = new Set();
+ for (const calendar of model.calendars) {
+ for (const exception of calendar.exceptions || []) {
+ if (exception.dayWorking !== false && (exception.workingTimes || []).length > 0) {
+ continue;
+ }
+ for (const day of expandExceptionDays(exception)) {
+ holidaySet.add(day);
+ }
+ }
+ }
+ return Array.from(holidaySet).sort();
+ }
+
+ function resolveProjectCalendar(model: ProjectModel): CalendarModel | undefined {
+ if (model.project.calendarUID) {
+ const projectCalendar = model.calendars.find((calendar) => calendar.uid === model.project.calendarUID);
+ if (projectCalendar) {
+ return projectCalendar;
+ }
+ }
+ return model.calendars.find((calendar) => calendar.isBaseCalendar) || model.calendars[0];
+ }
+
+ function resolveCalendarDayWorking(
+ calendarByUid: Map,
+ calendar: CalendarModel | undefined,
+ dayType: number,
+ visiting = new Set()
+ ): boolean | undefined {
+ if (!calendar) {
+ return undefined;
+ }
+ if (visiting.has(calendar.uid)) {
+ return undefined;
+ }
+ visiting.add(calendar.uid);
+ const weekDay = calendar.weekDays.find((item) => item.dayType === dayType);
+ if (weekDay) {
+ return weekDay.dayWorking;
+ }
+ if (calendar.baseCalendarUID) {
+ return resolveCalendarDayWorking(calendarByUid, calendarByUid.get(calendar.baseCalendarUID), dayType, visiting);
+ }
+ return undefined;
+ }
+
+ function collectProjectNonWorkingDayTypes(model: ProjectModel): Set {
+ const calendarByUid = new Map(model.calendars.map((calendar) => [calendar.uid, calendar]));
+ const projectCalendar = resolveProjectCalendar(model);
+ const nonWorkingDayTypes = new Set();
+ for (let dayType = 1; dayType <= 7; dayType += 1) {
+ const dayWorking = resolveCalendarDayWorking(calendarByUid, projectCalendar, dayType);
+ if (dayWorking === false) {
+ nonWorkingDayTypes.add(dayType);
+ continue;
+ }
+ if (dayWorking === undefined && (dayType === 1 || dayType === 7)) {
+ nonWorkingDayTypes.add(dayType);
+ }
+ }
+ return nonWorkingDayTypes;
+ }
+
+ function exportWbsWorkbook(model: ProjectModel, options: WbsExportOptions = {}): WbsXlsxWorkbookLike {
+ const nonWorkingDayTypes = collectProjectNonWorkingDayTypes(model);
+ const resourceNameByUid = new Map(model.resources.map((resource) => [resource.uid, resource.name]));
+ const predecessorNameByUid = new Map(model.tasks.map((task) => [task.uid, task.name]));
+ const calendarNameByUid = new Map(model.calendars.map((calendar) => [calendar.uid, calendar.name]));
+ const resourceNamesByTaskUid = new Map();
+ const holidaySet = new Set([
+ ...collectWbsHolidayDates(model),
+ ...(options.holidayDates || []).map((day) => day.slice(0, 10))
+ ]);
+
+ for (const assignment of model.assignments) {
+ const resourceName = resourceNameByUid.get(assignment.resourceUid);
+ if (!resourceName) {
+ continue;
+ }
+ const resourceNames = resourceNamesByTaskUid.get(assignment.taskUid) || [];
+ if (!resourceNames.includes(resourceName)) {
+ resourceNames.push(resourceName);
+ }
+ resourceNamesByTaskUid.set(assignment.taskUid, resourceNames);
+ }
+
+ const dateBand = buildDisplayDateBand(
+ model.project.startDate,
+ model.project.finishDate,
+ model.project.currentDate,
+ options.displayDaysBeforeBaseDate,
+ options.displayDaysAfterBaseDate,
+ holidaySet,
+ nonWorkingDayTypes,
+ options.useBusinessDaysForDisplayRange
+ );
+ const fixedHeaders = [
+ "UID",
+ "ID",
+ "WBS",
+ "種別",
+ "階層",
+ "名称",
+ "開始",
+ "終了",
+ "期間",
+ "タスク詳細",
+ "進捗",
+ "作業進捗",
+ "マイル",
+ "サマリ",
+ "クリティカル",
+ "担当",
+ "カレンダ",
+ "リソース",
+ "先行"
+ ];
+ const dividerColumnIndex = fixedHeaders.length + 1;
+ const dateBandStartColumnIndex = dividerColumnIndex;
+ const totalColumns = fixedHeaders.length + 1 + dateBand.length;
+ const rows: WbsXlsxWorkbookLike["sheets"][number]["rows"] = [];
+ const mergedRanges = [];
+ const projectInfoBlock = projectInfoRows(
+ model.project,
+ calendarNameByUid,
+ holidaySet.size,
+ totalColumns,
+ 0,
+ rows.length + 1
+ );
+ overlayRows(rows, 0, projectInfoBlock.rows, totalColumns);
+ const exportTimestampRow = rows[1] || (rows[1] = emptyRow(totalColumns));
+ exportTimestampRow.cells[9] = {
+ value: formatWbsExportTimestamp(new Date()),
+ horizontalAlign: "left",
+ verticalAlign: "center"
+ };
+ mergedRanges.push(...projectInfoBlock.mergedRanges);
+ rows.push(dateBandHeaderRow(fixedHeaders.length + 1, dateBand, model.project.currentDate, holidaySet, nonWorkingDayTypes));
+ rows.push(weekdayHeaderRow(fixedHeaders, dateBand, model.project.currentDate, holidaySet, nonWorkingDayTypes));
+ rows.push(...model.tasks.map((task) => ({
+ height: taskRowHeight(task),
+ cells: [
+ identifierCell(task, task.uid),
+ identifierCell(task, task.id),
+ identifierCell(task, task.wbs || task.outlineNumber),
+ kindCell(task),
+ identifierCell(task, task.outlineLevel),
+ taskCell(task, formatTaskLabel(task), "left"),
+ taskCell(task, formatWbsDate(task.start), "center"),
+ taskCell(task, formatWbsDate(task.finish), "center"),
+ taskCell(task, formatDurationLabel(task, holidaySet, nonWorkingDayTypes, options.useBusinessDaysForProgressBand), "center"),
+ detailCell(task, task.notes),
+ progressCell(task, task.percentComplete),
+ progressCell(task, task.percentWorkComplete),
+ flagCell(task, task.milestone, "Mil"),
+ flagCell(task, task.summary, "Sum"),
+ flagCell(task, task.critical, "Crit"),
+ referenceCell(task, truncateWbsReference(firstResourceName(resourceNamesByTaskUid.get(task.uid)), 14), "center"),
+ referenceCell(task, formatCalendarLabel(task.calendarUID || model.project.calendarUID, calendarNameByUid), "center"),
+ referenceCell(task, truncateWbsReference((resourceNamesByTaskUid.get(task.uid) || []).join(", "), 18)),
+ referenceCell(task, truncateWbsReference(task.predecessors.map((item) => predecessorNameByUid.get(item.predecessorUid) || item.predecessorUid).join(", "), 18)),
+ dividerCell(),
+ ...dateBand.map((day) => dateBandCell(task, day, model.project.currentDate, holidaySet, nonWorkingDayTypes, options.useBusinessDaysForProgressBand))
+ ]
+ })));
+ rows.push(emptyRow(totalColumns, 28));
+ const legendBlock = legendRows(totalColumns, rows.length + 1);
+ rows.push(...legendBlock.rows);
+ mergedRanges.push(...legendBlock.mergedRanges);
+ rows.push(emptyRow(totalColumns, 28));
+ const summaryBlock = displaySummaryRows(
+ dateBand.length,
+ countBusinessDays(dateBand, holidaySet, nonWorkingDayTypes),
+ model.project.currentDate,
+ model.tasks.length,
+ model.resources.length,
+ model.assignments.length,
+ model.calendars.length,
+ totalColumns,
+ 0,
+ rows.length + 1,
+ options.displayDaysBeforeBaseDate,
+ options.displayDaysAfterBaseDate,
+ options.useBusinessDaysForDisplayRange,
+ options.useBusinessDaysForProgressBand
+ );
+ rows.push(...summaryBlock.rows);
+ mergedRanges.push(...summaryBlock.mergedRanges);
+
+ return {
+ sheets: [
+ {
+ name: "WBS",
+ columns: [
+ { width: pxWidth(45) }, { width: pxWidth(45) }, { width: pxWidth(65) }, { width: pxWidth(60) }, { width: pxWidth(45) }, { width: 42 },
+ { width: pxWidth(85) }, { width: pxWidth(85) }, { width: pxWidth(65) }, { width: 28 }, { width: 14 },
+ { width: 18, hidden: true }, { width: 12, hidden: true }, { width: 12, hidden: true }, { width: 12, hidden: true },
+ { width: pxWidth(85) }, { width: 12, hidden: true }, { width: 20, hidden: true }, { width: 18, hidden: true }, { width: 3 },
+ ...dateBand.map(() => ({ width: 6 }))
+ ],
+ mergedRanges,
+ rows
+ }
+ ]
+ };
+ }
+
+ function emptyRow(columnCount: number, height = 22) {
+ return {
+ height,
+ cells: Array.from({ length: columnCount }, () => ({}))
+ };
+ }
+
+ function overlayRows(
+ rows: Array<{ height?: number; cells: WbsXlsxCellLike[] }>,
+ startIndex: number,
+ blockRows: Array<{ height?: number; cells: WbsXlsxCellLike[] }>,
+ columnCount: number
+ ) {
+ blockRows.forEach((blockRow, offset) => {
+ const rowIndex = startIndex + offset;
+ if (!rows[rowIndex]) {
+ rows[rowIndex] = emptyRow(columnCount);
+ }
+ const targetRow = rows[rowIndex];
+ if ((blockRow.height || 0) > (targetRow.height || 0)) {
+ targetRow.height = blockRow.height;
+ }
+ blockRow.cells.forEach((cell, cellIndex) => {
+ if (hasCellContent(cell)) {
+ targetRow.cells[cellIndex] = cell;
+ }
+ });
+ });
+ }
+
+ function hasCellContent(cell: WbsXlsxCellLike | undefined): boolean {
+ return !!cell && Object.keys(cell).length > 0;
+ }
+
+ function formatTaskLabel(task: TaskModel): string {
+ const prefix = task.summary ? "> " : (task.milestone ? "* " : "- ");
+ return `${" ".repeat(Math.max(0, task.outlineLevel - 1))}${prefix}${task.name}`;
+ }
+
+ function classifyTaskKind(task: TaskModel): string {
+ if (task.summary) {
+ return "フェーズ";
+ }
+ if (task.milestone) {
+ return "マイル";
+ }
+ return "タスク";
+ }
+
+ function firstResourceName(resourceNames: string[] | undefined): string {
+ if (!resourceNames || resourceNames.length === 0) {
+ return "";
+ }
+ return resourceNames[0];
+ }
+
+ function formatCalendarLabel(
+ calendarUID: string | undefined,
+ calendarNameByUid: Map
+ ): string {
+ if (!calendarUID) {
+ return "-";
+ }
+ const calendarName = calendarNameByUid.get(calendarUID);
+ return calendarName ? `${calendarUID} ${truncateWbsReference(calendarName, 9)}` : calendarUID;
+ }
+
+ function displayReferenceValue(value: string | undefined): string {
+ return value && value.trim() ? value : "-";
+ }
+
+ function truncateWbsReference(value: string | undefined, maxLength: number): string {
+ const normalized = value?.trim() || "";
+ if (!normalized) {
+ return "";
+ }
+ if (normalized.length <= maxLength) {
+ return normalized;
+ }
+ return `${normalized.slice(0, Math.max(1, maxLength - 3))}...`;
+ }
+
+ function referenceCell(
+ task: TaskModel,
+ value: string | undefined,
+ horizontalAlign: "left" | "center" | "right" = "center"
+ ): WbsXlsxCellLike {
+ const displayValue = displayReferenceValue(value);
+ const placeholder = displayValue === "-";
+ return {
+ value: displayValue,
+ border: "thin",
+ horizontalAlign: placeholder ? "center" : horizontalAlign,
+ verticalAlign: "center",
+ bold: task.summary || task.milestone || false,
+ fillColor: placeholder
+ ? PLACEHOLDER_FILL
+ : (task.summary
+ ? PHASE_FILL
+ : (task.milestone ? MILESTONE_FILL : REFERENCE_COLUMN_FILL))
+ };
+ }
+
+ function sheetTitleRow(title: string, columnCount: number) {
+ return {
+ height: 24,
+ cells: [
+ {
+ value: title,
+ bold: true,
+ fontSize: 16,
+ horizontalAlign: "left"
+ },
+ ...Array.from({ length: Math.max(0, columnCount - 1) }, () => ({
+ fillColor: "#EEF4FA"
+ }))
+ ]
+ };
+ }
+
+ function infoRow(text: string, columnCount: number) {
+ return {
+ height: 24,
+ cells: [
+ {
+ value: text,
+ border: "thin",
+ horizontalAlign: "left"
+ },
+ ...Array.from({ length: Math.max(0, columnCount - 1) }, () => ({}))
+ ]
+ };
+ }
+
+ function projectInfoRows(
+ project: ProjectModel["project"],
+ calendarNameByUid: Map,
+ holidayCount: number,
+ columnCount: number,
+ startColumnIndex: number,
+ startRowNumber: number
+ ) {
+ const items: Array<{ label: string; value: string | number; fillColor: string }> = [
+ { label: "プロジェクト名", value: truncateWbsReference(project.name || "-", 18) || "-", fillColor: SUMMARY_ASSIGNMENT_FILL },
+ { label: "カレンダ", value: formatCalendarLabel(project.calendarUID, calendarNameByUid), fillColor: SUMMARY_ASSIGNMENT_FILL },
+ { label: "開始日", value: formatWbsDate(project.startDate), fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "終了日", value: formatWbsDate(project.finishDate), fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "現在日", value: formatWbsDate(project.currentDate), fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "祝日", value: holidayCount, fillColor: SUMMARY_SCHEDULE_FILL }
+ ];
+ return {
+ mergedRanges: [
+ WBS_LAYOUT.range(
+ WBS_LAYOUT.reference(startRowNumber, startColumnIndex),
+ WBS_LAYOUT.reference(startRowNumber, startColumnIndex + 4)
+ ),
+ ...items.map((_, index) => {
+ const rowNumber = startRowNumber + index + 1;
+ return [
+ WBS_LAYOUT.range(
+ WBS_LAYOUT.reference(rowNumber, startColumnIndex),
+ WBS_LAYOUT.reference(rowNumber, startColumnIndex + 1)
+ ),
+ WBS_LAYOUT.range(
+ WBS_LAYOUT.reference(rowNumber, startColumnIndex + 2),
+ WBS_LAYOUT.reference(rowNumber, startColumnIndex + 4)
+ )
+ ];
+ }).flat()
+ ],
+ rows: [
+ projectBlockHeaderRow(columnCount, startColumnIndex, "プロジェクト情報"),
+ ...items.map((item) => projectPairRow(columnCount, startColumnIndex, item.label, item.value, item.fillColor))
+ ]
+ };
+ }
+
+ function legendRows(columnCount: number, startRowNumber: number) {
+ const items: Array<{ value: string; fillColor: string }> = [
+ { value: "進捗済み", fillColor: PROGRESS_BAND_FILL },
+ { value: "予定帯", fillColor: ACTIVE_BAND_FILL },
+ { value: "当日", fillColor: TODAY_BAND_FILL },
+ { value: "週頭", fillColor: WEEK_START_BAND_FILL },
+ { value: "週末", fillColor: WEEKEND_BAND_FILL },
+ { value: "祝日", fillColor: HOLIDAY_BAND_FILL },
+ { value: "━:フェーズ", fillColor: PHASE_FILL },
+ { value: "■:進捗済みタスク", fillColor: PROGRESS_BAND_FILL },
+ { value: "□:予定タスク", fillColor: ACTIVE_BAND_FILL },
+ { value: "◆:マイルストーン", fillColor: MILESTONE_FILL },
+ { value: "Mil:マイルストーン", fillColor: "#FBE4EC" },
+ { value: "Sum:サマリ", fillColor: "#F7EAF0" },
+ { value: "Crit:クリティカル", fillColor: "#F3E1E9" },
+ { value: "-:未設定", fillColor: PLACEHOLDER_FILL }
+ ];
+ const startColumnRef = WBS_LAYOUT.reference(startRowNumber, WBS_LAYOUT.columnIndex("A"));
+ const endColumnRef = WBS_LAYOUT.reference(startRowNumber, WBS_LAYOUT.columnIndex("C"));
+ return {
+ mergedRanges: [
+ WBS_LAYOUT.range(startColumnRef, endColumnRef),
+ ...items.map((_, index) => WBS_LAYOUT.range(
+ WBS_LAYOUT.reference(startRowNumber + index + 1, WBS_LAYOUT.columnIndex("A")),
+ WBS_LAYOUT.reference(startRowNumber + index + 1, WBS_LAYOUT.columnIndex("C"))
+ ))
+ ],
+ rows: [
+ blockHeaderRow(columnCount, 0, "凡例"),
+ ...items.map((item) => mergedLabelRow(columnCount, 0, item.value, item.fillColor))
+ ]
+ };
+ }
+
+ function weekBandRow(
+ fixedColumnCount: number,
+ weekBandRanges: Array<{ startIndex: number; label: string; hasMonthBoundary: boolean }>,
+ dateBandLength: number
+ ) {
+ const weekLabelColumnIndex = WBS_LAYOUT.columnIndex("S");
+ const dividerColumnIndex = WBS_LAYOUT.columnIndex("T");
+ const bandCells = Array.from({ length: dateBandLength }, () => ({} as WbsXlsxCellLike));
+ weekBandRanges.forEach((item, index) => {
+ bandCells[item.startIndex] = {
+ value: item.label,
+ bold: true,
+ fontSize: 14,
+ border: "thin" as const,
+ horizontalAlign: "center" as const,
+ fillColor: item.hasMonthBoundary ? MONTH_BOUNDARY_WEEK_FILL : (index % 2 === 0 ? "#EDF4FB" : "#EAF1F9")
+ };
+ });
+ return {
+ height: 24,
+ cells: [
+ ...Array.from({ length: fixedColumnCount }, (_, index) => {
+ if (index === weekLabelColumnIndex) {
+ return {
+ value: "週",
+ bold: true,
+ fontSize: 14,
+ border: "thin" as const,
+ horizontalAlign: "center" as const,
+ fillColor: "#E3EEF9"
+ };
+ }
+ if (index === dividerColumnIndex) {
+ return dividerCell();
+ }
+ if (index < weekLabelColumnIndex) {
+ return {};
+ }
+ return {};
+ }),
+ ...bandCells
+ ]
+ };
+ }
+
+ function displaySummaryRows(
+ displayDays: number,
+ businessDays: number,
+ baseDate: string | undefined,
+ taskCount: number,
+ resourceCount: number,
+ assignmentCount: number,
+ calendarCount: number,
+ columnCount: number,
+ startColumnIndex = 5,
+ startRowNumber = 5,
+ displayDaysBeforeBaseDate?: number,
+ displayDaysAfterBaseDate?: number,
+ useBusinessDaysForDisplayRange?: boolean,
+ useBusinessDaysForProgressBand?: boolean
+ ) {
+ const displayWeeks = displayDays > 0 ? Math.ceil(displayDays / 7) : 0;
+ const scheduleItems: Array<{ label: string; value: string | number; fillColor: string }> = [
+ { label: "表示日", value: displayDays, fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "表示週", value: displayWeeks, fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "営業日", value: businessDays, fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "前日数", value: displayDaysBeforeBaseDate ?? "-", fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "後日数", value: displayDaysAfterBaseDate ?? "-", fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "表示", value: useBusinessDaysForDisplayRange ? "営業日" : "暦日", fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "進捗", value: useBusinessDaysForProgressBand ? "営業日" : "暦日", fillColor: SUMMARY_SCHEDULE_FILL },
+ { label: "基準日", value: (baseDate || "-").slice(0, 10), fillColor: SUMMARY_SCHEDULE_FILL }
+ ];
+ const countItems: Array<{ label: string; value: string | number; fillColor: string }> = [
+ { label: "タスク", value: taskCount, fillColor: SUMMARY_ASSIGNMENT_FILL },
+ { label: "リソース", value: resourceCount, fillColor: SUMMARY_ASSIGNMENT_FILL },
+ { label: "割当", value: assignmentCount, fillColor: SUMMARY_ASSIGNMENT_FILL },
+ { label: "カレンダ", value: calendarCount, fillColor: SUMMARY_ASSIGNMENT_FILL }
+ ];
+ const blockRows = [blockHeaderRow(columnCount, startColumnIndex, "サマリ")];
+ for (const item of scheduleItems) {
+ blockRows.push(summaryPairRow(columnCount, startColumnIndex, item.label, item.value, item.fillColor));
+ }
+ for (const item of countItems) {
+ blockRows.push(summaryPairRow(columnCount, startColumnIndex, item.label, item.value, item.fillColor));
+ }
+ const mergedRanges = [
+ WBS_LAYOUT.range(
+ WBS_LAYOUT.reference(startRowNumber, startColumnIndex),
+ WBS_LAYOUT.reference(startRowNumber, startColumnIndex + 2)
+ )
+ ];
+ for (let index = 1; index < blockRows.length; index += 1) {
+ const rowNumber = startRowNumber + index;
+ mergedRanges.push(WBS_LAYOUT.range(
+ WBS_LAYOUT.reference(rowNumber, startColumnIndex + 1),
+ WBS_LAYOUT.reference(rowNumber, startColumnIndex + 2)
+ ));
+ }
+ return {
+ mergedRanges,
+ rows: blockRows
+ };
+ }
+
+ function blockHeaderRow(columnCount: number, startColumnIndex: number, title: string) {
+ const cells = Array.from({ length: columnCount }, () => ({} as WbsXlsxCellLike));
+ cells[startColumnIndex] = {
+ value: title,
+ border: "thin",
+ horizontalAlign: "left",
+ bold: true,
+ fontSize: 14,
+ fillColor: HEADER_ID_FILL
+ };
+ cells[startColumnIndex + 1] = {
+ value: "",
+ border: "thin",
+ fillColor: HEADER_ID_FILL
+ };
+ cells[startColumnIndex + 2] = {
+ value: "",
+ border: "thin",
+ fillColor: HEADER_ID_FILL
+ };
+ return { height: 24, cells };
+ }
+
+ function projectBlockHeaderRow(columnCount: number, startColumnIndex: number, title: string) {
+ const cells = Array.from({ length: columnCount }, () => ({} as WbsXlsxCellLike));
+ cells[startColumnIndex] = {
+ value: title,
+ border: "thin",
+ horizontalAlign: "left",
+ bold: true,
+ fontSize: 14,
+ fillColor: HEADER_ID_FILL
+ };
+ for (let offset = 1; offset < 5; offset += 1) {
+ cells[startColumnIndex + offset] = {
+ value: "",
+ border: "thin",
+ fillColor: HEADER_ID_FILL
+ };
+ }
+ return { height: 24, cells };
+ }
+
+ function projectPairRow(
+ columnCount: number,
+ startColumnIndex: number,
+ label: string,
+ value: string | number,
+ fillColor: string
+ ) {
+ const cells = Array.from({ length: columnCount }, () => ({} as WbsXlsxCellLike));
+ cells[startColumnIndex] = {
+ value: label,
+ border: "thin",
+ horizontalAlign: "right",
+ bold: true,
+ fillColor
+ };
+ cells[startColumnIndex + 1] = {
+ value: "",
+ border: "thin",
+ fillColor
+ };
+ cells[startColumnIndex + 2] = {
+ value: stringifyCellValue(value),
+ border: "thin",
+ horizontalAlign: typeof value === "number" ? "center" : "left",
+ bold: true,
+ fillColor
+ };
+ cells[startColumnIndex + 3] = {
+ value: "",
+ border: "thin",
+ fillColor
+ };
+ cells[startColumnIndex + 4] = {
+ value: "",
+ border: "thin",
+ fillColor
+ };
+ return { height: 22, cells };
+ }
+
+ function summaryPairRow(
+ columnCount: number,
+ startColumnIndex: number,
+ label: string,
+ value: string | number,
+ fillColor: string
+ ) {
+ const cells = Array.from({ length: columnCount }, () => ({} as WbsXlsxCellLike));
+ cells[startColumnIndex] = summaryStatCell(label, fillColor, false);
+ cells[startColumnIndex + 1] = summaryStatCell(value, fillColor, true);
+ cells[startColumnIndex + 2] = {
+ value: "",
+ border: "thin",
+ fillColor
+ };
+ return { height: 22, cells };
+ }
+
+ function overlaySummaryPair(
+ row: { height?: number; cells: WbsXlsxCellLike[] },
+ startColumnIndex: number,
+ label: string,
+ value: string | number,
+ fillColor: string
+ ) {
+ row.cells[startColumnIndex] = summaryStatCell(label, fillColor, false);
+ row.cells[startColumnIndex + 1] = summaryStatCell(value, fillColor, true);
+ row.height = Math.max(row.height || 22, 22);
+ }
+
+ function mergedLabelRow(
+ columnCount: number,
+ startColumnIndex: number,
+ value: string,
+ fillColor: string
+ ) {
+ const cells = Array.from({ length: columnCount }, () => ({} as WbsXlsxCellLike));
+ cells[startColumnIndex] = {
+ value,
+ border: "thin",
+ horizontalAlign: "center",
+ bold: true,
+ fillColor
+ };
+ cells[startColumnIndex + 1] = {
+ value: "",
+ border: "thin",
+ fillColor
+ };
+ cells[startColumnIndex + 2] = {
+ value: "",
+ border: "thin",
+ fillColor
+ };
+ return { height: 24, cells };
+ }
+
+ function summaryStatCell(value: string | number, fillColor: string, isValueCell: boolean): WbsXlsxCellLike {
+ const valueAlign = typeof value === "number" ? "center" : "left";
+ return {
+ value: stringifyCellValue(value),
+ border: "thin",
+ horizontalAlign: isValueCell ? valueAlign : "right",
+ bold: true,
+ fillColor
+ };
+ }
+
+ function headerRow(labels: Array) {
+ return {
+ height: 24,
+ cells: labels.map((label) => {
+ if (typeof label === "string") {
+ return {
+ value: label,
+ bold: true,
+ fillColor: headerFillForLabel(label),
+ border: "thin" as const,
+ horizontalAlign: "center" as const,
+ verticalAlign: "center" as const
+ };
+ }
+ return {
+ border: "thin" as const,
+ horizontalAlign: "center" as const,
+ verticalAlign: "center" as const,
+ ...label
+ };
+ })
+ };
+ }
+
+ function weekdayRow(
+ fixedColumnCount: number,
+ dateBand: string[],
+ currentDate: string | undefined,
+ holidaySet: Set,
+ nonWorkingDayTypes: Set
+ ) {
+ return {
+ height: 24,
+ cells: [
+ ...Array.from({ length: fixedColumnCount }, () => ({} as WbsXlsxCellLike)),
+ ...dateBand.map((day) => weekdayCell(day, currentDate, holidaySet, nonWorkingDayTypes))
+ ]
+ };
+ }
+
+ function dateBandHeaderRow(
+ fixedColumnCount: number,
+ dateBand: string[],
+ currentDate: string | undefined,
+ holidaySet: Set,
+ nonWorkingDayTypes: Set
+ ) {
+ return {
+ height: 24,
+ cells: [
+ ...Array.from({ length: fixedColumnCount }, () => ({} as WbsXlsxCellLike)),
+ ...dateBand.map((day) => dateNumberCell(day, currentDate, holidaySet, nonWorkingDayTypes))
+ ]
+ };
+ }
+
+ function weekdayHeaderRow(
+ fixedHeaders: string[],
+ dateBand: string[],
+ currentDate: string | undefined,
+ holidaySet: Set,
+ nonWorkingDayTypes: Set
+ ) {
+ return headerRow([
+ ...fixedHeaders,
+ dividerCell(),
+ ...dateBand.map((day) => weekdayCell(day, currentDate, holidaySet, nonWorkingDayTypes))
+ ]);
+ }
+
+ function dividerCell(): WbsXlsxCellLike {
+ return {
+ value: "",
+ fillColor: DIVIDER_FILL,
+ border: "thin",
+ horizontalAlign: "center",
+ verticalAlign: "center"
+ };
+ }
+
+ function headerFillForLabel(label: string): string {
+ if (label === "UID" || label === "ID") {
+ return HEADER_ID_FILL;
+ }
+ if (label === "WBS" || label === "種別" || label === "階層" || label === "名称") {
+ return HEADER_STRUCTURE_FILL;
+ }
+ if (label === "開始" || label === "終了" || label === "期間") {
+ return HEADER_SCHEDULE_FILL;
+ }
+ if (label === "タスク詳細") {
+ return HEADER_FILL;
+ }
+ if (label === "進捗" || label === "作業進捗" || label === "マイル" || label === "サマリ" || label === "クリティカル") {
+ return HEADER_STATUS_FILL;
+ }
+ if (label === "担当" || label === "カレンダ" || label === "リソース" || label === "先行") {
+ return HEADER_ASSIGNMENT_FILL;
+ }
+ return HEADER_FILL;
+ }
+
+ function cell(value: string | number | boolean | undefined): WbsXlsxCellLike {
+ if (value === undefined || value === "") {
+ return {};
+ }
+ return {
+ value: stringifyCellValue(value),
+ border: "thin"
+ };
+ }
+
+ function stringifyCellValue(value: string | number | boolean): string {
+ return typeof value === "string" ? value : String(value);
+ }
+
+ function taskCell(
+ task: TaskModel,
+ value: string | number | boolean | undefined,
+ horizontalAlign: "left" | "center" | "right" = "left"
+ ): WbsXlsxCellLike {
+ if (value === undefined || value === "") {
+ return {};
+ }
+ return {
+ value: stringifyCellValue(value),
+ border: "thin",
+ horizontalAlign,
+ verticalAlign: "center",
+ wrapText: typeof value === "string" ? true : undefined,
+ bold: task.summary || task.milestone || false,
+ fillColor: task.summary
+ ? PHASE_FILL
+ : (task.milestone
+ ? MILESTONE_FILL
+ : (horizontalAlign === "left"
+ ? NAME_COLUMN_FILL
+ : (horizontalAlign === "center" ? SCHEDULE_COLUMN_FILL : undefined)))
+ };
+ }
+
+ function detailCell(task: TaskModel, value: string | undefined): WbsXlsxCellLike {
+ const normalized = value?.trim() || "";
+ const placeholder = !normalized;
+ return {
+ value: placeholder ? "-" : normalized,
+ border: "thin",
+ horizontalAlign: "left",
+ verticalAlign: "center",
+ wrapText: placeholder ? undefined : true,
+ bold: task.summary || task.milestone || false,
+ fillColor: placeholder
+ ? PLACEHOLDER_FILL
+ : (task.summary
+ ? PHASE_FILL
+ : (task.milestone ? MILESTONE_FILL : NAME_COLUMN_FILL))
+ };
+ }
+
+ function taskRowHeight(task: TaskModel): number | undefined {
+ const labelLineCount = estimateWrappedLineCount(formatTaskLabel(task), 22);
+ const notesLineCount = estimateWrappedLineCount((task.notes || "").trim(), 18);
+ const maxLineCount = Math.max(labelLineCount, notesLineCount, 1);
+ if (maxLineCount >= 5) {
+ return 82;
+ }
+ if (maxLineCount === 4) {
+ return 70;
+ }
+ if (maxLineCount === 3) {
+ return 58;
+ }
+ if (maxLineCount === 2) {
+ return 46;
+ }
+ return 34;
+ }
+
+ function estimateWrappedLineCount(value: string, charactersPerLine: number): number {
+ const normalized = value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
+ if (!normalized) {
+ return 1;
+ }
+ return normalized
+ .split("\n")
+ .reduce((count, line) => count + Math.max(1, Math.ceil(line.length / charactersPerLine)), 0);
+ }
+
+ function kindCell(task: TaskModel): WbsXlsxCellLike {
+ return {
+ value: classifyTaskKind(task),
+ border: "thin",
+ horizontalAlign: "center",
+ verticalAlign: "center",
+ bold: true,
+ fillColor: task.summary ? PHASE_FILL : (task.milestone ? MILESTONE_FILL : TASK_KIND_FILL)
+ };
+ }
+
+ function identifierCell(task: TaskModel, value: string | number | boolean | undefined): WbsXlsxCellLike {
+ if (value === undefined || value === "") {
+ return {};
+ }
+ return {
+ value: stringifyCellValue(value),
+ border: "thin",
+ horizontalAlign: "center",
+ verticalAlign: "center",
+ bold: task.summary || task.milestone || false,
+ fillColor: task.summary ? PHASE_FILL : (task.milestone ? MILESTONE_FILL : IDENTIFIER_FILL)
+ };
+ }
+
+ function flagCell(task: TaskModel, enabled: boolean | undefined, marker: string): WbsXlsxCellLike {
+ return {
+ value: enabled ? marker : "",
+ border: "thin",
+ horizontalAlign: "center",
+ verticalAlign: "center",
+ bold: !!enabled,
+ fillColor: task.summary ? PHASE_FILL : (task.milestone ? MILESTONE_FILL : undefined)
+ };
+ }
+
+ function progressCell(task: TaskModel, value: number | undefined): WbsXlsxCellLike {
+ const progressValue = formatProgressLabel(value);
+ return {
+ value: progressValue,
+ border: "thin",
+ horizontalAlign: "center",
+ verticalAlign: "center",
+ wrapText: true,
+ bold: task.summary || task.milestone || false,
+ fillColor: task.summary ? PHASE_FILL : (task.milestone ? MILESTONE_FILL : PROGRESS_COLUMN_FILL)
+ };
+ }
+
+ function formatProgressLabel(value: number | undefined): string {
+ if (value === undefined || value === null || !Number.isFinite(value)) {
+ return "";
+ }
+ const clamped = Math.max(0, Math.min(100, Math.round(value)));
+ const filled = Math.round(clamped / 10);
+ const bar = `${"#".repeat(filled)}${"-".repeat(10 - filled)}`;
+ return `${String(clamped).padStart(3, " ")}%\n[${bar}]`;
+ }
+
+ function formatDurationLabel(
+ task: TaskModel,
+ holidaySet: Set,
+ nonWorkingDayTypes: Set,
+ useBusinessDaysForProgressBand: boolean | undefined
+ ): string {
+ if (useBusinessDaysForProgressBand) {
+ const businessDays = enumerateBusinessDays(task.start, task.finish, holidaySet, nonWorkingDayTypes).length;
+ return businessDays > 0 ? `${businessDays}営業日` : "-";
+ }
+ const calendarDays = buildDateBand(task.start, task.finish).length;
+ return calendarDays > 0 ? `${calendarDays}日` : "-";
+ }
+
+ function formatWbsDate(value: string | undefined): string {
+ return value ? value.slice(0, 10) : "-";
+ }
+
+ function formatWbsExportTimestamp(value: Date): string {
+ const year = value.getFullYear();
+ const month = String(value.getMonth() + 1).padStart(2, "0");
+ const day = String(value.getDate()).padStart(2, "0");
+ const hours = String(value.getHours()).padStart(2, "0");
+ const minutes = String(value.getMinutes()).padStart(2, "0");
+ return `出力日時 ${year}-${month}-${day} ${hours}:${minutes}`;
+ }
+
+ function dateNumberCell(
+ day: string,
+ currentDate: string | undefined,
+ holidaySet: Set,
+ nonWorkingDayTypes: Set
+ ): WbsXlsxCellLike {
+ const isToday = isSameDay(day, currentDate);
+ const isWeekendDay = isWeeklyNonWorkingDay(day, nonWorkingDayTypes);
+ const isHoliday = holidaySet.has(day);
+ const weekStart = isWeekStart(day);
+ const monthStart = isMonthStart(day);
+ return {
+ value: formatDateValue(day),
+ bold: true,
+ border: "thin",
+ horizontalAlign: "center",
+ verticalAlign: "center",
+ fillColor: isToday ? TODAY_BAND_FILL : (isHoliday ? HOLIDAY_BAND_FILL : (isWeekendDay ? WEEKEND_BAND_FILL : (monthStart ? MONTH_START_HEADER_FILL : (weekStart ? WEEK_START_BAND_FILL : HEADER_FILL))))
+ };
+ }
+
+ function weekdayCell(
+ day: string,
+ currentDate: string | undefined,
+ holidaySet: Set,
+ nonWorkingDayTypes: Set
+ ): WbsXlsxCellLike {
+ const isToday = isSameDay(day, currentDate);
+ const isWeekendDay = isWeeklyNonWorkingDay(day, nonWorkingDayTypes);
+ const isHoliday = holidaySet.has(day);
+ const weekStart = isWeekStart(day);
+ const monthStart = isMonthStart(day);
+ const target = parseDateOnly(day);
+ const dayType = target ? (target.getDay() === 0 ? 1 : target.getDay() + 1) : 0;
+ const weekendHeaderFill = dayType === 7 ? SATURDAY_HEADER_FILL : (dayType === 1 ? SUNDAY_HEADER_FILL : WEEKEND_BAND_FILL);
+ return {
+ value: formatWeekdayValue(day),
+ bold: true,
+ border: "thin",
+ horizontalAlign: "center",
+ verticalAlign: "center",
+ fillColor: isHoliday ? HOLIDAY_BAND_FILL : (isWeekendDay ? weekendHeaderFill : (isToday ? TODAY_BAND_FILL : (monthStart ? MONTH_START_HEADER_FILL : (weekStart ? WEEK_START_BAND_FILL : HEADER_FILL))))
+ };
+ }
+
+ function dateBandCell(
+ task: TaskModel,
+ day: string,
+ currentDate: string | undefined,
+ holidaySet: Set,
+ nonWorkingDayTypes: Set,
+ useBusinessDaysForProgressBand: boolean | undefined
+ ): WbsXlsxCellLike {
+ const isWeekendDay = isWeeklyNonWorkingDay(day, nonWorkingDayTypes);
+ const isHoliday = holidaySet.has(day);
+ const isNonWorkingDay = isWeekendDay || isHoliday;
+ const isTaskStart = isSameDay(day, task.start);
+ const isTaskFinish = isSameDay(day, task.finish);
+ const active = includesDay(task.start, task.finish, day) && (!isNonWorkingDay || isTaskStart || isTaskFinish);
+ const isToday = isSameDay(day, currentDate);
+ const weekStart = isWeekStart(day);
+ const complete = active && isCompletedDay(task, day, holidaySet, nonWorkingDayTypes, useBusinessDaysForProgressBand);
+ return {
+ value: active ? activeBandMarker(task, complete) : "",
+ border: "thin",
+ horizontalAlign: "center",
+ verticalAlign: "center",
+ fillColor: active
+ ? (complete
+ ? (isToday ? TODAY_PROGRESS_BAND_FILL : PROGRESS_BAND_FILL)
+ : (isToday ? TODAY_ACTIVE_BAND_FILL : ACTIVE_BAND_FILL))
+ : (isToday ? TODAY_BAND_FILL : (isHoliday ? HOLIDAY_BAND_FILL : (isWeekendDay ? WEEKEND_BAND_FILL : (weekStart ? WEEK_START_BAND_FILL : BAND_FILL))))
+ };
+ }
+
+ function activeBandMarker(task: TaskModel, complete: boolean): string {
+ if (task.summary) {
+ return "━";
+ }
+ if (task.milestone) {
+ return "◆";
+ }
+ return complete ? "■" : "□";
+ }
+
+ function buildDateBand(startDate: string | undefined, finishDate: string | undefined): string[] {
+ const start = parseDateOnly(startDate);
+ const finish = parseDateOnly(finishDate);
+ if (!start || !finish || start.getTime() > finish.getTime()) {
+ return [];
+ }
+ const days: string[] = [];
+ const cursor = new Date(start.getTime());
+ while (cursor.getTime() <= finish.getTime()) {
+ days.push(formatDateOnly(cursor));
+ cursor.setDate(cursor.getDate() + 1);
+ }
+ return days;
+ }
+
+ function buildDisplayDateBand(
+ startDate: string | undefined,
+ finishDate: string | undefined,
+ baseDate: string | undefined,
+ displayDaysBeforeBaseDate: number | undefined,
+ displayDaysAfterBaseDate: number | undefined,
+ holidaySet: Set,
+ nonWorkingDayTypes: Set,
+ useBusinessDaysForDisplayRange: boolean | undefined
+ ): string[] {
+ const fullBand = buildDateBand(startDate, finishDate);
+ const before = normalizeDisplayDayCount(displayDaysBeforeBaseDate);
+ const after = normalizeDisplayDayCount(displayDaysAfterBaseDate);
+ if (before === null && after === null) {
+ return fullBand;
+ }
+ const base = parseDateOnly(baseDate);
+ if (!base || fullBand.length === 0) {
+ return fullBand;
+ }
+ const projectStart = parseDateOnly(startDate);
+ const projectFinish = parseDateOnly(finishDate);
+ if (!projectStart || !projectFinish) {
+ return fullBand;
+ }
+ const from = useBusinessDaysForDisplayRange
+ ? shiftBusinessDays(base, -(before || 0), holidaySet, nonWorkingDayTypes)
+ : shiftCalendarDays(base, -(before || 0));
+ const to = useBusinessDaysForDisplayRange
+ ? shiftBusinessDays(base, after || 0, holidaySet, nonWorkingDayTypes)
+ : shiftCalendarDays(base, after || 0);
+ const clampedStart = from.getTime() < projectStart.getTime() ? projectStart : from;
+ const clampedFinish = to.getTime() > projectFinish.getTime() ? projectFinish : to;
+ if (clampedStart.getTime() > clampedFinish.getTime()) {
+ return fullBand;
+ }
+ return buildDateBand(formatDateOnly(clampedStart), formatDateOnly(clampedFinish));
+ }
+
+ function normalizeDisplayDayCount(value: number | undefined): number | null {
+ if (value === undefined || value === null || !Number.isFinite(value)) {
+ return null;
+ }
+ return Math.max(0, Math.floor(value));
+ }
+
+ function countBusinessDays(dateBand: string[], holidaySet: Set, nonWorkingDayTypes: Set): number {
+ return dateBand.filter((day) => !isWeeklyNonWorkingDay(day, nonWorkingDayTypes) && !holidaySet.has(day)).length;
+ }
+
+ function shiftCalendarDays(base: Date, offset: number): Date {
+ const result = new Date(base.getTime());
+ result.setDate(result.getDate() + offset);
+ return result;
+ }
+
+ function shiftBusinessDays(base: Date, offset: number, holidaySet: Set, nonWorkingDayTypes: Set): Date {
+ const result = new Date(base.getTime());
+ const direction = offset < 0 ? -1 : 1;
+ let remaining = Math.abs(offset);
+ while (remaining > 0) {
+ result.setDate(result.getDate() + direction);
+ const day = formatDateOnly(result);
+ if (isWeeklyNonWorkingDay(day, nonWorkingDayTypes) || holidaySet.has(day)) {
+ continue;
+ }
+ remaining -= 1;
+ }
+ return result;
+ }
+
+ function buildWeekBandRanges(dateBand: string[], startColumnIndex: number, rowNumber: number) {
+ const ranges: Array<{ range: string; startIndex: number; label: string; hasMonthBoundary: boolean }> = [];
+ if (dateBand.length === 0) {
+ return ranges;
+ }
+ let chunkStart = 0;
+ while (chunkStart < dateBand.length) {
+ const weekStart = formatWeekKey(dateBand[chunkStart]);
+ let chunkEnd = chunkStart;
+ while (chunkEnd + 1 < dateBand.length && formatWeekKey(dateBand[chunkEnd + 1]) === weekStart) {
+ chunkEnd += 1;
+ }
+ const chunkDays = dateBand.slice(chunkStart, chunkEnd + 1);
+ ranges.push({
+ range: WBS_LAYOUT.range(
+ WBS_LAYOUT.reference(rowNumber, startColumnIndex + chunkStart),
+ WBS_LAYOUT.reference(rowNumber, startColumnIndex + chunkEnd)
+ ),
+ startIndex: chunkStart,
+ label: formatWeekLabel(weekStart, chunkDays),
+ hasMonthBoundary: chunkDays.some((day) => isMonthStart(day))
+ });
+ chunkStart = chunkEnd + 1;
+ }
+ return ranges;
+ }
+
+ function includesDay(startDate: string | undefined, finishDate: string | undefined, day: string): boolean {
+ const start = parseDateOnly(startDate);
+ const finish = parseDateOnly(finishDate);
+ const target = parseDateOnly(day);
+ if (!start || !finish || !target) {
+ return false;
+ }
+ return start.getTime() <= target.getTime() && target.getTime() <= finish.getTime();
+ }
+
+ function isCompletedDay(
+ task: TaskModel,
+ day: string,
+ holidaySet: Set,
+ nonWorkingDayTypes: Set,
+ useBusinessDaysForProgressBand: boolean | undefined
+ ): boolean {
+ const start = parseDateOnly(task.start);
+ const finish = parseDateOnly(task.finish);
+ const target = parseDateOnly(day);
+ if (!start || !finish || !target) {
+ return false;
+ }
+ if (useBusinessDaysForProgressBand) {
+ const activeBusinessDays = enumerateBusinessDays(task.start, task.finish, holidaySet, nonWorkingDayTypes);
+ if (activeBusinessDays.length === 0) {
+ return false;
+ }
+ const percent = Math.max(0, Math.min(100, task.percentComplete || 0));
+ const completedDays = Math.floor(activeBusinessDays.length * (percent / 100));
+ const dayKey = formatDateOnly(target);
+ const dayIndex = activeBusinessDays.indexOf(dayKey);
+ return dayIndex >= 0 && dayIndex < completedDays;
+ }
+ const totalDays = Math.floor((finish.getTime() - start.getTime()) / 86400000) + 1;
+ if (totalDays <= 0) {
+ return false;
+ }
+ const percent = Math.max(0, Math.min(100, task.percentComplete || 0));
+ const completedDays = Math.floor(totalDays * (percent / 100));
+ const dayIndex = Math.floor((target.getTime() - start.getTime()) / 86400000);
+ return dayIndex >= 0 && dayIndex < completedDays;
+ }
+
+ function enumerateBusinessDays(
+ startDate: string | undefined,
+ finishDate: string | undefined,
+ holidaySet: Set,
+ nonWorkingDayTypes: Set
+ ): string[] {
+ return buildDateBand(startDate, finishDate).filter((day) => !isWeeklyNonWorkingDay(day, nonWorkingDayTypes) && !holidaySet.has(day));
+ }
+
+ function isSameDay(day: string, other: string | undefined): boolean {
+ return day === (other || "").slice(0, 10);
+ }
+
+ function isWeeklyNonWorkingDay(day: string, nonWorkingDayTypes: Set): boolean {
+ const target = parseDateOnly(day);
+ if (!target) {
+ return false;
+ }
+ const dayType = target.getDay() === 0 ? 1 : target.getDay() + 1;
+ return nonWorkingDayTypes.has(dayType);
+ }
+
+ function isWeekStart(day: string): boolean {
+ const target = parseDateOnly(day);
+ if (!target) {
+ return false;
+ }
+ return target.getDay() === 0;
+ }
+
+ function isMonthStart(day: string): boolean {
+ const target = parseDateOnly(day);
+ if (!target) {
+ return false;
+ }
+ return target.getDate() === 1;
+ }
+
+ function parseDateOnly(value: string | undefined): Date | null {
+ if (!value || value.length < 10) {
+ return null;
+ }
+ const dateOnly = value.slice(0, 10);
+ const [yearText, monthText, dayText] = dateOnly.split("-");
+ const year = Number(yearText);
+ const month = Number(monthText);
+ const day = Number(dayText);
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
+ return null;
+ }
+ return new Date(year, month - 1, day);
+ }
+
+ function expandExceptionDays(exception: CalendarExceptionModel): string[] {
+ const from = (exception.fromDate || "").slice(0, 10);
+ const to = (exception.toDate || "").slice(0, 10);
+ if (!from) {
+ return [];
+ }
+ if (!to || to === from) {
+ return [from];
+ }
+ const start = parseDateOnly(from);
+ const finish = parseDateOnly(to);
+ if (!start || !finish || start.getTime() > finish.getTime()) {
+ return [from];
+ }
+ const days: string[] = [];
+ const cursor = new Date(start.getTime());
+ while (cursor.getTime() <= finish.getTime()) {
+ days.push(formatDateOnly(cursor));
+ cursor.setDate(cursor.getDate() + 1);
+ }
+ return days;
+ }
+
+ function formatDateOnly(value: Date): string {
+ return [
+ value.getFullYear(),
+ String(value.getMonth() + 1).padStart(2, "0"),
+ String(value.getDate()).padStart(2, "0")
+ ].join("-");
+ }
+
+ function formatDateValue(day: string): string {
+ const target = parseDateOnly(day);
+ if (!target) {
+ return day;
+ }
+ return `${target.getMonth() + 1}/${target.getDate()}`;
+ }
+
+ function formatWeekdayValue(day: string): string {
+ const target = parseDateOnly(day);
+ if (!target) {
+ return day;
+ }
+ const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+ return weekdays[target.getDay()];
+ }
+
+ function formatWeekKey(day: string): string {
+ const target = parseDateOnly(day);
+ if (!target) {
+ return day;
+ }
+ const sunday = new Date(target.getTime());
+ const offset = sunday.getDay();
+ sunday.setDate(sunday.getDate() - offset);
+ return formatDateOnly(sunday);
+ }
+
+ function formatWeekLabel(weekKey: string, days: string[]): string {
+ if (days.length === 0) {
+ return "週";
+ }
+ const start = parseDateOnly(weekKey);
+ if (!start) {
+ return weekKey;
+ }
+ const monthSet = new Set(days.map((day) => {
+ const target = parseDateOnly(day);
+ return target ? target.getMonth() : -1;
+ }));
+ const startLabel = `${String(start.getMonth() + 1).padStart(2, "0")}/${String(start.getDate()).padStart(2, "0")}`;
+ if (monthSet.size <= 1) {
+ return `週 ${startLabel}`;
+ }
+ const tailMonths = Array.from(monthSet)
+ .filter((monthIndex) => monthIndex >= 0 && monthIndex !== start.getMonth())
+ .map((monthIndex) => String(monthIndex + 1).padStart(2, "0"));
+ return `週 ${startLabel} / ${tailMonths.join(" / ")}`;
+ }
+
+ function createWbsSheetLayoutHelper(): WbsSheetLayoutHelper {
+ return {
+ columnName(columnIndex: number): string {
+ let current = columnIndex + 1;
+ let name = "";
+ while (current > 0) {
+ const remainder = (current - 1) % 26;
+ name = String.fromCharCode(65 + remainder) + name;
+ current = Math.floor((current - 1) / 26);
+ }
+ return name;
+ },
+ columnIndex(columnReference: string): number {
+ const normalized = (columnReference || "").trim().toUpperCase();
+ if (!/^[A-Z]+$/.test(normalized)) {
+ throw new Error(`Invalid column reference: ${columnReference}`);
+ }
+ let value = 0;
+ for (const character of normalized) {
+ value = (value * 26) + (character.charCodeAt(0) - 64);
+ }
+ return value - 1;
+ },
+ reference(rowNumber: number, columnIndex: number): string {
+ if (!Number.isInteger(rowNumber) || rowNumber <= 0) {
+ throw new Error(`Invalid row number: ${rowNumber}`);
+ }
+ if (!Number.isInteger(columnIndex) || columnIndex < 0) {
+ throw new Error(`Invalid column index: ${columnIndex}`);
+ }
+ return `${this.columnName(columnIndex)}${rowNumber}`;
+ },
+ parseCellReference(reference: string) {
+ const match = /^([A-Z]+)(\d+)$/i.exec((reference || "").trim());
+ if (!match) {
+ throw new Error(`Invalid cell reference: ${reference}`);
+ }
+ const rowNumber = Number(match[2]);
+ const columnName = match[1].toUpperCase();
+ const columnIndex = this.columnIndex(columnName);
+ return {
+ reference: `${columnName}${rowNumber}`,
+ rowNumber,
+ rowIndex: rowNumber - 1,
+ columnName,
+ columnIndex
+ };
+ },
+ range(startReference: string, endReference: string): string {
+ return `${startReference}:${endReference}`;
+ },
+ describeCell(reference: string): string {
+ const cell = this.parseCellReference(reference);
+ return `${cell.reference} (row ${cell.rowNumber}, rowIndex ${cell.rowIndex}, column ${cell.columnName}, columnIndex ${cell.columnIndex})`;
+ },
+ logCell(reference: string, label?: string, logger?: (...args: unknown[]) => void): string {
+ const message = label
+ ? `${label}: ${this.describeCell(reference)}`
+ : this.describeCell(reference);
+ (logger || console.log)(message);
+ return message;
+ }
+ };
+ }
+
+ (globalThis as typeof globalThis & {
+ __mikuprojectWbsXlsx?: {
+ collectWbsHolidayDates: typeof collectWbsHolidayDates;
+ exportWbsWorkbook: typeof exportWbsWorkbook;
+ layout: WbsSheetLayoutHelper;
+ };
+ }).__mikuprojectWbsXlsx = {
+ collectWbsHolidayDates,
+ exportWbsWorkbook,
+ layout: WBS_LAYOUT
+ };
+})();
diff --git a/testdata/dependency.xml b/testdata/dependency.xml
new file mode 100644
index 0000000..b0200a7
--- /dev/null
+++ b/testdata/dependency.xml
@@ -0,0 +1,68 @@
+
+
+ Dependency Project
+ 2026-03-16T09:00:00
+ 2026-03-16T09:00:00
+ 2026-03-19T18:00:00
+ 1
+ 1
+
+
+ 1
+ Standard
+ 1
+
+
+
+
+ 1
+ 1
+ Prepare
+ 1
+ 1
+ 2026-03-16T09:00:00
+ 2026-03-17T18:00:00
+ PT16H0M0S
+ 0
+ 0
+ 100
+
+
+ 2
+ 2
+ Execute
+ 1
+ 2
+ 2026-03-18T09:00:00
+ 2026-03-19T18:00:00
+ PT16H0M0S
+ 0
+ 0
+ 0
+
+ 1
+ 1
+ PT0H0M0S
+
+
+
+
+
+ 1
+ 1
+ Miku
+ 1
+
+
+
+
+ 1
+ 2
+ 1
+ 2026-03-18T09:00:00
+ 2026-03-19T18:00:00
+ 1
+ PT16H0M0S
+
+
+
diff --git a/testdata/hierarchy.xml b/testdata/hierarchy.xml
new file mode 100644
index 0000000..cb33450
--- /dev/null
+++ b/testdata/hierarchy.xml
@@ -0,0 +1,52 @@
+
+
+ Hierarchy Project
+ 2026-03-16T09:00:00
+ 2026-03-16T09:00:00
+ 2026-03-18T18:00:00
+ 1
+
+
+ 1
+ 1
+ Summary
+ 1
+ 1
+ 2026-03-16T09:00:00
+ 2026-03-18T18:00:00
+ PT24H0M0S
+ 0
+ 1
+ 0
+
+
+ 2
+ 2
+ Child A
+ 2
+ 1.1
+ 2026-03-16T09:00:00
+ 2026-03-16T18:00:00
+ PT8H0M0S
+ 0
+ 0
+ 100
+
+
+ 3
+ 3
+ Child B
+ 2
+ 1.2
+ 2026-03-17T09:00:00
+ 2026-03-18T18:00:00
+ PT16H0M0S
+ 0
+ 0
+ 0
+ Second child task
+
+
+
+
+
diff --git a/testdata/minimal.xml b/testdata/minimal.xml
new file mode 100644
index 0000000..fe68b86
--- /dev/null
+++ b/testdata/minimal.xml
@@ -0,0 +1,24 @@
+
+
+ Minimal Project
+ 2026-03-16T09:00:00
+ 2026-03-16T18:00:00
+ 1
+
+
+ 1
+ 1
+ Single Task
+ 1
+ 1
+ 2026-03-16T09:00:00
+ 2026-03-16T18:00:00
+ PT8H0M0S
+ 0
+ 0
+ 0
+
+
+
+
+
diff --git a/testdata/workbook-import-sample.json b/testdata/workbook-import-sample.json
new file mode 100644
index 0000000..41e8dd3
--- /dev/null
+++ b/testdata/workbook-import-sample.json
@@ -0,0 +1,18 @@
+{
+ "format": "mikuproject_workbook_json",
+ "version": 1,
+ "sheets": {
+ "Project": [],
+ "Tasks": [
+ {
+ "UID": "3",
+ "Name": "初期実装 Imported From JSON File",
+ "PercentComplete": 55
+ }
+ ],
+ "Resources": [],
+ "Assignments": [],
+ "Calendars": [],
+ "NonWorkingDays": []
+ }
+}
diff --git a/tests/mikuproject-excel-io.test.js b/tests/mikuproject-excel-io.test.js
new file mode 100644
index 0000000..91f7137
--- /dev/null
+++ b/tests/mikuproject-excel-io.test.js
@@ -0,0 +1,739 @@
+// @vitest-environment jsdom
+
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { deflateRawSync } from "node:zlib";
+
+import { describe, expect, it } from "vitest";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const typesCode = readFileSync(
+ path.resolve(__dirname, "../src/js/types.js"),
+ "utf8"
+);
+const excelIoCode = readFileSync(
+ path.resolve(__dirname, "../src/js/excel-io.js"),
+ "utf8"
+);
+
+function bootExcelIoModule() {
+ new Function(`${typesCode}\n${excelIoCode}`)();
+ return globalThis.__mikuprojectExcelIo;
+}
+
+function decodeUtf8(bytes) {
+ return new TextDecoder().decode(bytes);
+}
+
+function encodeUtf8(text) {
+ return new TextEncoder().encode(text);
+}
+
+function buildDeflatedZipWithSingleEntry(name, text) {
+ const nameBytes = encodeUtf8(name);
+ const rawData = encodeUtf8(text);
+ const compressedData = new Uint8Array(deflateRawSync(rawData));
+
+ const localHeader = new Uint8Array(30 + nameBytes.length);
+ const localView = new DataView(localHeader.buffer);
+ localView.setUint32(0, 0x04034b50, true);
+ localView.setUint16(4, 20, true);
+ localView.setUint16(6, 0, true);
+ localView.setUint16(8, 8, true);
+ localView.setUint32(14, 0, true);
+ localView.setUint32(18, compressedData.byteLength, true);
+ localView.setUint32(22, rawData.byteLength, true);
+ localView.setUint16(26, nameBytes.length, true);
+ localView.setUint16(28, 0, true);
+ localHeader.set(nameBytes, 30);
+
+ const centralHeader = new Uint8Array(46 + nameBytes.length);
+ const centralView = new DataView(centralHeader.buffer);
+ centralView.setUint32(0, 0x02014b50, true);
+ centralView.setUint16(4, 20, true);
+ centralView.setUint16(6, 20, true);
+ centralView.setUint16(8, 0, true);
+ centralView.setUint16(10, 8, true);
+ centralView.setUint32(16, 0, true);
+ centralView.setUint32(20, compressedData.byteLength, true);
+ centralView.setUint32(24, rawData.byteLength, true);
+ centralView.setUint16(28, nameBytes.length, true);
+ centralView.setUint16(30, 0, true);
+ centralView.setUint16(32, 0, true);
+ centralView.setUint16(34, 0, true);
+ centralView.setUint16(36, 0, true);
+ centralView.setUint32(38, 0, true);
+ centralView.setUint32(42, 0, true);
+ centralHeader.set(nameBytes, 46);
+
+ const end = new Uint8Array(22);
+ const endView = new DataView(end.buffer);
+ endView.setUint32(0, 0x06054b50, true);
+ endView.setUint16(4, 0, true);
+ endView.setUint16(6, 0, true);
+ endView.setUint16(8, 1, true);
+ endView.setUint16(10, 1, true);
+ endView.setUint32(12, centralHeader.byteLength, true);
+ endView.setUint32(16, localHeader.byteLength + compressedData.byteLength, true);
+ endView.setUint16(20, 0, true);
+
+ const bytes = new Uint8Array(localHeader.byteLength + compressedData.byteLength + centralHeader.byteLength + end.byteLength);
+ let offset = 0;
+ bytes.set(localHeader, offset);
+ offset += localHeader.byteLength;
+ bytes.set(compressedData, offset);
+ offset += compressedData.byteLength;
+ bytes.set(centralHeader, offset);
+ offset += centralHeader.byteLength;
+ bytes.set(end, offset);
+ return bytes;
+}
+
+describe("mikuproject excel io", () => {
+ it("exports a minimal xlsx package with required workbook entries", () => {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const workbook = {
+ sheets: [
+ {
+ name: "Project",
+ rows: [
+ {
+ cells: [
+ { value: "Name" },
+ { value: "Miku Project" }
+ ]
+ }
+ ]
+ }
+ ]
+ };
+
+ const bytes = codec.exportWorkbook(workbook);
+ const entryNames = codec.listEntries(bytes);
+
+ expect(bytes).toBeInstanceOf(Uint8Array);
+ expect(bytes.byteLength).toBeGreaterThan(0);
+ expect(entryNames).toEqual([
+ "[Content_Types].xml",
+ "_rels/.rels",
+ "xl/_rels/workbook.xml.rels",
+ "xl/styles.xml",
+ "xl/workbook.xml",
+ "xl/worksheets/sheet1.xml"
+ ]);
+ });
+
+ it("round-trips sheet names, sparse cells, formulas, and primitive cell values as text by default", () => {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const workbook = {
+ sheets: [
+ {
+ name: "Project",
+ rows: [
+ {
+ cells: [
+ { value: "Task" },
+ { value: "Days" },
+ { value: "Done" },
+ { value: "Formula" }
+ ]
+ },
+ {
+ cells: [
+ { value: "Design" },
+ { value: "2" },
+ { value: "true" },
+ { formula: "B2*2", value: 4 }
+ ]
+ },
+ {
+ cells: [
+ { value: "Build" },
+ {},
+ { value: "false" },
+ { value: "" }
+ ]
+ }
+ ]
+ },
+ {
+ name: "Resources",
+ rows: [
+ {
+ cells: [
+ { value: "Name" },
+ { value: "Role" }
+ ]
+ },
+ {
+ cells: [
+ { value: "Miku" },
+ { value: "Engineer" }
+ ]
+ }
+ ]
+ }
+ ]
+ };
+
+ const bytes = codec.exportWorkbook(workbook);
+ const imported = codec.importWorkbook(bytes);
+
+ expect(imported).toEqual(workbook);
+ });
+
+ it("writes string cells as text-formatted inline strings and preserves leading whitespace", () => {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const workbook = {
+ sheets: [{
+ name: "Sheet1",
+ rows: [{
+ cells: [
+ { value: " - round-trip" },
+ { value: "plain" }
+ ]
+ }]
+ }]
+ };
+
+ const bytes = codec.exportWorkbook(workbook);
+ const entries = codec.unpackEntries(bytes);
+ const sheetXml = new TextDecoder().decode(entries["xl/worksheets/sheet1.xml"]);
+ const stylesXml = new TextDecoder().decode(entries["xl/styles.xml"]);
+
+ expect(sheetXml).toContain('t="inlineStr"> - round-trip ');
+ expect(stylesXml).toContain('numFmtId="49"');
+ expect(codec.importWorkbook(bytes)).toEqual(workbook);
+ });
+
+ it("sanitizes XML-invalid control characters from cell strings", () => {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const workbook = {
+ sheets: [{
+ name: "Sheet1",
+ rows: [{
+ cells: [
+ { value: "ok\u0000bad\u0008text" },
+ { value: "line1\nline2\tok" }
+ ]
+ }]
+ }]
+ };
+
+ const bytes = codec.exportWorkbook(workbook);
+ const entries = codec.unpackEntries(bytes);
+ const sheetXml = new TextDecoder().decode(entries["xl/worksheets/sheet1.xml"]);
+ const imported = codec.importWorkbook(bytes);
+
+ expect(sheetXml).toContain("okbadtext");
+ expect(sheetXml).not.toContain("\u0000");
+ expect(sheetXml).not.toContain("\u0008");
+ expect(imported.sheets[0].rows[0].cells[0].value).toBe("okbadtext");
+ expect(imported.sheets[0].rows[0].cells[1].value).toBe("line1\nline2\tok");
+ });
+
+ it("keeps explicitly formatted numeric cells as numeric values", () => {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const workbook = {
+ sheets: [{
+ name: "Numeric",
+ rows: [{
+ cells: [
+ { value: 12, numberFormat: "integer" },
+ { value: 0.5, numberFormat: "percent" },
+ { formula: "A1*2", value: 24 }
+ ]
+ }]
+ }]
+ };
+
+ const bytes = codec.exportWorkbook(workbook);
+ const imported = codec.importWorkbook(bytes);
+
+ expect(imported).toEqual(workbook);
+ });
+
+ it("round-trips column widths, row heights, and basic cell formatting", () => {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const workbook = {
+ sheets: [
+ {
+ name: "Schedule",
+ columns: [
+ { width: 24 },
+ { width: 12 },
+ { width: 14 }
+ ],
+ rows: [
+ {
+ height: 28,
+ cells: [
+ { value: "Task", horizontalAlign: "center" },
+ { value: "Start", horizontalAlign: "center" },
+ { value: "Progress", horizontalAlign: "center" }
+ ]
+ },
+ {
+ cells: [
+ { value: "Design" },
+ { value: 45367, numberFormat: "date" },
+ { value: 0.5, numberFormat: "percent" }
+ ]
+ }
+ ]
+ }
+ ]
+ };
+
+ const bytes = codec.exportWorkbook(workbook);
+ const imported = codec.importWorkbook(bytes);
+
+ expect(imported).toEqual(workbook);
+ });
+
+ it("round-trips hidden columns", () => {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const workbook = {
+ sheets: [
+ {
+ name: "HiddenCols",
+ columns: [
+ { width: 12 },
+ { width: 18, hidden: true },
+ { hidden: true }
+ ],
+ rows: [
+ {
+ cells: [
+ { value: "Visible" },
+ { value: "Hidden" },
+ { value: "HiddenNoWidth" }
+ ]
+ }
+ ]
+ }
+ ]
+ };
+
+ const bytes = codec.exportWorkbook(workbook);
+ const imported = codec.importWorkbook(bytes);
+ const sheetXml = decodeUtf8(codec.unpackEntries(bytes)["xl/worksheets/sheet1.xml"]);
+
+ expect(imported).toEqual(workbook);
+ expect(sheetXml).toContain('min="2" max="2" width="18" customWidth="1" hidden="1"');
+ expect(sheetXml).toContain('min="3" max="3" hidden="1"');
+ });
+
+ it("round-trips wrapped text alignment", () => {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const workbook = {
+ sheets: [
+ {
+ name: "Wrapped",
+ rows: [
+ {
+ cells: [
+ { value: "Long task name", horizontalAlign: "left", wrapText: true }
+ ]
+ }
+ ]
+ }
+ ]
+ };
+
+ const bytes = codec.exportWorkbook(workbook);
+ const imported = codec.importWorkbook(bytes);
+ const stylesXml = decodeUtf8(codec.unpackEntries(bytes)["xl/styles.xml"]);
+
+ expect(imported).toEqual(workbook);
+ expect(stylesXml).toContain('wrapText="1"');
+ });
+
+ it("round-trips bold, fill color, and border styles", () => {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const workbook = {
+ sheets: [
+ {
+ name: "Styled",
+ rows: [
+ {
+ cells: [
+ {
+ value: "Header",
+ bold: true,
+ fillColor: "#D9EAF7",
+ border: "thin"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ };
+
+ const bytes = codec.exportWorkbook(workbook);
+ const imported = codec.importWorkbook(bytes);
+
+ expect(imported).toEqual(workbook);
+ });
+
+ it("round-trips font size styles", () => {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const workbook = {
+ sheets: [
+ {
+ name: "Sized",
+ rows: [
+ {
+ cells: [
+ {
+ value: "Title",
+ bold: true,
+ fontSize: 16
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ };
+
+ const bytes = codec.exportWorkbook(workbook);
+ const imported = codec.importWorkbook(bytes);
+
+ expect(imported).toEqual(workbook);
+ });
+
+ it("round-trips merged cell ranges", () => {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const workbook = {
+ sheets: [
+ {
+ name: "Merged",
+ mergedRanges: ["A1:B1", "A3:A4"],
+ rows: [
+ {
+ cells: [
+ { value: "Header", bold: true },
+ {}
+ ]
+ },
+ {
+ cells: [
+ { value: "Row1" },
+ { value: "Value1" }
+ ]
+ },
+ {
+ cells: [
+ { value: "Group" },
+ { value: "Value2" }
+ ]
+ },
+ {
+ cells: [
+ {},
+ { value: "Value3" }
+ ]
+ }
+ ]
+ }
+ ]
+ };
+
+ const bytes = codec.exportWorkbook(workbook);
+ const imported = codec.importWorkbook(bytes);
+
+ expect(imported.sheets[0].name).toBe("Merged");
+ expect(imported.sheets[0].mergedRanges).toEqual(["A1:B1", "A3:A4"]);
+ expect(imported.sheets[0].rows[0].cells[0].value).toBe("Header");
+ expect(imported.sheets[0].rows[2].cells[0].value).toBe("Group");
+ expect(imported.sheets[0].rows[3].cells[1].value).toBe("Value3");
+ });
+
+ it("round-trips frozen pane settings", () => {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const workbook = {
+ sheets: [
+ {
+ name: "Frozen",
+ freezePane: {
+ rowSplit: 2,
+ colSplit: 3
+ },
+ rows: [
+ {
+ cells: [
+ { value: "A" },
+ { value: "B" },
+ { value: "C" },
+ { value: "D" }
+ ]
+ },
+ {
+ cells: [
+ { value: "1" },
+ { value: "2" },
+ { value: "3" },
+ { value: "4" }
+ ]
+ }
+ ]
+ }
+ ]
+ };
+
+ const bytes = codec.exportWorkbook(workbook);
+ const imported = codec.importWorkbook(bytes);
+ const sheetXml = decodeUtf8(codec.unpackEntries(bytes)["xl/worksheets/sheet1.xml"]);
+
+ expect(imported).toEqual(workbook);
+ expect(sheetXml).toContain("");
+ expect(sheetXml).toContain('xSplit="3"');
+ expect(sheetXml).toContain('ySplit="2"');
+ expect(sheetXml).toContain('topLeftCell="D3"');
+ expect(sheetXml).toContain('state="frozen"');
+ });
+
+ it("rejects duplicate or invalid sheet names", () => {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+
+ expect(() => codec.exportWorkbook({
+ sheets: [
+ { name: "Same", rows: [] },
+ { name: "Same", rows: [] }
+ ]
+ })).toThrow(/sheet name/i);
+
+ expect(() => codec.exportWorkbook({
+ sheets: [
+ { name: "Bad/Name", rows: [] }
+ ]
+ })).toThrow(/sheet name/i);
+ });
+
+ it("exposes workbook xml for inspection after unzip", () => {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const bytes = codec.exportWorkbook({
+ sheets: [
+ {
+ name: "One",
+ rows: [
+ {
+ cells: [
+ { value: "hello" },
+ { value: 1 }
+ ]
+ }
+ ]
+ }
+ ]
+ });
+
+ const entries = codec.unpackEntries(bytes);
+
+ expect(decodeUtf8(entries["xl/workbook.xml"])).toContain("1 ");
+ });
+
+ it("writes styles and sheet layout xml when formatting is present", () => {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const bytes = codec.exportWorkbook({
+ sheets: [
+ {
+ name: "Styled",
+ columns: [
+ { width: 18 }
+ ],
+ rows: [
+ {
+ height: 32,
+ cells: [
+ { value: 45367, numberFormat: "date", horizontalAlign: "center" }
+ ]
+ }
+ ]
+ }
+ ]
+ });
+
+ const entries = codec.unpackEntries(bytes);
+
+ expect(Object.keys(entries).sort()).toContain("xl/styles.xml");
+ expect(decodeUtf8(entries["[Content_Types].xml"])).toContain("/xl/styles.xml");
+ expect(decodeUtf8(entries["xl/_rels/workbook.xml.rels"])).toContain("styles.xml");
+ expect(decodeUtf8(entries["xl/styles.xml"])).toContain("numFmtId=\"14\"");
+ expect(decodeUtf8(entries["xl/styles.xml"])).toContain("horizontal=\"center\"");
+ expect(decodeUtf8(entries["xl/worksheets/sheet1.xml"])).toContain("");
+ expect(decodeUtf8(entries["xl/worksheets/sheet1.xml"])).toContain("width=\"18\"");
+ expect(decodeUtf8(entries["xl/worksheets/sheet1.xml"])).toContain("ht=\"32\"");
+ expect(decodeUtf8(entries["xl/worksheets/sheet1.xml"])).toContain("customHeight=\"1\"");
+ expect(decodeUtf8(entries["xl/worksheets/sheet1.xml"])).toContain(" s=\"1\"");
+ });
+
+ it("writes empty styled cells when a fill-only cell is present", () => {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const bytes = codec.exportWorkbook({
+ sheets: [
+ {
+ name: "StyledGap",
+ rows: [
+ {
+ cells: [
+ { value: "Header", fillColor: "#BFD7EA", border: "thin" },
+ { fillColor: "#BFD7EA", border: "thin" }
+ ]
+ }
+ ]
+ }
+ ]
+ });
+
+ const sheetXml = decodeUtf8(codec.unpackEntries(bytes)["xl/worksheets/sheet1.xml"]);
+
+ expect(sheetXml).toContain('r="A1"');
+ expect(sheetXml).toContain('r="B1"');
+ expect(sheetXml).toMatch(/ /);
+ });
+
+ it("writes font, fill, and border definitions when style options are present", () => {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const bytes = codec.exportWorkbook({
+ sheets: [
+ {
+ name: "Styled",
+ rows: [
+ {
+ cells: [
+ {
+ value: "Header",
+ bold: true,
+ fillColor: "#D9EAF7",
+ border: "thin"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ });
+
+ const stylesXml = decodeUtf8(codec.unpackEntries(bytes)["xl/styles.xml"]);
+
+ expect(stylesXml).toContain(" ");
+ expect(stylesXml).toContain('patternType="gray125"');
+ expect(stylesXml).toContain('patternType="solid"');
+ expect(stylesXml).toContain("FFD9EAF7");
+ expect(stylesXml).toContain("");
+ expect(stylesXml).toContain("applyFill=\"1\"");
+ expect(stylesXml).toContain("applyBorder=\"1\"");
+ expect(stylesXml).toContain("applyFont=\"1\"");
+ });
+
+ it("writes required default fills before custom solid fills", () => {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const bytes = codec.exportWorkbook({
+ sheets: [{
+ name: "Styled",
+ rows: [{
+ cells: [
+ { value: "Header", fillColor: "#D9EAF7", border: "thin" }
+ ]
+ }]
+ }]
+ });
+
+ const stylesXml = decodeUtf8(codec.unpackEntries(bytes)["xl/styles.xml"]);
+
+ expect(stylesXml).toContain('');
+ expect(stylesXml).toContain(' {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const bytes = codec.exportWorkbook({
+ sheets: [
+ {
+ name: "FontSized",
+ rows: [
+ {
+ cells: [
+ {
+ value: "Title",
+ fontSize: 16
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ });
+
+ const stylesXml = decodeUtf8(codec.unpackEntries(bytes)["xl/styles.xml"]);
+
+ expect(stylesXml).toContain(' ');
+ expect(stylesXml).toContain("applyFont=\"1\"");
+ });
+
+ it("writes mergeCells xml when merged ranges are present", () => {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const bytes = codec.exportWorkbook({
+ sheets: [
+ {
+ name: "Merged",
+ mergedRanges: ["A1:B1"],
+ rows: [
+ {
+ cells: [
+ { value: "Header" },
+ {}
+ ]
+ }
+ ]
+ }
+ ]
+ });
+
+ const worksheetXml = decodeUtf8(codec.unpackEntries(bytes)["xl/worksheets/sheet1.xml"]);
+
+ expect(worksheetXml).toContain(" {
+ const excelIo = bootExcelIoModule();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const bytes = buildDeflatedZipWithSingleEntry("[Content_Types].xml", " ");
+
+ const entries = await codec.unpackEntriesAsync(bytes);
+
+ expect(Object.keys(entries)).toEqual(["[Content_Types].xml"]);
+ expect(decodeUtf8(entries["[Content_Types].xml"])).toBe(" ");
+ });
+});
diff --git a/tests/mikuproject-main.test.js b/tests/mikuproject-main.test.js
new file mode 100644
index 0000000..9be2202
--- /dev/null
+++ b/tests/mikuproject-main.test.js
@@ -0,0 +1,2335 @@
+// @vitest-environment jsdom
+
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const typesCode = readFileSync(
+ path.resolve(__dirname, "../src/js/types.js"),
+ "utf8"
+);
+const markdownEscapeCode = readFileSync(
+ path.resolve(__dirname, "../src/js/markdown-escape.js"),
+ "utf8"
+);
+const excelIoCode = readFileSync(
+ path.resolve(__dirname, "../src/js/excel-io.js"),
+ "utf8"
+);
+const msProjectXmlCode = readFileSync(
+ path.resolve(__dirname, "../src/js/msproject-xml.js"),
+ "utf8"
+);
+const projectWorkbookSchemaCode = readFileSync(
+ path.resolve(__dirname, "../src/js/project-workbook-schema.js"),
+ "utf8"
+);
+const projectXlsxCode = readFileSync(
+ path.resolve(__dirname, "../src/js/project-xlsx.js"),
+ "utf8"
+);
+const projectWorkbookJsonCode = readFileSync(
+ path.resolve(__dirname, "../src/js/project-workbook-json.js"),
+ "utf8"
+);
+const wbsXlsxCode = readFileSync(
+ path.resolve(__dirname, "../src/js/wbs-xlsx.js"),
+ "utf8"
+);
+const wbsMarkdownCode = readFileSync(
+ path.resolve(__dirname, "../src/js/wbs-markdown.js"),
+ "utf8"
+);
+const nativeSvgCode = readFileSync(
+ path.resolve(__dirname, "../src/js/native-svg.js"),
+ "utf8"
+);
+const mainCode = readFileSync(
+ path.resolve(__dirname, "../src/js/main.js"),
+ "utf8"
+);
+const minimalXml = readFileSync(
+ path.resolve(__dirname, "../testdata/minimal.xml"),
+ "utf8"
+);
+const hierarchyXml = readFileSync(
+ path.resolve(__dirname, "../testdata/hierarchy.xml"),
+ "utf8"
+);
+const dependencyXml = readFileSync(
+ path.resolve(__dirname, "../testdata/dependency.xml"),
+ "utf8"
+);
+const workbookImportSampleJson = readFileSync(
+ path.resolve(__dirname, "../testdata/workbook-import-sample.json"),
+ "utf8"
+);
+
+function mountDom() {
+ document.body.innerHTML = `
+ Load from file
+ サンプル
+ XLSX
+ JSON
+ CSV
+ WBS XLSX
+ WBS Markdown
+ Mermaid
+ SVG
+ project_overview + all phase_detail full
+ project_overview_view
+ phase_detail_view full
+ phase_detail_view
+ サンプル draft
+ project_draft_view を取り込む
+ MS Project XML
+
+
+
+
+
+
+
+ 1
+ Input
+
+
+ 2
+ Transform
+
+
+ 3
+ Output
+
+
+
+
+
+
+ `;
+ const toast = document.getElementById("toast");
+ toast.show = vi.fn();
+ const copyButton = document.getElementById("copyAiPromptBtnPane");
+ if (copyButton) {
+ copyButton.id = "copyAiPromptBtn";
+ }
+}
+
+function bootPage() {
+ mountDom();
+ new Function(`${typesCode}\n${markdownEscapeCode}\n${excelIoCode}\n${msProjectXmlCode}\n${projectWorkbookSchemaCode}\n${projectXlsxCode}\n${projectWorkbookJsonCode}\n${wbsXlsxCode}\n${wbsMarkdownCode}\n${nativeSvgCode}\n${mainCode}`)();
+ document.dispatchEvent(new Event("DOMContentLoaded"));
+}
+
+function bootXmlModule() {
+ new Function(`${typesCode}\n${msProjectXmlCode}`)();
+ return globalThis.__mikuprojectXml;
+}
+
+function getMainHooks() {
+ return globalThis.__mikuprojectMainTestHooks;
+}
+
+function parseXmlViaHook() {
+ getMainHooks().parseCurrentXml();
+}
+
+async function exportMermaidViaHook() {
+ await getMainHooks().exportCurrentMermaid();
+}
+
+const SAMPLE_HOLIDAY_COUNT = 1;
+const SAMPLE_FIRST_HOLIDAY_NAME = "春分の日";
+const SAMPLE_FIRST_HOLIDAY_DATE = "2026-03-20";
+
+function getDefaultSampleHolidayDates() {
+ return globalThis.__mikuprojectWbsXlsx.collectWbsHolidayDates(
+ globalThis.__mikuprojectXml.importMsProjectXml(globalThis.__mikuprojectXml.SAMPLE_XML)
+ );
+}
+
+async function flushAsyncWork() {
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+
+describe("mikuproject main", () => {
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ Object.defineProperty(URL, "createObjectURL", {
+ value: vi.fn(() => "blob:mock"),
+ configurable: true
+ });
+ Object.defineProperty(URL, "revokeObjectURL", {
+ value: vi.fn(),
+ configurable: true
+ });
+ HTMLAnchorElement.prototype.click = vi.fn();
+ const clipboard = {
+ writeText: vi.fn(async () => {})
+ };
+ Object.defineProperty(globalThis.navigator, "clipboard", {
+ value: clipboard,
+ configurable: true
+ });
+ Object.defineProperty(window.navigator, "clipboard", {
+ value: clipboard,
+ configurable: true
+ });
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-16T23:12:00+09:00"));
+ });
+
+ it("loads sample xml on startup", () => {
+ bootPage();
+
+ expect(document.getElementById("xmlInput").value).toContain(" {
+ bootPage();
+
+ expect(document.getElementById("tabPanelInput").hidden).toBe(false);
+ expect(document.getElementById("tabPanelTransform").hidden).toBe(true);
+ expect(document.getElementById("tabPanelOutput").hidden).toBe(true);
+
+ document.querySelector('.md-top-tab[data-tab="transform"]').click();
+ await flushAsyncWork();
+ await flushAsyncWork();
+ expect(document.getElementById("tabPanelInput").hidden).toBe(true);
+ expect(document.getElementById("tabPanelTransform").hidden).toBe(false);
+ expect(document.getElementById("tabPanelOutput").hidden).toBe(true);
+ expect(document.getElementById("summaryProjectName").textContent).toBe("mikuproject開発");
+ expect(document.getElementById("mermaidOutput").value).toContain("gantt");
+ expect(document.getElementById("nativeSvgPreview").innerHTML).toContain(" {
+ bootPage();
+
+ document.getElementById("xmlInput").value = hierarchyXml;
+ parseXmlViaHook();
+ const createObjectUrlCalls = URL.createObjectURL.mock.calls.length;
+ const anchorClickCalls = HTMLAnchorElement.prototype.click.mock.calls.length;
+
+ document.getElementById("exportProjectOverviewBtn").click();
+ document.getElementById("exportPhaseDetailFullBtn").click();
+
+ const projectOverview = JSON.parse(document.getElementById("projectOverviewOutput").value);
+ const phaseDetail = JSON.parse(document.getElementById("phaseDetailOutput").value);
+
+ expect(projectOverview.view_type).toBe("project_overview_view");
+ expect(Array.isArray(projectOverview.phases)).toBe(true);
+ expect(projectOverview.phases.length).toBeGreaterThan(0);
+ expect(phaseDetail.view_type).toBe("phase_detail_view");
+ expect(Array.isArray(phaseDetail.tasks)).toBe(true);
+ expect(phaseDetail.phase.uid).toBeTruthy();
+ expect(phaseDetail.scope).toEqual({ mode: "full", root_uid: null, max_depth: null });
+ const downloads = HTMLAnchorElement.prototype.click.mock.instances
+ .slice(anchorClickCalls)
+ .map((anchor) => anchor.download);
+ expect(downloads).toContain("mikuproject-project-overview-view.editjson");
+ expect(downloads).toContain(`mikuproject-phase-detail-view-${phaseDetail.phase.uid}-full.editjson`);
+ expect(URL.createObjectURL.mock.calls.length - createObjectUrlCalls).toBeGreaterThanOrEqual(2);
+ expect(HTMLAnchorElement.prototype.click.mock.calls.length - anchorClickCalls).toBeGreaterThanOrEqual(2);
+ });
+
+ it("exports ai projection bundle", () => {
+ bootPage();
+
+ document.getElementById("xmlInput").value = hierarchyXml;
+ parseXmlViaHook();
+ const createObjectUrlCalls = URL.createObjectURL.mock.calls.length;
+ const anchorClickCalls = HTMLAnchorElement.prototype.click.mock.calls.length;
+
+ document.getElementById("exportAiBundleBtn").click();
+
+ const bundle = JSON.parse(document.getElementById("aiBundleOutput").value);
+ expect(bundle.view_type).toBe("ai_projection_bundle");
+ expect(bundle.project_overview_view.view_type).toBe("project_overview_view");
+ expect(Array.isArray(bundle.project_overview_view.phases)).toBe(true);
+ expect(Array.isArray(bundle.phase_detail_views_full)).toBe(true);
+ expect(bundle.phase_detail_views_full.length).toBeGreaterThan(0);
+ expect(bundle.phase_detail_views_full.every((item) => item.view_type === "phase_detail_view")).toBe(true);
+ expect(bundle.phase_detail_views_full.every((item) => item.scope?.mode === "full")).toBe(true);
+ const clickedAnchor = HTMLAnchorElement.prototype.click.mock.instances.at(-1);
+ expect(clickedAnchor.download).toBe("mikuproject-full-bundle.editjson");
+ expect(URL.createObjectURL.mock.calls.length - createObjectUrlCalls).toBeGreaterThanOrEqual(1);
+ expect(HTMLAnchorElement.prototype.click.mock.calls.length - anchorClickCalls).toBeGreaterThanOrEqual(1);
+ });
+
+ it("exports scoped phase_detail_view", () => {
+ bootPage();
+
+ document.getElementById("xmlInput").value = hierarchyXml;
+ parseXmlViaHook();
+ document.getElementById("phaseDetailUidInput").value = "1";
+ document.getElementById("phaseDetailRootUidInput").value = "2";
+ document.getElementById("phaseDetailMaxDepthInput").value = "1";
+
+ document.getElementById("exportPhaseDetailBtn").click();
+
+ const phaseDetail = JSON.parse(document.getElementById("phaseDetailOutput").value);
+ expect(phaseDetail.scope).toEqual({ mode: "scoped", root_uid: "2", max_depth: 1 });
+ expect(phaseDetail.tasks.every((task) => ["2", "3", "4", "5", "18"].includes(task.uid))).toBe(true);
+ expect(phaseDetail.tasks.some((task) => task.uid === "19")).toBe(false);
+ const clickedAnchor = HTMLAnchorElement.prototype.click.mock.instances.at(-1);
+ expect(clickedAnchor.download).toBe("mikuproject-phase-detail-view-1-scoped-root-2-depth-1.editjson");
+ });
+
+ it("imports project_draft_view", async () => {
+ bootPage();
+
+ document.getElementById("projectDraftImportInput").value = [
+ "説明文",
+ "```json",
+ JSON.stringify({
+ view_type: "project_draft_view",
+ project: {
+ name: "新規基幹刷新",
+ planned_start: "2026-04-01"
+ },
+ tasks: [
+ { uid: "draft-1", name: "要件定義", parent_uid: null, position: 0, is_summary: true, percent_complete: 100 },
+ { uid: "draft-2", name: "ヒアリング", parent_uid: "draft-1", position: 0, percent_complete: 50, planned_finish: "2026-04-01" },
+ { uid: "draft-3", name: "整理期間", parent_uid: "draft-1", position: 1, planned_start: "2026-04-02", planned_finish: "2026-04-03" },
+ { uid: "draft-4", name: "要件確定", parent_uid: "draft-1", position: 2, is_milestone: true, predecessors: ["draft-2"], planned_start: "2026-04-08T18:00:00", planned_finish: "2026-04-08T18:00:00" }
+ ]
+ }, null, 2),
+ "```"
+ ].join("\n");
+
+ document.getElementById("importProjectDraftBtn").click();
+ await flushAsyncWork();
+ await flushAsyncWork();
+
+ expect(document.getElementById("summaryProjectName").textContent).toBe("新規基幹刷新");
+ expect(document.getElementById("summaryTaskCount").textContent).toBe("4");
+ expect(document.getElementById("summaryCalendarCount").textContent).toBe("1");
+ expect(document.getElementById("xmlInput").value).toContain("新規基幹刷新 ");
+ expect(document.getElementById("xmlInput").value).toContain("新規基幹刷新 ");
+ expect(document.getElementById("xmlInput").value).toContain("1 ");
+ expect(document.getElementById("xmlInput").value).toContain("Standard ");
+ expect(document.getElementById("xmlInput").value).toContain("3 ");
+ expect(document.getElementById("modelOutput").value).toContain("\"title\": \"新規基幹刷新\"");
+ expect(document.getElementById("modelOutput").value).toContain("\"name\": \"ヒアリング\"");
+ expect(document.getElementById("modelOutput").value).toContain("\"milestone\": false");
+ expect(document.getElementById("modelOutput").value).toContain("\"percentComplete\": 100");
+ expect(document.getElementById("modelOutput").value).toContain("\"percentComplete\": 50");
+ expect(document.getElementById("modelOutput").value).toContain("\"start\": \"2026-04-01T09:00:00\"");
+ expect(document.getElementById("modelOutput").value).toContain("\"finish\": \"2026-04-01T18:00:00\"");
+ expect(document.getElementById("modelOutput").value).toContain("\"name\": \"整理期間\"");
+ expect(document.getElementById("modelOutput").value).toContain("\"start\": \"2026-04-02T09:00:00\"");
+ expect(document.getElementById("modelOutput").value).toContain("\"finish\": \"2026-04-03T18:00:00\"");
+ expect(document.getElementById("modelOutput").value).toContain("\"name\": \"要件確定\"");
+ expect(document.getElementById("modelOutput").value).toContain("\"milestone\": true");
+ expect(document.getElementById("modelOutput").value).toContain("\"uid\": \"4\"");
+ expect(document.getElementById("modelOutput").value).not.toContain("\"uid\": \"draft-4\"");
+ expect(document.getElementById("modelOutput").value).toContain("\"name\": \"Standard\"");
+ });
+
+ it("loads sample project_draft_view into the input area", () => {
+ bootPage();
+
+ document.getElementById("loadProjectDraftSampleBtn").click();
+
+ const draftText = document.getElementById("projectDraftImportInput").value;
+ expect(draftText).toContain("\"view_type\": \"project_draft_view\"");
+ expect(draftText).toContain("\"name\": \"mikuproject開発\"");
+ expect(draftText).toContain("架空検討フェーズ【架空】");
+ expect(document.getElementById("statusMessage").textContent).toContain("サンプル project_draft_view");
+ });
+
+ it("copies ai prompt to clipboard", async () => {
+ bootPage();
+
+ document.getElementById("copyAiPromptBtn").click();
+ await flushAsyncWork();
+
+ expect(globalThis.navigator.clipboard.writeText.mock.calls.length).toBeGreaterThan(0);
+ expect(globalThis.navigator.clipboard.writeText.mock.calls.at(-1)[0]).toContain("# mikuproject AI JSON Spec");
+ expect(document.getElementById("statusMessage").textContent).toContain("生成AIプロンプトをクリップボードにコピーしました");
+ });
+
+ it("parses xml into internal model summary", () => {
+ bootPage();
+
+ parseXmlViaHook();
+
+ expect(document.getElementById("summaryProjectName").textContent).toBe("mikuproject開発");
+ expect(document.getElementById("summaryTaskCount").textContent).toBe("13");
+ expect(document.getElementById("summaryResourceCount").textContent).toBe("0");
+ expect(document.getElementById("summaryAssignmentCount").textContent).toBe("0");
+ expect(document.getElementById("summaryCalendarCount").textContent).toBe("1");
+ expect(document.getElementById("modelOutput").value).toContain("\"name\": \"mikuproject開発\"");
+ expect(document.getElementById("modelOutput").value).toContain("\"name\": \"基盤整備\"");
+ expect(document.getElementById("modelOutput").value).toContain("\"name\": \"架空検討フェーズ【架空】\"");
+ expect(document.getElementById("modelOutput").value).toContain("\"name\": \"Standard\"");
+ expect(document.getElementById("projectPreview").textContent).toContain("mikuproject開発");
+ expect(document.getElementById("projectPreview").textContent).toContain("Calendar=1 (Standard)");
+ expect(document.getElementById("taskPreview").textContent).toContain("初期実装(MS Project XML 調査・基軸フォーマット選定・内部モデルの概要確定)");
+ expect(document.getElementById("resourcePreview").textContent).toContain("まだ表示できる項目がありません");
+ expect(document.getElementById("assignmentPreview").textContent).toContain("まだ表示できる項目がありません");
+ expect(document.getElementById("calendarPreview").textContent).toContain(`WeekDays=7 / Exceptions=${SAMPLE_HOLIDAY_COUNT} / WorkWeeks=0`);
+ });
+
+ it("exports xml from the current model", () => {
+ bootPage();
+
+ parseXmlViaHook();
+ document.getElementById("downloadXmlBtn").click();
+
+ const xmlText = document.getElementById("xmlInput").value;
+ expect(xmlText).toContain("");
+ expect(xmlText).toContain("\n\n");
+ expect(xmlText).toContain("mikuproject開発 ");
+ expect(xmlText).toContain("2026-03-16 ");
+ expect(xmlText).toContain("2026-04-01 ");
+ expect(xmlText).toContain("1 ");
+ expect(xmlText).toContain("Standard ");
+ expect(xmlText).toContain("架空検討フェーズ【架空】 ");
+ expect(xmlText).toContain("v1.0 リリース ");
+ expect(xmlText).toContain("2026-03-20T00:00:00 ");
+ expect(xmlText).not.toContain("2031-02-24T00:00:00 ");
+ });
+
+ it("exports mermaid gantt from the current model", async () => {
+ bootPage();
+
+ parseXmlViaHook();
+ await exportMermaidViaHook();
+
+ const mermaidText = document.getElementById("mermaidOutput").value;
+ expect(mermaidText).toContain("gantt");
+ expect(mermaidText).toContain("title mikuproject開発");
+ expect(mermaidText).toContain("section 基盤整備");
+ expect(mermaidText).toContain("section 架空検討フェーズ【架空】");
+ expect(mermaidText).toContain("初期実装");
+ expect(document.getElementById("nativeSvgPreview").innerHTML).toContain(" {
+ const xmlTools = bootXmlModule();
+ const model = {
+ project: {
+ name: "Mermaid Complex",
+ startDate: "2026-03-16T09:00:00",
+ finishDate: "2026-03-20T18:00:00",
+ scheduleFromStart: true,
+ outlineCodes: [],
+ wbsMasks: [],
+ extendedAttributes: []
+ },
+ tasks: [
+ {
+ uid: "1",
+ id: "1",
+ name: "Prep",
+ outlineLevel: 1,
+ outlineNumber: "1",
+ start: "2026-03-16T09:00:00",
+ finish: "2026-03-16T18:00:00",
+ duration: "PT8H0M0S",
+ milestone: false,
+ summary: false,
+ percentComplete: 100,
+ predecessors: [],
+ extendedAttributes: [],
+ baselines: [],
+ timephasedData: []
+ },
+ {
+ uid: "2",
+ id: "2",
+ name: "Review",
+ outlineLevel: 1,
+ outlineNumber: "2",
+ start: "2026-03-17T09:00:00",
+ finish: "2026-03-17T18:00:00",
+ duration: "PT8H0M0S",
+ milestone: false,
+ summary: false,
+ percentComplete: 0,
+ predecessors: [],
+ extendedAttributes: [],
+ baselines: [],
+ timephasedData: []
+ },
+ {
+ uid: "3",
+ id: "3",
+ name: "Ship",
+ outlineLevel: 1,
+ outlineNumber: "3",
+ start: "2026-03-18T09:00:00",
+ finish: "2026-03-18T18:00:00",
+ duration: "PT8H0M0S",
+ milestone: false,
+ summary: false,
+ percentComplete: 0,
+ predecessors: [
+ { predecessorUid: "1", type: 1, linkLag: "PT2H0M0S" },
+ { predecessorUid: "2", type: 4 }
+ ],
+ extendedAttributes: [],
+ baselines: [],
+ timephasedData: []
+ }
+ ],
+ resources: [],
+ assignments: [],
+ calendars: []
+ };
+
+ const mermaidText = xmlTools.exportMermaidGantt(model);
+
+ expect(mermaidText).toContain("Ship :task_3, 2026-03-18T09:00:00, 2026-03-18T18:00:00");
+ expect(mermaidText).toContain("%% dependency: Ship after Prep (type=FS, lag=2h) [task_3 after task_1]");
+ expect(mermaidText).toContain("%% dependency(pseudo): Ship ~= after Prep + 2h");
+ expect(mermaidText).toContain("%% dependency: Ship after Review (type=SS) [task_3 after task_2]");
+ expect(mermaidText).toContain("%% dependency(note): Ship has multiple predecessors");
+ });
+
+ it("sanitizes date-leading mermaid gantt labels", () => {
+ const xmlTools = bootXmlModule();
+ const model = {
+ project: {
+ name: "2026-03 mikuproject開発",
+ startDate: "2026-03-16T09:00:00",
+ finishDate: "2026-03-16T18:00:00",
+ scheduleFromStart: true,
+ outlineCodes: [],
+ wbsMasks: [],
+ extendedAttributes: []
+ },
+ tasks: [
+ {
+ uid: "1",
+ id: "1",
+ name: "2026-03-16 初期実装(42513dd:XML import/export)",
+ outlineLevel: 1,
+ outlineNumber: "1",
+ start: "2026-03-16T09:00:00",
+ finish: "2026-03-16T18:00:00",
+ duration: "PT8H0M0S",
+ milestone: false,
+ summary: false,
+ percentComplete: 0,
+ predecessors: [],
+ extendedAttributes: [],
+ baselines: [],
+ timephasedData: []
+ }
+ ],
+ resources: [],
+ assignments: [],
+ calendars: []
+ };
+
+ const mermaidText = xmlTools.exportMermaidGantt(model);
+
+ expect(mermaidText).toContain("title Project 2026-03 mikuproject開発");
+ expect(mermaidText).toContain("Task 2026-03-16 初期実装(42513dd XML import/export) :task_1, 2026-03-16T09:00:00, 2026-03-16T18:00:00");
+ });
+
+ it("exports csv with parent id from the current model", async () => {
+ bootPage();
+
+ parseXmlViaHook();
+ document.getElementById("downloadXmlBtn").click();
+ const createObjectUrlCalls = URL.createObjectURL.mock.calls.length;
+ const anchorClickCalls = HTMLAnchorElement.prototype.click.mock.calls.length;
+ const xmlInput = document.getElementById("xmlInput");
+ xmlInput.value = `${xmlInput.value}\n`;
+ xmlInput.dispatchEvent(new Event("input"));
+ document.getElementById("exportCsvBtn").click();
+
+ expect(URL.createObjectURL.mock.calls.length - createObjectUrlCalls).toBeGreaterThan(0);
+ expect(HTMLAnchorElement.prototype.click.mock.calls.length - anchorClickCalls).toBeGreaterThan(0);
+ const csvBlob = URL.createObjectURL.mock.calls.at(-1)?.[0];
+ expect(csvBlob).toBeTruthy();
+ expect(csvBlob.type).toBe("text/csv;charset=utf-8");
+ expect(document.getElementById("xmlInput").value).not.toContain("");
+ expect(document.getElementById("xmlSaveState").textContent).toContain("XML 保存状態: 保存済み (2026-03-16 23:12)");
+ expect(document.getElementById("statusMessage").textContent).toContain("CSV + ParentID を生成して保存しました");
+ });
+
+ it("parses csv with parent id into internal model summary", async () => {
+ bootPage();
+
+ const file = new File([[
+ "ID,ParentID,WBS,Name,Start,Finish,PredecessorID,Resource,PercentComplete",
+ "1,,1,Project Summary,2026-03-16T09:00:00,2026-03-20T18:00:00,,,50",
+ "2,1,1.1,Design,2026-03-16T09:00:00,2026-03-17T18:00:00,,Miku,100",
+ "3,1,1.2,Implementation,2026-03-18T09:00:00,2026-03-20T18:00:00,2,Miku,0"
+ ].join("\n")], "sample.csv", { type: "text/csv" });
+ Object.defineProperty(file, "text", {
+ configurable: true,
+ value: () => Promise.resolve([
+ "ID,ParentID,WBS,Name,Start,Finish,PredecessorID,Resource,PercentComplete",
+ "1,,1,Project Summary,2026-03-16T09:00:00,2026-03-20T18:00:00,,,50",
+ "2,1,1.1,Design,2026-03-16T09:00:00,2026-03-17T18:00:00,,Miku,100",
+ "3,1,1.2,Implementation,2026-03-18T09:00:00,2026-03-20T18:00:00,2,Miku,0"
+ ].join("\n"))
+ });
+
+ const importInput = document.getElementById("importFileInput");
+ Object.defineProperty(importInput, "files", {
+ value: [file],
+ configurable: true
+ });
+ importInput.dispatchEvent(new Event("change"));
+ await flushAsyncWork();
+ await flushAsyncWork();
+
+ expect(document.getElementById("summaryProjectName").textContent).toBe("CSV Imported Project");
+ expect(document.getElementById("summaryTaskCount").textContent).toBe("3");
+ expect(document.getElementById("summaryResourceCount").textContent).toBe("1");
+ expect(document.getElementById("summaryAssignmentCount").textContent).toBe("2");
+ expect(document.getElementById("summaryCalendarCount").textContent).toBe("1");
+ expect(document.getElementById("modelOutput").value).toContain("\"name\": \"CSV Imported Project\"");
+ expect(document.getElementById("modelOutput").value).toContain("\"title\": \"CSV Imported Project\"");
+ expect(document.getElementById("modelOutput").value).toContain("\"name\": \"Standard\"");
+ expect(document.getElementById("taskPreview").textContent).toContain("Implementation");
+ expect(document.getElementById("taskPreview").textContent).toContain("Predecessors=2");
+ expect(document.getElementById("resourcePreview").textContent).toContain("Miku");
+ expect(document.getElementById("assignmentPreview").textContent).toContain("Task=2 (Design)");
+ expect(document.getElementById("assignmentPreview").textContent).toContain("Resource=1 (Miku)");
+ expect(document.getElementById("mermaidOutput").value).toContain("gantt");
+ expect(document.getElementById("nativeSvgPreview").innerHTML).toContain(" {
+ bootPage();
+
+ parseXmlViaHook();
+ document.getElementById("roundTripBtn").click();
+
+ expect(document.getElementById("statusMessage").textContent).toContain("再読込テストに成功");
+ expect(document.getElementById("modelOutput").value).toContain("\"extendedAttributes\": [");
+ });
+
+ it("imports xml from a file into the textarea", async () => {
+ bootPage();
+
+ const importInput = document.getElementById("importFileInput");
+ const file = new File(["Imported "], "sample.xml", { type: "application/xml" });
+ Object.defineProperty(file, "text", {
+ configurable: true,
+ value: () => Promise.resolve("Imported ")
+ });
+ Object.defineProperty(importInput, "files", {
+ configurable: true,
+ value: [file]
+ });
+
+ importInput.dispatchEvent(new Event("change"));
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(document.getElementById("xmlInput").value).toContain("Imported ");
+ expect(document.getElementById("summaryProjectName").textContent).toBe("Imported");
+ expect(document.getElementById("summaryCalendarCount").textContent).toBe("1");
+ expect(document.getElementById("modelOutput").value).toContain("\"name\": \"Imported\"");
+ expect(document.getElementById("modelOutput").value).toContain("\"name\": \"Standard\"");
+ expect(document.getElementById("mermaidOutput").value).toContain("gantt");
+ expect(document.getElementById("nativeSvgPreview").innerHTML).toContain(" {
+ bootPage();
+
+ parseXmlViaHook();
+ document.getElementById("downloadXmlBtn").click();
+ const xmlInput = document.getElementById("xmlInput");
+ xmlInput.value = `${xmlInput.value}\n`;
+ xmlInput.dispatchEvent(new Event("input"));
+ document.getElementById("exportXlsxBtn").click();
+
+ expect(URL.createObjectURL).toHaveBeenCalled();
+ expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled();
+ const clickedAnchor = HTMLAnchorElement.prototype.click.mock.instances.at(-1);
+ expect(clickedAnchor.download).toBe("mikuproject-export-202603162312.xlsx");
+ expect(document.getElementById("xmlInput").value).not.toContain("");
+ expect(document.getElementById("xmlSaveState").textContent).toContain("XML 保存状態: 保存済み (2026-03-16 23:12)");
+ expect(document.getElementById("statusMessage").textContent).toContain("XLSX ファイルをエクスポートしました");
+ });
+
+ it("downloads current workbook json", () => {
+ bootPage();
+
+ parseXmlViaHook();
+ document.getElementById("exportWorkbookJsonBtn").click();
+
+ expect(URL.createObjectURL).toHaveBeenCalled();
+ expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled();
+ const clickedAnchor = HTMLAnchorElement.prototype.click.mock.instances.at(-1);
+ expect(clickedAnchor.download).toBe("mikuproject-workbook-202603162312.json");
+ const workbookJson = JSON.parse(document.getElementById("workbookJsonOutput").value);
+ expect(workbookJson.format).toBe("mikuproject_workbook_json");
+ expect(workbookJson.version).toBe(1);
+ expect(workbookJson.sheets.Tasks[0].UID).toBe("1");
+ expect(document.getElementById("statusMessage").textContent).toContain("workbook JSON を生成して保存しました");
+ });
+
+ it("downloads current wbs xlsx", () => {
+ bootPage();
+ const exportSpy = vi.spyOn(globalThis.__mikuprojectWbsXlsx, "exportWbsWorkbook");
+
+ parseXmlViaHook();
+ document.getElementById("downloadXmlBtn").click();
+ const xmlInput = document.getElementById("xmlInput");
+ xmlInput.value = `${xmlInput.value}\n`;
+ xmlInput.dispatchEvent(new Event("input"));
+ document.getElementById("exportWbsXlsxBtn").click();
+ const defaultHolidayDates = getDefaultSampleHolidayDates();
+
+ expect(URL.createObjectURL).toHaveBeenCalled();
+ expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled();
+ const clickedAnchor = HTMLAnchorElement.prototype.click.mock.instances.at(-1);
+ expect(clickedAnchor.download).toBe("mikuproject-wbs-202603162312.xlsx");
+ expect(document.getElementById("wbsHolidayDatesInput").value.split("\n")).toEqual(defaultHolidayDates);
+ expect(exportSpy.mock.calls.at(-1)?.[1]).toEqual({
+ holidayDates: defaultHolidayDates,
+ displayDaysBeforeBaseDate: undefined,
+ displayDaysAfterBaseDate: undefined,
+ useBusinessDaysForDisplayRange: true,
+ useBusinessDaysForProgressBand: true
+ });
+ expect(document.getElementById("xmlInput").value).not.toContain("");
+ expect(document.getElementById("xmlSaveState").textContent).toContain("XML 保存状態: 保存済み (2026-03-16 23:12)");
+ expect(document.getElementById("statusMessage").textContent).toContain("WBS XLSX ファイルをエクスポートしました");
+ expect(document.getElementById("statusMessage").textContent).toContain(`祝日 ${SAMPLE_HOLIDAY_COUNT} 件`);
+ });
+
+ it("downloads current wbs xlsx with configured display range", async () => {
+ bootPage();
+ const exportSpy = vi.spyOn(globalThis.__mikuprojectWbsXlsx, "exportWbsWorkbook");
+
+ parseXmlViaHook();
+ document.getElementById("wbsDisplayDaysBeforeInput").value = "1";
+ document.getElementById("wbsDisplayDaysAfterInput").value = "2";
+ document.getElementById("exportWbsXlsxBtn").click();
+ const defaultHolidayDates = getDefaultSampleHolidayDates();
+
+ expect(exportSpy).toHaveBeenCalled();
+ expect(exportSpy.mock.calls.at(-1)?.[1]).toEqual({
+ holidayDates: defaultHolidayDates,
+ displayDaysBeforeBaseDate: 1,
+ displayDaysAfterBaseDate: 2,
+ useBusinessDaysForDisplayRange: true,
+ useBusinessDaysForProgressBand: true
+ });
+ expect(document.getElementById("statusMessage").textContent).toContain("表示期間 営業日 基準日前 1 日, 基準日後 2 日");
+ });
+
+ it("downloads current wbs xlsx with business-day display range", async () => {
+ bootPage();
+ const exportSpy = vi.spyOn(globalThis.__mikuprojectWbsXlsx, "exportWbsWorkbook");
+
+ parseXmlViaHook();
+ document.getElementById("wbsDisplayDaysBeforeInput").value = "1";
+ document.getElementById("wbsDisplayDaysAfterInput").value = "2";
+ document.getElementById("exportWbsXlsxBtn").click();
+ const defaultHolidayDates = getDefaultSampleHolidayDates();
+
+ expect(exportSpy.mock.calls.at(-1)?.[1]).toEqual({
+ holidayDates: defaultHolidayDates,
+ displayDaysBeforeBaseDate: 1,
+ displayDaysAfterBaseDate: 2,
+ useBusinessDaysForDisplayRange: true,
+ useBusinessDaysForProgressBand: true
+ });
+ expect(document.getElementById("statusMessage").textContent).toContain("表示期間 営業日 基準日前 1 日, 基準日後 2 日");
+ });
+
+ it("downloads current wbs xlsx with business-day progress band", async () => {
+ bootPage();
+ const exportSpy = vi.spyOn(globalThis.__mikuprojectWbsXlsx, "exportWbsWorkbook");
+
+ parseXmlViaHook();
+ document.getElementById("exportWbsXlsxBtn").click();
+ const defaultHolidayDates = getDefaultSampleHolidayDates();
+
+ expect(exportSpy.mock.calls.at(-1)?.[1]).toEqual({
+ holidayDates: defaultHolidayDates,
+ displayDaysBeforeBaseDate: undefined,
+ displayDaysAfterBaseDate: undefined,
+ useBusinessDaysForDisplayRange: true,
+ useBusinessDaysForProgressBand: true
+ });
+ expect(document.getElementById("statusMessage").textContent).toContain("進捗帯 営業日");
+ });
+
+ it("fills wbs holiday input from model defaults when xml is parsed", () => {
+ bootPage();
+
+ parseXmlViaHook();
+ const defaultHolidayDates = getDefaultSampleHolidayDates();
+
+ expect(document.getElementById("wbsHolidayDatesInput").value.split("\n")).toEqual(defaultHolidayDates);
+ expect(document.getElementById("wbsHolidaySummary").textContent).toContain(`既定祝日: ${SAMPLE_HOLIDAY_COUNT} 件`);
+ expect(document.getElementById("wbsHolidaySummary").textContent).toContain("2026-03-20");
+ });
+
+ it("imports xlsx edits back into the current model and xml", async () => {
+ bootPage();
+ parseXmlViaHook();
+ document.getElementById("downloadXmlBtn").click();
+ expect(document.getElementById("xmlSaveState").textContent).toContain("XML 保存状態: 保存済み (2026-03-16 23:12)");
+
+ const codec = new globalThis.__mikuprojectExcelIo.XlsxWorkbookCodec();
+ const workbook = globalThis.__mikuprojectProjectXlsx.exportProjectWorkbook(
+ globalThis.__mikuprojectXml.importMsProjectXml(document.getElementById("xmlInput").value)
+ );
+ const tasksSheet = workbook.sheets.find((sheet) => sheet.name === "Tasks");
+ tasksSheet.rows[5].cells[2].value = "初期実装 Imported From XLSX";
+ tasksSheet.rows[5].cells[9].value = 77;
+ const bytes = codec.exportWorkbook(workbook);
+
+ const importInput = document.getElementById("importFileInput");
+ const file = new File([bytes], "sample.xlsx", {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ });
+ Object.defineProperty(file, "arrayBuffer", {
+ configurable: true,
+ value: () => Promise.resolve(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength))
+ });
+ Object.defineProperty(importInput, "files", {
+ configurable: true,
+ value: [file]
+ });
+
+ importInput.dispatchEvent(new Event("change"));
+ await flushAsyncWork();
+ await flushAsyncWork();
+
+ expect(document.getElementById("modelOutput").value).toContain("\"name\": \"初期実装 Imported From XLSX\"");
+ expect(document.getElementById("modelOutput").value).toContain("\"percentComplete\": 77");
+ expect(document.getElementById("xmlInput").value).toContain("初期実装 Imported From XLSX ");
+ expect(document.getElementById("statusMessage").textContent).toContain("XLSX を読み込んで 2 件の変更を反映しました");
+ expect(document.getElementById("statusMessage").textContent).toContain("XML Export で保存できます");
+ expect(document.getElementById("xmlSaveState").textContent).toContain("XML 保存状態: 未保存");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("Tasks");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("Tasks 1");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("変更なし: Project, Resources, Assignments, Calendars");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("UID=3 初期実装(MS Project XML 調査・基軸フォーマット選定・内部モデルの概要確定)");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("Name: 初期実装(MS Project XML 調査・基軸フォーマット選定・内部モデルの概要確定) -> 初期実装 Imported From XLSX");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("PercentComplete: 100 -> 77");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("反映後の XML は更新済みです");
+ expect(document.querySelectorAll("#xlsxImportSummary .md-xlsx-summary__section")).toHaveLength(1);
+ expect(document.querySelectorAll("#xlsxImportSummary .md-xlsx-summary__item")).toHaveLength(1);
+ });
+
+ it("clears file input value before reselecting the same file", () => {
+ bootPage();
+
+ const importInput = document.getElementById("importFileInput");
+ Object.defineProperty(importInput, "value", {
+ configurable: true,
+ writable: true,
+ value: "same-file.xlsx"
+ });
+
+ importInput.dispatchEvent(new Event("click"));
+
+ expect(importInput.value).toBe("");
+ });
+
+ it("imports workbook json edits back into the current model and xml", async () => {
+ bootPage();
+ parseXmlViaHook();
+
+ const workbookJson = globalThis.__mikuprojectProjectWorkbookJson.exportProjectWorkbookJson(
+ globalThis.__mikuprojectXml.importMsProjectXml(document.getElementById("xmlInput").value)
+ );
+ workbookJson.sheets.Tasks[2].Name = "初期実装 Imported From JSON";
+ workbookJson.sheets.Tasks[2].PercentComplete = 66;
+ const importInput = document.getElementById("importFileInput");
+ const file = new File([JSON.stringify(workbookJson, null, 2)], "workbook-inline.json", {
+ type: "application/json"
+ });
+ Object.defineProperty(file, "text", {
+ configurable: true,
+ value: () => Promise.resolve(JSON.stringify(workbookJson, null, 2))
+ });
+ Object.defineProperty(importInput, "files", {
+ configurable: true,
+ value: [file]
+ });
+
+ importInput.dispatchEvent(new Event("change"));
+ await flushAsyncWork();
+ await flushAsyncWork();
+
+ expect(document.getElementById("modelOutput").value).toContain("\"name\": \"初期実装 Imported From JSON\"");
+ expect(document.getElementById("modelOutput").value).toContain("\"percentComplete\": 66");
+ expect(document.getElementById("xmlInput").value).toContain("初期実装 Imported From JSON ");
+ expect(document.getElementById("statusMessage").textContent).toContain("JSON を読み込んで 2 件の変更を反映しました");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("Tasks 1");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("PercentComplete: 100 -> 66");
+ });
+
+ it("imports workbook json from a file into the current model and xml", async () => {
+ bootPage();
+ parseXmlViaHook();
+
+ const importInput = document.getElementById("importFileInput");
+ const file = new File([workbookImportSampleJson], "workbook-import-sample.json", {
+ type: "application/json"
+ });
+ Object.defineProperty(file, "text", {
+ configurable: true,
+ value: () => Promise.resolve(workbookImportSampleJson)
+ });
+ Object.defineProperty(importInput, "files", {
+ configurable: true,
+ value: [file]
+ });
+
+ importInput.dispatchEvent(new Event("change"));
+ await flushAsyncWork();
+ await flushAsyncWork();
+
+ expect(document.getElementById("modelOutput").value).toContain("\"name\": \"初期実装 Imported From JSON File\"");
+ expect(document.getElementById("modelOutput").value).toContain("\"percentComplete\": 55");
+ expect(document.getElementById("xmlInput").value).toContain("初期実装 Imported From JSON File ");
+ expect(document.getElementById("statusMessage").textContent).toContain("JSON を読み込んで 2 件の変更を反映しました");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("Tasks 1");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("PercentComplete: 100 -> 55");
+ });
+
+ it("reports ignored workbook json warnings in status message", async () => {
+ bootPage();
+ parseXmlViaHook();
+
+ const workbookJson = globalThis.__mikuprojectProjectWorkbookJson.exportProjectWorkbookJson(
+ globalThis.__mikuprojectXml.importMsProjectXml(document.getElementById("xmlInput").value)
+ );
+ workbookJson.sheets.Tasks[2].UnknownColumn = "ignored";
+ workbookJson.sheets.UnknownSheet = [];
+ const importInput = document.getElementById("importFileInput");
+ const file = new File([JSON.stringify(workbookJson, null, 2)], "workbook-warning.json", {
+ type: "application/json"
+ });
+ Object.defineProperty(file, "text", {
+ configurable: true,
+ value: () => Promise.resolve(JSON.stringify(workbookJson, null, 2))
+ });
+ Object.defineProperty(importInput, "files", {
+ configurable: true,
+ value: [file]
+ });
+
+ importInput.dispatchEvent(new Event("change"));
+ await flushAsyncWork();
+ await flushAsyncWork();
+
+ expect(document.getElementById("statusMessage").textContent).toContain("JSON 取込で 2 件の warning を無視しました");
+ expect(document.getElementById("importWarnings").textContent).toContain("未知の列は無視します: Tasks[2].UnknownColumn");
+ expect(document.getElementById("importWarnings").textContent).toContain("未知の sheet は無視します: UnknownSheet");
+ expect(document.querySelector(".md-feedback-stack")?.classList.contains("md-hidden")).toBe(false);
+ });
+
+ it("imports project sheet edits back into the current model and xml", async () => {
+ bootPage();
+ parseXmlViaHook();
+
+ const codec = new globalThis.__mikuprojectExcelIo.XlsxWorkbookCodec();
+ const workbook = globalThis.__mikuprojectProjectXlsx.exportProjectWorkbook(
+ globalThis.__mikuprojectXml.importMsProjectXml(document.getElementById("xmlInput").value)
+ );
+ const projectSheet = workbook.sheets.find((sheet) => sheet.name === "Project");
+ projectSheet.rows[3].cells[1].value = "Project From XLSX";
+ projectSheet.rows[13].cells[1].value = 420;
+ const bytes = codec.exportWorkbook(workbook);
+
+ const importInput = document.getElementById("importFileInput");
+ const file = new File([bytes], "project-sheet.xlsx", {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ });
+ Object.defineProperty(file, "arrayBuffer", {
+ configurable: true,
+ value: () => Promise.resolve(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength))
+ });
+ Object.defineProperty(importInput, "files", {
+ configurable: true,
+ value: [file]
+ });
+
+ importInput.dispatchEvent(new Event("change"));
+ await flushAsyncWork();
+ await flushAsyncWork();
+
+ expect(document.getElementById("modelOutput").value).toContain("\"name\": \"Project From XLSX\"");
+ expect(document.getElementById("modelOutput").value).toContain("\"minutesPerDay\": 420");
+ expect(document.getElementById("xmlInput").value).toContain("Project From XLSX ");
+ expect(document.getElementById("statusMessage").textContent).toContain("XLSX を読み込んで 2 件の変更を反映しました");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("Project 1");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("変更なし: Tasks, Resources, Assignments, Calendars");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("UID=project mikuproject開発");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("Name: mikuproject開発 -> Project From XLSX");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("MinutesPerDay: (empty) -> 420");
+ expect(document.querySelectorAll("#xlsxImportSummary .md-xlsx-summary__section")).toHaveLength(1);
+ expect(document.querySelectorAll("#xlsxImportSummary .md-xlsx-summary__item")).toHaveLength(1);
+ });
+
+ it("renders project import summary content without xlsx import wiring", () => {
+ bootPage();
+
+ getMainHooks().renderXlsxImportSummary([
+ { scope: "project", uid: "project", label: "mikuproject開発", field: "CalendarUID", before: "1", after: "2" },
+ { scope: "project", uid: "project", label: "mikuproject開発", field: "ScheduleFromStart", before: true, after: false },
+ { scope: "project", uid: "project", label: "mikuproject開発", field: "Author", before: undefined, after: "Author From XLSX" }
+ ]);
+
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("Project 1");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("変更なし: Tasks, Resources, Assignments, Calendars");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("UID=project mikuproject開発");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("CalendarUID: 1 -> 2");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("ScheduleFromStart: true -> false");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("Author: (empty) -> Author From XLSX");
+ expect(document.querySelectorAll("#xlsxImportSummary .md-xlsx-summary__section")).toHaveLength(1);
+ expect(document.querySelectorAll("#xlsxImportSummary .md-xlsx-summary__item")).toHaveLength(1);
+ });
+
+ it("reports when xlsx import has no applicable changes", async () => {
+ bootPage();
+ parseXmlViaHook();
+
+ const codec = new globalThis.__mikuprojectExcelIo.XlsxWorkbookCodec();
+ const originalXml = document.getElementById("xmlInput").value;
+ const workbook = globalThis.__mikuprojectProjectXlsx.exportProjectWorkbook(
+ globalThis.__mikuprojectXml.importMsProjectXml(originalXml)
+ );
+ const bytes = codec.exportWorkbook(workbook);
+
+ const importInput = document.getElementById("importFileInput");
+ const file = new File([bytes], "no-change.xlsx", {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ });
+ Object.defineProperty(file, "arrayBuffer", {
+ configurable: true,
+ value: () => Promise.resolve(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength))
+ });
+ Object.defineProperty(importInput, "files", {
+ configurable: true,
+ value: [file]
+ });
+
+ importInput.dispatchEvent(new Event("change"));
+ await flushAsyncWork();
+ await flushAsyncWork();
+
+ expect(document.getElementById("statusMessage").textContent).toContain("XLSX に反映対象の変更はありませんでした");
+ expect(document.getElementById("statusMessage").textContent).toContain("XML は未変更です");
+ expect(
+ globalThis.__mikuprojectXml.normalizeProjectModel(
+ globalThis.__mikuprojectXml.importMsProjectXml(document.getElementById("xmlInput").value)
+ )
+ ).toEqual(
+ globalThis.__mikuprojectXml.normalizeProjectModel(
+ globalThis.__mikuprojectXml.importMsProjectXml(originalXml)
+ )
+ );
+ expect(document.getElementById("xlsxImportSummary").textContent).toBe("");
+ expect(document.getElementById("xlsxImportSummary").classList.contains("md-hidden")).toBe(true);
+ });
+
+ it("ignores edits in unsupported xlsx columns and sheets", async () => {
+ bootPage();
+ parseXmlViaHook();
+
+ const codec = new globalThis.__mikuprojectExcelIo.XlsxWorkbookCodec();
+ const originalXml = document.getElementById("xmlInput").value;
+ const workbook = globalThis.__mikuprojectProjectXlsx.exportProjectWorkbook(
+ globalThis.__mikuprojectXml.importMsProjectXml(originalXml)
+ );
+ const tasksSheet = workbook.sheets.find((sheet) => sheet.name === "Tasks");
+ const calendarsSheet = workbook.sheets.find((sheet) => sheet.name === "Calendars");
+
+ tasksSheet.rows[4].cells[8].value = "PT99H0M0S";
+ calendarsSheet.rows[3].cells[4].value = 99;
+
+ const bytes = codec.exportWorkbook(workbook);
+ const importInput = document.getElementById("importFileInput");
+ const file = new File([bytes], "unsupported-columns.xlsx", {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ });
+ Object.defineProperty(file, "arrayBuffer", {
+ configurable: true,
+ value: () => Promise.resolve(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength))
+ });
+ Object.defineProperty(importInput, "files", {
+ configurable: true,
+ value: [file]
+ });
+
+ importInput.dispatchEvent(new Event("change"));
+ await flushAsyncWork();
+ await flushAsyncWork();
+
+ expect(document.getElementById("statusMessage").textContent).toContain("XLSX に反映対象の変更はありませんでした");
+ expect(document.getElementById("statusMessage").textContent).toContain("XML は未変更です");
+ expect(document.getElementById("modelOutput").value).not.toContain("\"duration\": \"PT99H0M0S\"");
+ expect(document.getElementById("modelOutput").value).not.toContain("\"weekDays\": 99");
+ expect(document.getElementById("xmlInput").value).toBe(originalXml);
+ expect(document.getElementById("xlsxImportSummary").textContent).toBe("");
+ expect(document.getElementById("xlsxImportSummary").classList.contains("md-hidden")).toBe(true);
+ });
+
+ it("ignores calendar WeekDays, Exceptions, and WorkWeeks edits in xlsx import", async () => {
+ bootPage();
+ parseXmlViaHook();
+
+ const codec = new globalThis.__mikuprojectExcelIo.XlsxWorkbookCodec();
+ const originalXml = document.getElementById("xmlInput").value;
+ const workbook = globalThis.__mikuprojectProjectXlsx.exportProjectWorkbook(
+ globalThis.__mikuprojectXml.importMsProjectXml(originalXml)
+ );
+ const calendarsSheet = workbook.sheets.find((sheet) => sheet.name === "Calendars");
+
+ calendarsSheet.rows[3].cells[4].value = 77;
+ calendarsSheet.rows[3].cells[5].value = 88;
+ calendarsSheet.rows[3].cells[6].value = 99;
+
+ const bytes = codec.exportWorkbook(workbook);
+ const importInput = document.getElementById("importFileInput");
+ const file = new File([bytes], "ignored-calendar-structure.xlsx", {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ });
+ Object.defineProperty(file, "arrayBuffer", {
+ configurable: true,
+ value: () => Promise.resolve(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength))
+ });
+ Object.defineProperty(importInput, "files", {
+ configurable: true,
+ value: [file]
+ });
+
+ importInput.dispatchEvent(new Event("change"));
+ await flushAsyncWork();
+ await flushAsyncWork();
+
+ expect(document.getElementById("statusMessage").textContent).toContain("XLSX に反映対象の変更はありませんでした");
+ expect(document.getElementById("statusMessage").textContent).toContain("XML は未変更です");
+ expect(document.getElementById("modelOutput").value).not.toContain("\"weekDays\": 77");
+ expect(document.getElementById("modelOutput").value).not.toContain("\"exceptions\": 88");
+ expect(document.getElementById("modelOutput").value).not.toContain("\"workWeeks\": 99");
+ expect(document.getElementById("xmlInput").value).toBe(originalXml);
+ expect(document.getElementById("xlsxImportSummary").textContent).toBe("");
+ expect(document.getElementById("xlsxImportSummary").classList.contains("md-hidden")).toBe(true);
+ }, 10000);
+
+ it("renders grouped xlsx import summary content without xlsx import wiring", () => {
+ bootPage();
+
+ getMainHooks().renderXlsxImportSummary([
+ { scope: "calendars", uid: "1", label: "Standard", field: "Name", before: "Standard", after: "Standard Updated" },
+ { scope: "tasks", uid: "3", label: "初期実装(MS Project XML 調査・基軸フォーマット選定・内部モデルの概要確定)", field: "Start", before: "2026-03-16", after: "2026-03-17" },
+ { scope: "tasks", uid: "3", label: "初期実装(MS Project XML 調査・基軸フォーマット選定・内部モデルの概要確定)", field: "Finish", before: "2026-03-16", after: "2026-03-18" },
+ { scope: "resources", uid: "1", label: "Miku", field: "Name", before: "Miku", after: "Miku Renamed" },
+ { scope: "assignments", uid: "1", label: "TaskUID=2", field: "Work", before: "PT16H0M0S", after: "PT12H0M0S" }
+ ]);
+
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("Tasks 1");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("Resources 1");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("Assignments 1");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("Calendars 1");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("変更なし: Project");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("UID=1 Standard");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("UID=3 初期実装(MS Project XML 調査・基軸フォーマット選定・内部モデルの概要確定)");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("UID=1 Miku");
+ expect(document.getElementById("xlsxImportSummary").textContent).toContain("UID=1 TaskUID=2");
+ expect(document.querySelectorAll("#xlsxImportSummary .md-xlsx-summary__section")).toHaveLength(4);
+ expect(document.querySelectorAll("#xlsxImportSummary .md-xlsx-summary__item")).toHaveLength(4);
+ });
+
+ it("renders validation issues without xlsx import wiring", () => {
+ bootPage();
+
+ getMainHooks().renderValidationIssues([
+ { level: "warning", scope: "tasks", message: "Task UID=2 (Design) PercentComplete must be within 0..100" },
+ { level: "warning", scope: "tasks", message: "Task UID=2 (Design) Start must be earlier than or equal to Finish" },
+ { level: "warning", scope: "calendars", message: "Calendar BaseCalendarUID が自身を指しています: UID=1 Name=Standard" }
+ ]);
+
+ expect(document.getElementById("validationIssues").classList.contains("md-hidden")).toBe(false);
+ expect(document.querySelector(".md-feedback-stack")?.classList.contains("md-hidden")).toBe(false);
+ expect(document.getElementById("validationIssues").textContent).toContain("Tasks");
+ expect(document.getElementById("validationIssues").textContent).toContain("Calendars");
+ expect(document.getElementById("validationIssues").textContent).toContain("PercentComplete");
+ expect(document.getElementById("validationIssues").textContent).toContain("Start");
+ expect(document.getElementById("validationIssues").textContent).toContain("BaseCalendarUID");
+ });
+
+
+ it("downloads current xml", () => {
+ bootPage();
+
+ document.getElementById("downloadXmlBtn").click();
+
+ expect(URL.createObjectURL).toHaveBeenCalled();
+ expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled();
+ const clickedAnchor = HTMLAnchorElement.prototype.click.mock.instances.at(-1);
+ expect(clickedAnchor.download).toBe("mikuproject-export-202603162312.xml");
+ expect(document.getElementById("statusMessage").textContent).toContain("XML ファイルをエクスポートしました");
+ expect(document.getElementById("xmlSaveState").textContent).toContain("XML 保存状態: 保存済み (2026-03-16 23:12)");
+ expect(document.getElementById("xmlSaveState").classList.contains("md-save-state--clean")).toBe(true);
+ });
+
+ it("returns xml save state to unsaved after manual xml edit", async () => {
+ bootPage();
+
+ document.getElementById("downloadXmlBtn").click();
+ expect(document.getElementById("xmlSaveState").textContent).toContain("XML 保存状態: 保存済み (2026-03-16 23:12)");
+
+ const xmlInput = document.getElementById("xmlInput");
+ xmlInput.value = `${xmlInput.value}\n`;
+ xmlInput.dispatchEvent(new Event("input"));
+ await flushAsyncWork();
+
+ expect(document.getElementById("xmlSaveState").textContent).toContain("XML 保存状態: 未保存");
+ expect(document.getElementById("xmlSaveState").classList.contains("md-save-state--dirty")).toBe(true);
+ });
+
+ it("exports regenerated xml instead of manual textarea edits when a model exists", async () => {
+ bootPage();
+
+ parseXmlViaHook();
+ const xmlInput = document.getElementById("xmlInput");
+ xmlInput.value = `${xmlInput.value}\n`;
+ xmlInput.dispatchEvent(new Event("input"));
+ await flushAsyncWork();
+
+ const OriginalBlob = Blob;
+ class InspectableBlob extends OriginalBlob {
+ __parts;
+
+ constructor(parts, options) {
+ super(parts, options);
+ this.__parts = parts;
+ }
+
+ text() {
+ return Promise.resolve(this.__parts.join(""));
+ }
+ }
+ globalThis.Blob = InspectableBlob;
+ URL.createObjectURL.mockClear();
+ HTMLAnchorElement.prototype.click.mockClear();
+
+ try {
+ document.getElementById("downloadXmlBtn").click();
+
+ expect(URL.createObjectURL).toHaveBeenCalled();
+ const exportedBlob = URL.createObjectURL.mock.calls.at(-1)?.[0];
+ expect(exportedBlob).toBeInstanceOf(InspectableBlob);
+ await expect(exportedBlob.text()).resolves.not.toContain("");
+ expect(document.getElementById("xmlInput").value).not.toContain("");
+ expect(document.getElementById("xmlSaveState").textContent).toContain("XML 保存状態: 保存済み (2026-03-16 23:12)");
+ } finally {
+ globalThis.Blob = OriginalBlob;
+ }
+ });
+
+
+ it("downloads rendered native svg", async () => {
+ bootPage();
+
+ parseXmlViaHook();
+ document.getElementById("downloadXmlBtn").click();
+ const xmlInput = document.getElementById("xmlInput");
+ xmlInput.value = `${xmlInput.value}\n`;
+ xmlInput.dispatchEvent(new Event("input"));
+ URL.createObjectURL.mockClear();
+ HTMLAnchorElement.prototype.click.mockClear();
+ document.getElementById("downloadSvgBtn").click();
+ await flushAsyncWork();
+ await flushAsyncWork();
+
+ expect(URL.createObjectURL).toHaveBeenCalled();
+ expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled();
+ const clickedAnchor = HTMLAnchorElement.prototype.click.mock.instances.at(-1);
+ expect(clickedAnchor.download).toBe("mikuproject-native.svg");
+ expect(document.getElementById("xmlInput").value).not.toContain("");
+ expect(document.getElementById("xmlSaveState").textContent).toContain("XML 保存状態: 保存済み (2026-03-16 23:12)");
+ expect(document.getElementById("mermaidOutput").value).toContain("gantt");
+ expect(document.getElementById("statusMessage").textContent).toContain("SVG を保存しました");
+ });
+
+ it("downloads mermaid markdown", () => {
+ bootPage();
+
+ parseXmlViaHook();
+ document.getElementById("exportMermaidMdBtn").click();
+
+ expect(URL.createObjectURL).toHaveBeenCalled();
+ expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled();
+ const clickedAnchor = HTMLAnchorElement.prototype.click.mock.instances.at(-1);
+ expect(clickedAnchor.download).toBe("mermaid-20260316.md");
+ const markdownBlob = URL.createObjectURL.mock.calls.at(-1)?.[0];
+ expect(markdownBlob).toBeTruthy();
+ expect(markdownBlob.type).toBe("text/markdown;charset=utf-8");
+ expect(document.getElementById("mermaidOutput").value).toContain("gantt");
+ expect(document.getElementById("statusMessage").textContent).toContain("Mermaid Markdown を保存しました");
+ });
+
+ it("downloads wbs markdown", () => {
+ bootPage();
+
+ parseXmlViaHook();
+ document.getElementById("exportWbsMdBtn").click();
+ expect(URL.createObjectURL).toHaveBeenCalled();
+ expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled();
+ const clickedAnchor = HTMLAnchorElement.prototype.click.mock.instances.at(-1);
+ expect(clickedAnchor.download).toBe("mikuproject-wbs-20260316.md");
+ const markdownBlob = URL.createObjectURL.mock.calls.at(-1)?.[0];
+ expect(markdownBlob).toBeTruthy();
+ expect(markdownBlob.type).toBe("text/markdown;charset=utf-8");
+ expect(document.getElementById("statusMessage").textContent).toContain("WBS Markdown を保存しました");
+ });
+
+ it("reports validation error when assignment references a missing resource", () => {
+ bootPage();
+
+ document.getElementById("xmlInput").value = dependencyXml.replace(
+ "1 ",
+ "99 "
+ );
+ parseXmlViaHook();
+ document.getElementById("roundTripBtn").click();
+
+ expect(document.getElementById("validationIssues").textContent).toContain("Assignment ResourceUID");
+ expect(document.getElementById("validationIssues").textContent).toContain("UID=1");
+ expect(document.getElementById("validationIssues").textContent).toContain("TaskUID=2");
+ expect(document.getElementById("validationIssues").textContent).toContain("Execute");
+ expect(document.getElementById("validationIssues").textContent).toContain("ResourceUID=99");
+ }, 10000);
+
+ it("reports validation error when project calendar does not exist", () => {
+ bootPage();
+
+ document.getElementById("xmlInput").value = dependencyXml.replace(
+ "1 ",
+ "99 "
+ );
+ parseXmlViaHook();
+
+ expect(document.getElementById("statusMessage").textContent).toContain("検証で");
+ expect(document.getElementById("validationIssues").textContent).toContain("Project");
+ expect(document.getElementById("validationIssues").textContent).toContain("Project CalendarUID");
+ });
+
+ it("reports validation warning when task calendar does not exist", () => {
+ bootPage();
+
+ document.getElementById("xmlInput").value = dependencyXml.replace(
+ "0 ",
+ "0 \n 99 "
+ );
+ parseXmlViaHook();
+
+ expect(document.getElementById("validationIssues").textContent).toContain("Task CalendarUID");
+ expect(document.getElementById("validationIssues").textContent).toContain("UID=2");
+ expect(document.getElementById("validationIssues").textContent).toContain("Execute");
+ });
+
+ it("reports validation warning when percent complete is out of range", () => {
+ bootPage();
+
+ document.getElementById("xmlInput").value = dependencyXml.replace(
+ "0 ",
+ "120 "
+ );
+ parseXmlViaHook();
+
+ expect(document.getElementById("validationIssues").textContent).toContain("PercentComplete");
+ });
+
+ it("reports validation warning when task start is after finish", () => {
+ bootPage();
+
+ document.getElementById("xmlInput").value = dependencyXml.replace(
+ "2026-03-18T09:00:00 \n 2026-03-19T18:00:00 ",
+ "2026-03-21T09:00:00 \n 2026-03-20T18:00:00 "
+ );
+ parseXmlViaHook();
+
+ expect(document.getElementById("validationIssues").textContent).toContain("Task Start が Finish より後");
+ });
+
+ it("reports validation warning when task order does not match outline order", () => {
+ bootPage();
+
+ document.getElementById("xmlInput").value = dependencyXml.replace(
+ "2 ",
+ "1 "
+ );
+ parseXmlViaHook();
+
+ expect(document.getElementById("validationIssues").textContent).toContain("Task の並び順");
+ expect(document.getElementById("validationIssues").textContent).toContain("UID=2");
+ expect(document.getElementById("validationIssues").textContent).toContain("Execute");
+ expect(document.getElementById("validationIssues").textContent).toContain("UID=1");
+ expect(document.getElementById("validationIssues").textContent).toContain("Prepare");
+ });
+
+ it("reports validation error when predecessor references a missing task", () => {
+ bootPage();
+
+ document.getElementById("xmlInput").value = dependencyXml.replace(
+ "1 ",
+ "99 "
+ );
+ parseXmlViaHook();
+ document.getElementById("roundTripBtn").click();
+
+ expect(document.getElementById("validationIssues").textContent).toContain("PredecessorUID");
+ expect(document.getElementById("validationIssues").textContent).toContain("UID=2");
+ expect(document.getElementById("validationIssues").textContent).toContain("Execute");
+ expect(document.getElementById("validationIssues").textContent).toContain("TaskUID=99");
+ }, 10000);
+
+ it("round-trips the minimal xml sample", () => {
+ const xmlTools = bootXmlModule();
+
+ const model = xmlTools.importMsProjectXml(minimalXml);
+ const exportedXml = xmlTools.exportMsProjectXml(model);
+ const reparsedModel = xmlTools.importMsProjectXml(exportedXml);
+
+ expect(model.project.name).toBe("Minimal Project");
+ expect(reparsedModel.project.name).toBe("Minimal Project");
+ expect(reparsedModel.project.title).toBeUndefined();
+ expect(reparsedModel.tasks).toHaveLength(1);
+ expect(reparsedModel.tasks[0].name).toBe("Single Task");
+ expect(xmlTools.validateProjectModel(reparsedModel)).toHaveLength(0);
+ });
+
+ it("round-trips project metadata fields", () => {
+ const xmlTools = bootXmlModule();
+ const xml = `
+
+ Metadata Project
+ Metadata Title
+ Example Company
+ Example Author
+ 2026-03-16T07:00:00
+ 2026-03-16T10:15:00
+ 14
+ JPY
+ 0
+ ¥
+ 0
+ 2026-04-01T00:00:00
+ 1
+ 0
+ 1
+ 2
+ 5000/h
+ 7000/h
+ 0
+ 0
+ 0
+ 1
+ 1
+ 0
+ 1
+ 1
+ 1
+ 0
+ 1
+ 0
+
+
+ 188743731
+ Outline Code1
+ Phase
+ 1
+
+
+ 1
+ *
+ 0
+ 0
+
+
+
+
+ PLAN
+ Planning
+
+
+
+
+
+
+ 1
+ A
+ 1
+ 1
+
+
+
+
+ 188743734
+ Text1
+ Owner
+ 0
+ 0
+ 1
+
+
+ 2026-03-16T09:00:00
+ 2026-03-16T18:00:00
+ 1
+
+
+
+ `;
+
+ const model = xmlTools.importMsProjectXml(xml);
+ const exportedXml = xmlTools.exportMsProjectXml(model);
+ const reparsedModel = xmlTools.importMsProjectXml(exportedXml);
+
+ expect(reparsedModel.project.title).toBe("Metadata Title");
+ expect(reparsedModel.project.company).toBe("Example Company");
+ expect(reparsedModel.project.author).toBe("Example Author");
+ expect(reparsedModel.project.creationDate).toBe("2026-03-16T07:00:00");
+ expect(reparsedModel.project.lastSaved).toBe("2026-03-16T10:15:00");
+ expect(reparsedModel.project.saveVersion).toBe(14);
+ expect(reparsedModel.project.currencyCode).toBe("JPY");
+ expect(reparsedModel.project.currencyDigits).toBe(0);
+ expect(reparsedModel.project.currencySymbol).toBe("¥");
+ expect(reparsedModel.project.currencySymbolPosition).toBe(0);
+ expect(reparsedModel.project.fyStartDate).toBe("2026-04-01T00:00:00");
+ expect(reparsedModel.project.fiscalYearStart).toBe(true);
+ expect(reparsedModel.project.criticalSlackLimit).toBe(0);
+ expect(reparsedModel.project.defaultTaskType).toBe(1);
+ expect(reparsedModel.project.defaultFixedCostAccrual).toBe(2);
+ expect(reparsedModel.project.defaultStandardRate).toBe("5000/h");
+ expect(reparsedModel.project.defaultOvertimeRate).toBe("7000/h");
+ expect(reparsedModel.project.defaultTaskEVMethod).toBe(0);
+ expect(reparsedModel.project.newTaskStartDate).toBe(0);
+ expect(reparsedModel.project.newTasksAreManual).toBe(false);
+ expect(reparsedModel.project.newTasksEffortDriven).toBe(true);
+ expect(reparsedModel.project.newTasksEstimated).toBe(true);
+ expect(reparsedModel.project.actualsInSync).toBe(false);
+ expect(reparsedModel.project.editableActualCosts).toBe(true);
+ expect(reparsedModel.project.honorConstraints).toBe(true);
+ expect(reparsedModel.project.insertedProjectsLikeSummary).toBe(true);
+ expect(reparsedModel.project.multipleCriticalPaths).toBe(false);
+ expect(reparsedModel.project.taskUpdatesResource).toBe(true);
+ expect(reparsedModel.project.updateManuallyScheduledTasksWhenEditingLinks).toBe(false);
+ expect(reparsedModel.project.outlineCodes).toHaveLength(1);
+ expect(reparsedModel.project.outlineCodes[0].fieldID).toBe("188743731");
+ expect(reparsedModel.project.outlineCodes[0].alias).toBe("Phase");
+ expect(reparsedModel.project.outlineCodes[0].values[0].value).toBe("PLAN");
+ expect(reparsedModel.project.wbsMasks).toHaveLength(1);
+ expect(reparsedModel.project.wbsMasks[0].mask).toBe("A");
+ expect(reparsedModel.project.extendedAttributes).toHaveLength(1);
+ expect(reparsedModel.project.extendedAttributes[0].fieldName).toBe("Text1");
+ expect(reparsedModel.project.extendedAttributes[0].alias).toBe("Owner");
+ expect(reparsedModel.project.extendedAttributes[0].appendNewValues).toBe(true);
+ });
+
+ it("round-trips project scheduling metadata fields", () => {
+ const xmlTools = bootXmlModule();
+ const xml = `
+
+ Schedule Metadata Project
+ 2026-03-17T09:00:00
+ 2
+ 2
+ 7
+ 2026-03-16T09:00:00
+ 2026-03-18T18:00:00
+ 1
+
+
+
+ `;
+
+ const model = xmlTools.importMsProjectXml(xml);
+ const exportedXml = xmlTools.exportMsProjectXml(model);
+ const reparsedModel = xmlTools.importMsProjectXml(exportedXml);
+
+ expect(reparsedModel.project.statusDate).toBe("2026-03-17T09:00:00");
+ expect(reparsedModel.project.weekStartDay).toBe(2);
+ expect(reparsedModel.project.workFormat).toBe(2);
+ expect(reparsedModel.project.durationFormat).toBe(7);
+ });
+
+ it("round-trips calendar base and weekday fields", () => {
+ const xmlTools = bootXmlModule();
+ const xml = `
+
+ Calendar Detail Project
+ 2026-03-16T09:00:00
+ 2026-03-16T18:00:00
+ 1
+
+
+ 1
+ Standard
+ 1
+ 1
+
+
+ 2
+ Night Shift
+ 0
+ 1
+
+
+ 7
+ 1
+
+
+ 18:00:00
+ 22:00:00
+
+
+
+
+
+
+
+
+
+ `;
+
+ const model = xmlTools.importMsProjectXml(xml);
+ const exportedXml = xmlTools.exportMsProjectXml(model);
+ const reparsedModel = xmlTools.importMsProjectXml(exportedXml);
+
+ expect(reparsedModel.calendars).toHaveLength(2);
+ expect(reparsedModel.calendars[0].isBaselineCalendar).toBe(true);
+ expect(reparsedModel.calendars[1].baseCalendarUID).toBe("1");
+ expect(reparsedModel.calendars[1].weekDays[0].dayType).toBe(7);
+ expect(reparsedModel.calendars[1].weekDays[0].dayWorking).toBe(true);
+ expect(reparsedModel.calendars[1].weekDays[0].workingTimes[0].fromTime).toBe("18:00:00");
+ expect(reparsedModel.calendars[1].weekDays[0].workingTimes[0].toTime).toBe("22:00:00");
+ });
+
+ it("round-trips calendar exceptions and workweeks", () => {
+ const xmlTools = bootXmlModule();
+ const xml = `
+
+ Calendar Exception Project
+ 2026-03-16T09:00:00
+ 2026-03-16T18:00:00
+ 1
+
+
+ 1
+ Standard
+ 1
+
+
+ Holiday
+ 2026-03-20T00:00:00
+ 2026-03-20T23:59:59
+ 0
+
+
+ 09:00:00
+ 12:00:00
+
+
+
+
+
+
+ Sprint 1
+ 2026-03-16T00:00:00
+ 2026-03-31T23:59:59
+
+
+ 2
+ 1
+
+
+ 09:00:00
+ 17:00:00
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const model = xmlTools.importMsProjectXml(xml);
+ const exportedXml = xmlTools.exportMsProjectXml(model);
+ const reparsedModel = xmlTools.importMsProjectXml(exportedXml);
+
+ expect(reparsedModel.calendars[0].exceptions[0].name).toBe("Holiday");
+ expect(reparsedModel.calendars[0].exceptions[0].dayWorking).toBe(false);
+ expect(reparsedModel.calendars[0].exceptions[0].workingTimes[0].fromTime).toBe("09:00:00");
+ expect(reparsedModel.calendars[0].exceptions[0].workingTimes[0].toTime).toBe("12:00:00");
+ expect(reparsedModel.calendars[0].workWeeks[0].name).toBe("Sprint 1");
+ expect(reparsedModel.calendars[0].workWeeks[0].weekDays[0].dayType).toBe(2);
+ expect(reparsedModel.calendars[0].workWeeks[0].weekDays[0].workingTimes[0].toTime).toBe("17:00:00");
+ });
+
+ it("warns when calendar baseCalendarUID points to itself", () => {
+ const xmlTools = bootXmlModule();
+ const xml = `
+
+ Self Base Calendar Project
+ 2026-03-16T09:00:00
+ 2026-03-16T18:00:00
+ 1
+
+
+ 1
+ Loop Calendar
+ 0
+ 1
+
+
+
+
+ 1
+ 1
+ Task
+ 1
+ 1
+ 2026-03-16T09:00:00
+ 2026-03-16T18:00:00
+ PT8H0M0S
+ 0
+ 0
+ 0
+
+
+ `;
+
+ const issues = xmlTools.validateProjectModel(xmlTools.importMsProjectXml(xml));
+
+ expect(issues.some((issue) => issue.message.includes("BaseCalendarUID が自身を指しています"))).toBe(true);
+ });
+
+ it("round-trips resource and assignment practical fields", () => {
+ const xmlTools = bootXmlModule();
+ const xml = `
+
+ Resource Assignment Project
+ 2026-03-16T09:00:00
+ 2026-03-17T18:00:00
+ 1
+
+
+ 1
+ Standard
+ 1
+
+
+
+
+ 1
+ 1
+ Assigned Task
+ 1
+ 1
+ 2026-03-16T09:00:00
+ 2026-03-17T18:00:00
+ PT16H0M0S
+ 0
+ 0
+ 0
+
+
+
+
+ 1
+ 1
+ Worker
+ 1
+ 0
+ 1
+ 8000/h
+ 2
+ 12000/h
+ 2
+ 1500
+ PT24H0M0S
+ PT8H0M0S
+ PT16H0M0S
+ 180000
+ 60000
+ 120000
+ 33
+
+
+
+
+ 1
+ 1
+ 1
+ 2026-03-16T09:00:00
+ 2026-03-17T18:00:00
+ PT1H0M0S
+ PT2H0M0S
+ PT3H0M0S
+ 0
+ 1
+ 1
+ PT16H0M0S
+ 100000
+ 30000
+ 70000
+ 50
+ PT2H0M0S
+ PT1H0M0S
+ PT6H0M0S
+ PT10H0M0S
+
+
+ `;
+
+ const model = xmlTools.importMsProjectXml(xml);
+ const exportedXml = xmlTools.exportMsProjectXml(model);
+ const reparsedModel = xmlTools.importMsProjectXml(exportedXml);
+
+ expect(reparsedModel.resources[0].calendarUID).toBe("1");
+ expect(reparsedModel.resources[0].workGroup).toBe(0);
+ expect(reparsedModel.resources[0].standardRate).toBe("8000/h");
+ expect(reparsedModel.resources[0].standardRateFormat).toBe(2);
+ expect(reparsedModel.resources[0].overtimeRate).toBe("12000/h");
+ expect(reparsedModel.resources[0].overtimeRateFormat).toBe(2);
+ expect(reparsedModel.resources[0].costPerUse).toBe(1500);
+ expect(reparsedModel.resources[0].work).toBe("PT24H0M0S");
+ expect(reparsedModel.resources[0].actualWork).toBe("PT8H0M0S");
+ expect(reparsedModel.resources[0].remainingWork).toBe("PT16H0M0S");
+ expect(reparsedModel.resources[0].cost).toBe(180000);
+ expect(reparsedModel.resources[0].actualCost).toBe(60000);
+ expect(reparsedModel.resources[0].remainingCost).toBe(120000);
+ expect(reparsedModel.resources[0].percentWorkComplete).toBe(33);
+ expect(reparsedModel.assignments[0].startVariance).toBe("PT1H0M0S");
+ expect(reparsedModel.assignments[0].finishVariance).toBe("PT2H0M0S");
+ expect(reparsedModel.assignments[0].delay).toBe("PT3H0M0S");
+ expect(reparsedModel.assignments[0].milestone).toBe(false);
+ expect(reparsedModel.assignments[0].workContour).toBe(1);
+ expect(reparsedModel.assignments[0].cost).toBe(100000);
+ expect(reparsedModel.assignments[0].actualCost).toBe(30000);
+ expect(reparsedModel.assignments[0].remainingCost).toBe(70000);
+ expect(reparsedModel.assignments[0].percentWorkComplete).toBe(50);
+ expect(reparsedModel.assignments[0].overtimeWork).toBe("PT2H0M0S");
+ expect(reparsedModel.assignments[0].actualOvertimeWork).toBe("PT1H0M0S");
+ expect(reparsedModel.assignments[0].actualWork).toBe("PT6H0M0S");
+ expect(reparsedModel.assignments[0].remainingWork).toBe("PT10H0M0S");
+ });
+
+ it("round-trips task and assignment cost fields", () => {
+ const xmlTools = bootXmlModule();
+ const xml = `
+
+ Cost Project
+ 2026-03-16T09:00:00
+ 2026-03-18T18:00:00
+ 1
+
+
+ 1
+ 1
+ Cost Task
+ 1
+ 1
+ 2026-03-16T09:00:00
+ 2026-03-18T18:00:00
+ PT24H0M0S
+ PT24H0M0S
+ 150000
+ 50000
+ 100000
+ 0
+ 0
+ 0
+
+
+
+
+
+ 1
+ 1
+ -65535
+ 2026-03-16T09:00:00
+ 2026-03-18T18:00:00
+ 1
+ PT24H0M0S
+ 150000
+ 50000
+ 100000
+
+
+ `;
+
+ const model = xmlTools.importMsProjectXml(xml);
+ const exportedXml = xmlTools.exportMsProjectXml(model);
+ const reparsedModel = xmlTools.importMsProjectXml(exportedXml);
+
+ expect(reparsedModel.tasks[0].cost).toBe(150000);
+ expect(reparsedModel.tasks[0].actualCost).toBe(50000);
+ expect(reparsedModel.tasks[0].remainingCost).toBe(100000);
+ expect(reparsedModel.assignments[0].cost).toBe(150000);
+ expect(reparsedModel.assignments[0].actualCost).toBe(50000);
+ expect(reparsedModel.assignments[0].remainingCost).toBe(100000);
+ });
+
+ it("round-trips task deadline and variance fields", () => {
+ const xmlTools = bootXmlModule();
+ const xml = `
+
+ Task Variance Project
+ 2026-03-16T09:00:00
+ 2026-03-18T18:00:00
+ 1
+
+
+ 1
+ 1
+ Variance Task
+ 1
+ 1
+ 2026-03-16T09:00:00
+ 2026-03-18T18:00:00
+ 2026-03-19T18:00:00
+ PT24H0M0S
+ PT1H0M0S
+ PT2H0M0S
+ 0
+ 0
+ 0
+
+
+
+
+ `;
+
+ const model = xmlTools.importMsProjectXml(xml);
+ const exportedXml = xmlTools.exportMsProjectXml(model);
+ const reparsedModel = xmlTools.importMsProjectXml(exportedXml);
+
+ expect(reparsedModel.tasks[0].deadline).toBe("2026-03-19T18:00:00");
+ expect(reparsedModel.tasks[0].startVariance).toBe("PT1H0M0S");
+ expect(reparsedModel.tasks[0].finishVariance).toBe("PT2H0M0S");
+ });
+
+ it("round-trips extended task work fields", () => {
+ const xmlTools = bootXmlModule();
+ const xml = `
+
+ Task Detail Project
+ 2026-03-16T09:00:00
+ 2026-03-17T18:00:00
+ 1
+
+
+ 1
+ 1
+ Detailed Task
+ 1
+ 1
+ 1
+ 1
+ 1
+ 700
+ 2026-03-16T09:00:00
+ 2026-03-17T18:00:00
+ PT16H0M0S
+ PT16H0M0S
+ PT1H0M0S
+ PT4H0M0S
+ PT2H0M0S
+ PT8H0M0S
+ PT8H0M0S
+ 0
+ 0
+ 1
+ 50
+ 50
+
+
+
+
+ `;
+
+ const model = xmlTools.importMsProjectXml(xml);
+ const exportedXml = xmlTools.exportMsProjectXml(model);
+ const reparsedModel = xmlTools.importMsProjectXml(exportedXml);
+
+ expect(reparsedModel.tasks[0].wbs).toBe("1");
+ expect(reparsedModel.tasks[0].type).toBe(1);
+ expect(reparsedModel.tasks[0].calendarUID).toBe("1");
+ expect(reparsedModel.tasks[0].priority).toBe(700);
+ expect(reparsedModel.tasks[0].work).toBe("PT16H0M0S");
+ expect(reparsedModel.tasks[0].workVariance).toBe("PT1H0M0S");
+ expect(reparsedModel.tasks[0].totalSlack).toBe("PT4H0M0S");
+ expect(reparsedModel.tasks[0].freeSlack).toBe("PT2H0M0S");
+ expect(reparsedModel.tasks[0].remainingWork).toBe("PT8H0M0S");
+ expect(reparsedModel.tasks[0].actualWork).toBe("PT8H0M0S");
+ expect(reparsedModel.tasks[0].critical).toBe(true);
+ expect(reparsedModel.tasks[0].percentWorkComplete).toBe(50);
+ });
+
+ it("round-trips the hierarchy xml sample", () => {
+ const xmlTools = bootXmlModule();
+
+ const model = xmlTools.importMsProjectXml(hierarchyXml);
+ const exportedXml = xmlTools.exportMsProjectXml(model);
+ const reparsedModel = xmlTools.importMsProjectXml(exportedXml);
+
+ expect(reparsedModel.tasks).toHaveLength(3);
+ expect(reparsedModel.tasks[0].summary).toBe(true);
+ expect(reparsedModel.tasks[1].outlineNumber).toBe("1.1");
+ expect(reparsedModel.tasks[2].notes).toBe("Second child task");
+ expect(xmlTools.validateProjectModel(reparsedModel)).toHaveLength(0);
+ });
+
+ it("round-trips the dependency xml sample", () => {
+ const xmlTools = bootXmlModule();
+
+ const model = xmlTools.importMsProjectXml(dependencyXml);
+ const exportedXml = xmlTools.exportMsProjectXml(model);
+ const reparsedModel = xmlTools.importMsProjectXml(exportedXml);
+
+ expect(reparsedModel.calendars).toHaveLength(1);
+ expect(reparsedModel.tasks[1].predecessors).toHaveLength(1);
+ expect(reparsedModel.tasks[1].predecessors[0].predecessorUid).toBe("1");
+ expect(reparsedModel.assignments).toHaveLength(1);
+ expect(reparsedModel.assignments[0].taskUid).toBe("2");
+ expect(xmlTools.validateProjectModel(reparsedModel)).toHaveLength(0);
+ });
+
+ it("imports csv with parent id into a minimal project model", () => {
+ const xmlTools = bootXmlModule();
+ const csv = `ID,ParentID,WBS,Name,Start,Finish,PredecessorID,Resource,PercentComplete
+1,,1,Project Summary,2026-03-16T09:00:00,2026-03-20T18:00:00,,,50
+2,1,1.1,Design,2026-03-16T09:00:00,2026-03-17T18:00:00,,Miku,100
+3,1,1.2,Implementation,2026-03-18T09:00:00,2026-03-20T18:00:00,2,Miku|Rin,0
+4,3,1.2.1,Coding,2026-03-18T09:00:00,2026-03-19T18:00:00,2,Rin,20
+`;
+
+ const model = xmlTools.importCsvParentId(csv);
+
+ expect(model.project.name).toBe("CSV Imported Project");
+ expect(model.project.title).toBe("CSV Imported Project");
+ expect(model.project.calendarUID).toBe("1");
+ expect(model.tasks).toHaveLength(4);
+ expect(model.tasks[0].summary).toBe(true);
+ expect(model.tasks[1].outlineNumber).toBe("1.1");
+ expect(model.tasks[2].predecessors[0].predecessorUid).toBe("2");
+ expect(model.tasks[3].outlineLevel).toBe(3);
+ expect(model.resources.map((item) => item.name)).toEqual(["Miku", "Rin"]);
+ expect(model.assignments).toHaveLength(4);
+ expect(model.assignments[1].resourceUid).toBe("1");
+ expect(model.assignments[2].resourceUid).toBe("2");
+ expect(model.calendars).toHaveLength(1);
+ expect(model.calendars[0].name).toBe("Standard");
+ expect(model.calendars[0].weekDays).toHaveLength(7);
+ expect(model.calendars[0].exceptions.length).toBeGreaterThan(0);
+ });
+
+ it("imports extended task fields in csv with parent id", () => {
+ const xmlTools = bootXmlModule();
+ const csv = `ID,ParentID,WBS,Name,Start,Finish,PredecessorID,Resource,PercentComplete,PercentWorkComplete,Milestone,Summary,Critical,Type,Priority,Work,CalendarUID,ConstraintType,ConstraintDate,Deadline,Notes
+1,,1,Project Summary,2026-03-16T09:00:00,2026-03-20T18:00:00,,,,,1,1,0,1,500,PT40H0M0S,1,,,,Root note
+2,1,1.1,Design,2026-03-16T09:00:00,2026-03-17T18:00:00,,Miku,100,100,0,0,0,1,600,PT16H0M0S,1,,,,Design done
+3,1,1.2,Release,2026-03-20T18:00:00,2026-03-20T18:00:00,2,Miku,100,100,1,0,1,1,700,PT0H0M0S,2,4,2026-03-20T09:00:00,2026-03-21T18:00:00,Release gate
+`;
+
+ const model = xmlTools.importCsvParentId(csv);
+
+ expect(model.tasks[0].summary).toBe(true);
+ expect(model.tasks[0].notes).toBe("Root note");
+ expect(model.tasks[1].percentWorkComplete).toBe(100);
+ expect(model.tasks[1].critical).toBe(false);
+ expect(model.tasks[1].priority).toBe(600);
+ expect(model.tasks[1].work).toBe("PT16H0M0S");
+ expect(model.tasks[2].milestone).toBe(true);
+ expect(model.tasks[2].critical).toBe(true);
+ expect(model.tasks[2].type).toBe(1);
+ expect(model.tasks[2].calendarUID).toBe("2");
+ expect(model.tasks[2].constraintType).toBe(4);
+ expect(model.tasks[2].constraintDate).toBe("2026-03-20T09:00:00");
+ expect(model.tasks[2].deadline).toBe("2026-03-21T18:00:00");
+ expect(model.tasks[2].work).toBe("PT0H0M0S");
+ expect(model.tasks[2].notes).toBe("Release gate");
+ });
+
+ it("normalizes predecessor and resource separators in csv import", () => {
+ const xmlTools = bootXmlModule();
+ const csv = `ID,ParentID,WBS,Name,Start,Finish,PredecessorID,Resource,PercentComplete
+1,,1,Project Summary,2026-03-16T09:00:00,2026-03-20T18:00:00,,,50
+2,1,1.1,Design,2026-03-16T09:00:00,2026-03-17T18:00:00,,Miku,100
+3,1,1.2,Implementation,2026-03-18T09:00:00,2026-03-20T18:00:00,"2, 4; 2","Miku; Rin、Luka| Rin",0
+4,1,1.3,Review,2026-03-20T09:00:00,2026-03-20T18:00:00,,Luka,0
+`;
+
+ const model = xmlTools.importCsvParentId(csv);
+
+ expect(model.tasks[2].predecessors.map((item) => item.predecessorUid)).toEqual(["2", "4"]);
+ expect(model.resources.map((item) => item.name)).toEqual(["Miku", "Rin", "Luka"]);
+ expect(model.assignments.filter((item) => item.taskUid === "3")).toHaveLength(3);
+ });
+
+ it("rejects duplicate id in csv import", () => {
+ const xmlTools = bootXmlModule();
+ const csv = `ID,ParentID,Name
+1,,Root
+1,,Duplicate
+`;
+
+ expect(() => xmlTools.importCsvParentId(csv)).toThrow("CSV の ID が重複しています");
+ });
+
+ it("rejects missing parent id in csv import", () => {
+ const xmlTools = bootXmlModule();
+ const csv = `ID,ParentID,Name
+1,,Root
+2,99,Child
+`;
+
+ expect(() => xmlTools.importCsvParentId(csv)).toThrow("CSV の ParentID が既存 ID を指していません");
+ });
+
+ it("rejects cyclic parent id in csv import", () => {
+ const xmlTools = bootXmlModule();
+ const csv = `ID,ParentID,Name
+1,2,Root
+2,1,Child
+`;
+
+ expect(() => xmlTools.importCsvParentId(csv)).toThrow("CSV の ParentID が循環しています");
+ });
+
+ it("allows placeholder UID=0 and unassigned ResourceUID=-65535", () => {
+ const xmlTools = bootXmlModule();
+ const xml = `
+
+ Placeholder Project
+ 2026-03-16T09:00:00
+ 2026-03-16T18:00:00
+ 1
+
+
+ 0
+ 0
+
+ 0
+
+ 2026-03-16T09:00:00
+ 2026-03-16T18:00:00
+ PT8H0M0S
+ 0
+ 0
+ 0
+
+
+
+
+ 0
+ 0
+
+ 1
+
+
+
+
+ 1
+ 0
+ -65535
+ 2026-03-16T09:00:00
+ 2026-03-16T18:00:00
+ 1
+ PT8H0M0S
+
+
+ `;
+
+ const model = xmlTools.importMsProjectXml(xml);
+ const issues = xmlTools.validateProjectModel(model);
+
+ expect(issues.some((issue) => issue.message.includes("OutlineLevel"))).toBe(false);
+ expect(issues.some((issue) => issue.message.includes("ResourceUID が既存 Resource"))).toBe(false);
+ expect(issues.some((issue) => issue.message.includes("Resource Name が空"))).toBe(false);
+ });
+});
diff --git a/tests/mikuproject-project-workbook-json.test.js b/tests/mikuproject-project-workbook-json.test.js
new file mode 100644
index 0000000..d29ebe0
--- /dev/null
+++ b/tests/mikuproject-project-workbook-json.test.js
@@ -0,0 +1,113 @@
+// @vitest-environment jsdom
+
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const typesCode = readFileSync(
+ path.resolve(__dirname, "../src/js/types.js"),
+ "utf8"
+);
+const excelIoCode = readFileSync(
+ path.resolve(__dirname, "../src/js/excel-io.js"),
+ "utf8"
+);
+const msProjectXmlCode = readFileSync(
+ path.resolve(__dirname, "../src/js/msproject-xml.js"),
+ "utf8"
+);
+const projectWorkbookSchemaCode = readFileSync(
+ path.resolve(__dirname, "../src/js/project-workbook-schema.js"),
+ "utf8"
+);
+const projectXlsxCode = readFileSync(
+ path.resolve(__dirname, "../src/js/project-xlsx.js"),
+ "utf8"
+);
+const projectWorkbookJsonCode = readFileSync(
+ path.resolve(__dirname, "../src/js/project-workbook-json.js"),
+ "utf8"
+);
+
+function bootModules() {
+ new Function(`${typesCode}\n${excelIoCode}\n${msProjectXmlCode}\n${projectWorkbookSchemaCode}\n${projectXlsxCode}\n${projectWorkbookJsonCode}`)();
+ return {
+ xml: globalThis.__mikuprojectXml,
+ projectWorkbookJson: globalThis.__mikuprojectProjectWorkbookJson
+ };
+}
+
+describe("mikuproject project workbook json", () => {
+ it("exports workbook json with fixed format and sheets", () => {
+ const { xml, projectWorkbookJson } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+
+ const documentLike = projectWorkbookJson.exportProjectWorkbookJson(model);
+
+ expect(documentLike.format).toBe("mikuproject_workbook_json");
+ expect(documentLike.version).toBe(1);
+ expect(Object.keys(documentLike.sheets)).toEqual([
+ "Project",
+ "Tasks",
+ "Resources",
+ "Assignments",
+ "Calendars",
+ "NonWorkingDays"
+ ]);
+ expect(documentLike.sheets.Project[0]).toEqual({ Field: "Name", Value: "mikuproject開発" });
+ expect(documentLike.sheets.Tasks[0].UID).toBe("1");
+ expect(documentLike.sheets.Tasks[0].Name).toBe("基盤整備");
+ });
+
+ it("imports limited editable fields through workbook json", () => {
+ const { xml, projectWorkbookJson } = bootModules();
+ const baseModel = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const documentLike = projectWorkbookJson.exportProjectWorkbookJson(baseModel);
+
+ documentLike.sheets.Project.find((row) => row.Field === "Name").Value = "JSON import project";
+ documentLike.sheets.Tasks[2].Name = "JSON import task";
+ documentLike.sheets.Tasks[2].Start = "2026-03-16 10:00:00";
+ documentLike.sheets.Tasks[2].OutlineNumber = "999";
+
+ const result = projectWorkbookJson.importProjectWorkbookJson(documentLike, baseModel);
+
+ expect(result.model.project.name).toBe("JSON import project");
+ expect(result.model.tasks[2].name).toBe("JSON import task");
+ expect(result.model.tasks[2].start).toBe("2026-03-16T10:00:00");
+ expect(result.model.tasks[2].outlineNumber).toBe(baseModel.tasks[2].outlineNumber);
+ expect(result.changes.some((change) => change.field === "Name")).toBe(true);
+ });
+
+ it("rejects invalid workbook json format", () => {
+ const { projectWorkbookJson } = bootModules();
+
+ expect(() => projectWorkbookJson.validateWorkbookJsonDocument({
+ format: "other",
+ version: 1,
+ sheets: {}
+ })).toThrow("format が mikuproject_workbook_json ではありません");
+ });
+
+ it("reports warnings for unknown sheet and unknown columns", () => {
+ const { projectWorkbookJson } = bootModules();
+
+ const result = projectWorkbookJson.validateWorkbookJsonDocument({
+ format: "mikuproject_workbook_json",
+ version: 1,
+ sheets: {
+ Project: [{ Field: "Name", Value: "x", Extra: "ignored" }],
+ UnknownSheet: []
+ }
+ });
+
+ expect(result.warnings.map((item) => item.message)).toEqual([
+ "未知の列は無視します: Project[0].Extra",
+ "未知の sheet は無視します: UnknownSheet"
+ ]);
+ });
+});
diff --git a/tests/mikuproject-project-xlsx.test.js b/tests/mikuproject-project-xlsx.test.js
new file mode 100644
index 0000000..bb8b1bb
--- /dev/null
+++ b/tests/mikuproject-project-xlsx.test.js
@@ -0,0 +1,666 @@
+// @vitest-environment jsdom
+
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const typesCode = readFileSync(
+ path.resolve(__dirname, "../src/js/types.js"),
+ "utf8"
+);
+const excelIoCode = readFileSync(
+ path.resolve(__dirname, "../src/js/excel-io.js"),
+ "utf8"
+);
+const msProjectXmlCode = readFileSync(
+ path.resolve(__dirname, "../src/js/msproject-xml.js"),
+ "utf8"
+);
+const projectWorkbookSchemaCode = readFileSync(
+ path.resolve(__dirname, "../src/js/project-workbook-schema.js"),
+ "utf8"
+);
+const projectXlsxCode = readFileSync(
+ path.resolve(__dirname, "../src/js/project-xlsx.js"),
+ "utf8"
+);
+
+function bootModules() {
+ new Function(`${typesCode}\n${excelIoCode}\n${msProjectXmlCode}\n${projectWorkbookSchemaCode}\n${projectXlsxCode}`)();
+ return {
+ excelIo: globalThis.__mikuprojectExcelIo,
+ xml: globalThis.__mikuprojectXml,
+ projectXlsx: globalThis.__mikuprojectProjectXlsx
+ };
+}
+
+function buildSampleWorkbook(mutator) {
+ const { xml, projectXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ if (mutator) {
+ mutator(workbook);
+ }
+ return { xml, projectXlsx, model, workbook };
+}
+
+const SAMPLE_HOLIDAY_COUNT = 1;
+const SAMPLE_FIRST_HOLIDAY_NAME = "春分の日";
+const SAMPLE_FIRST_HOLIDAY_DATE = "2026-03-20";
+const EDITABLE_FILL = "#FDE7C7";
+
+describe("mikuproject project xlsx", () => {
+ it("converts ProjectModel into workbook sheets", () => {
+ const { xml, projectXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+
+ expect(workbook.sheets.map((sheet) => sheet.name)).toEqual([
+ "Project",
+ "Tasks",
+ "Resources",
+ "Assignments",
+ "Calendars",
+ "NonWorkingDays"
+ ]);
+ expect(workbook.sheets[0].mergedRanges).toEqual(["A11:B11"]);
+ expect(workbook.sheets[0].rows[0].cells[0].value).toBe("Project");
+ expect(workbook.sheets[0].rows[0].cells[0].bold).toBe(true);
+ expect(workbook.sheets[0].rows[0].cells[0].fontSize).toBe(16);
+ expect(workbook.sheets[0].rows[0].cells[0].fillColor).toBe("#BFD7EA");
+ expect(workbook.sheets[0].rows[0].cells[1].fillColor).toBe("#BFD7EA");
+ expect(workbook.sheets[0].rows[1].cells[0].value).toBe("Basic Info");
+ expect(workbook.sheets[0].rows[1].cells[0].fontSize).toBe(16);
+ expect(workbook.sheets[0].rows[1].cells[1].fillColor).toBe("#BFD7EA");
+ expect(workbook.sheets[0].rows[2].cells[0].value).toBe("Field");
+ expect(workbook.sheets[0].rows[3].cells[0].value).toBe("Name");
+ expect(workbook.sheets[0].rows[3].cells[1].value).toBe("mikuproject開発");
+ expect(workbook.sheets[0].rows[3].cells[1].fillColor).toBe(EDITABLE_FILL);
+ expect(workbook.sheets[0].rows[4].cells[1].fillColor).toBe(EDITABLE_FILL);
+ expect(workbook.sheets[0].rows[5].cells[1].fillColor).toBe(EDITABLE_FILL);
+ expect(workbook.sheets[0].rows[6].cells[1].fillColor).toBe(EDITABLE_FILL);
+ expect(workbook.sheets[0].rows[7].cells[1].value).toBe("2026-03-16");
+ expect(workbook.sheets[0].rows[11].cells[0].value).toBe("Settings");
+ expect(workbook.sheets[0].rows[12].cells[1].fillColor).toBe(EDITABLE_FILL);
+ });
+
+ it("maps tasks, resources, assignments, and calendars to tabular rows", () => {
+ const { xml, projectXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ const tasksSheet = workbook.sheets.find((sheet) => sheet.name === "Tasks");
+ const resourcesSheet = workbook.sheets.find((sheet) => sheet.name === "Resources");
+ const assignmentsSheet = workbook.sheets.find((sheet) => sheet.name === "Assignments");
+ const calendarsSheet = workbook.sheets.find((sheet) => sheet.name === "Calendars");
+ const nonWorkingDaysSheet = workbook.sheets.find((sheet) => sheet.name === "NonWorkingDays");
+
+ expect(tasksSheet.mergedRanges).toEqual([]);
+ expect(tasksSheet.rows[0].cells[0].value).toBe("Tasks");
+ expect(tasksSheet.rows[0].cells[0].fontSize).toBe(14);
+ expect(tasksSheet.rows[0].cells[0].fillColor).toBe("#D4E0EC");
+ expect(tasksSheet.rows[1].cells[0].value).toBe("Task List");
+ expect(tasksSheet.rows[1].cells[0].fontSize).toBe(14);
+ expect(tasksSheet.rows[2].cells.map((cell) => cell.value)).toEqual([
+ "UID",
+ "ID",
+ "Name",
+ "OutlineLevel",
+ "OutlineNumber",
+ "WBS",
+ "Start",
+ "Finish",
+ "Duration",
+ "PercentComplete",
+ "PercentWorkComplete",
+ "Milestone",
+ "Summary",
+ "Critical",
+ "CalendarUID",
+ "Predecessors",
+ "Notes"
+ ]);
+ expect(tasksSheet.rows[3].cells[2].value).toBe("基盤整備");
+ expect(tasksSheet.rows[3].cells[6].value).toBe("2026-03-16 09:00:00");
+ expect(tasksSheet.rows[3].cells[2].bold).toBe(true);
+ expect(tasksSheet.rows[3].cells[2].fillColor).toBe(EDITABLE_FILL);
+ expect(tasksSheet.rows[3].cells[12].fillColor).toBe("#E6F2E0");
+ expect(tasksSheet.rows[5].cells[2].value).toBe("初期実装(MS Project XML 調査・基軸フォーマット選定・内部モデルの概要確定)");
+ expect(tasksSheet.rows[5].cells[8].fillColor).toBe("#FBF6ED");
+ expect(tasksSheet.rows[5].cells[13].fillColor).toBeUndefined();
+ expect(tasksSheet.rows[5].cells[15].value).toBe("");
+ expect(tasksSheet.rows[5].cells[15].fillColor).toBe("#EEF7F4");
+ expect(tasksSheet.rows[5].cells[16].value).toBeUndefined();
+ expect(tasksSheet.rows[5].cells[16].fillColor).toBe(EDITABLE_FILL);
+ expect(tasksSheet.rows[5].cells[16].border).toBe("thin");
+
+ expect(resourcesSheet.mergedRanges).toEqual([]);
+ expect(resourcesSheet.rows[0].cells[0].value).toBe("Resources");
+ expect(resourcesSheet.rows[0].cells[0].fontSize).toBe(14);
+ expect(resourcesSheet.rows[0].cells[0].fillColor).toBe("#C8E3D8");
+ expect(resourcesSheet.rows[1].cells[0].value).toBe("Resource List");
+ expect(resourcesSheet.rows[2].cells.map((cell) => cell.value)).toEqual([
+ "UID",
+ "ID",
+ "Name",
+ "Type",
+ "Initials",
+ "Group",
+ "MaxUnits",
+ "CalendarUID",
+ "StandardRate",
+ "OvertimeRate",
+ "CostPerUse",
+ "Work",
+ "ActualWork",
+ "RemainingWork"
+ ]);
+ expect(resourcesSheet.rows).toHaveLength(4);
+ expect(resourcesSheet.rows[3].cells[0].value).toBeUndefined();
+ expect(resourcesSheet.rows[3].cells[2].fillColor).toBe(EDITABLE_FILL);
+ expect(resourcesSheet.rows[3].cells[5].fillColor).toBe(EDITABLE_FILL);
+ expect(resourcesSheet.rows[3].cells[6].fillColor).toBe(EDITABLE_FILL);
+ expect(resourcesSheet.rows[3].cells[2].border).toBe("thin");
+
+ expect(assignmentsSheet.mergedRanges).toEqual([]);
+ expect(assignmentsSheet.rows[0].cells[0].value).toBe("Assignments");
+ expect(assignmentsSheet.rows[0].cells[0].fontSize).toBe(14);
+ expect(assignmentsSheet.rows[0].cells[0].fillColor).toBe("#D7D2EC");
+ expect(assignmentsSheet.rows[1].cells[0].value).toBe("Assignment List");
+ expect(assignmentsSheet.rows[2].cells.map((cell) => cell.value)).toEqual([
+ "UID",
+ "TaskUID",
+ "TaskName",
+ "ResourceUID",
+ "ResourceName",
+ "Start",
+ "Finish",
+ "Units",
+ "Work",
+ "ActualWork",
+ "RemainingWork",
+ "PercentWorkComplete"
+ ]);
+ expect(assignmentsSheet.rows).toHaveLength(4);
+ expect(assignmentsSheet.rows[3].cells[0].value).toBeUndefined();
+ expect(assignmentsSheet.rows[3].cells[7].fillColor).toBe(EDITABLE_FILL);
+ expect(assignmentsSheet.rows[3].cells[8].fillColor).toBe(EDITABLE_FILL);
+ expect(assignmentsSheet.rows[3].cells[11].fillColor).toBe(EDITABLE_FILL);
+ expect(assignmentsSheet.rows[3].cells[7].border).toBe("thin");
+
+ expect(calendarsSheet.mergedRanges).toEqual([]);
+ expect(calendarsSheet.rows[0].cells[0].value).toBe("Calendars");
+ expect(calendarsSheet.rows[0].cells[0].fontSize).toBe(14);
+ expect(calendarsSheet.rows[0].cells[0].fillColor).toBe("#D7E3C4");
+ expect(calendarsSheet.rows[1].cells[0].value).toBe("Calendar List");
+ expect(calendarsSheet.rows[2].cells.map((cell) => cell.value)).toEqual([
+ "UID",
+ "Name",
+ "IsBaseCalendar",
+ "BaseCalendarUID",
+ "WeekDays",
+ "Exceptions",
+ "WorkWeeks"
+ ]);
+ expect(calendarsSheet.rows[3].cells[1].value).toBe("Standard");
+ expect(calendarsSheet.rows[3].cells[1].fillColor).toBe(EDITABLE_FILL);
+ expect(calendarsSheet.rows[3].cells[2].fillColor).toBe(EDITABLE_FILL);
+
+ expect(nonWorkingDaysSheet.mergedRanges).toEqual([]);
+ expect(nonWorkingDaysSheet.rows[0].cells[0].value).toBe("NonWorkingDays");
+ expect(nonWorkingDaysSheet.rows[0].cells[0].fontSize).toBe(14);
+ expect(nonWorkingDaysSheet.rows[0].cells[0].fillColor).toBe("#E9C7D5");
+ expect(nonWorkingDaysSheet.rows[1].cells[0].value).toBe("Calendar Exceptions");
+ expect(nonWorkingDaysSheet.rows[2].cells.map((cell) => cell.value)).toEqual([
+ "CalendarUID",
+ "Index",
+ "CalendarName",
+ "Name",
+ "Date",
+ "FromDate",
+ "ToDate",
+ "DayWorking"
+ ]);
+ expect(nonWorkingDaysSheet.rows[3].cells[0].value).toBe("1");
+ expect(nonWorkingDaysSheet.rows[3].cells[3].value).toBe(SAMPLE_FIRST_HOLIDAY_NAME);
+ expect(nonWorkingDaysSheet.rows[3].cells[3].fillColor).toBe(EDITABLE_FILL);
+ expect(nonWorkingDaysSheet.rows[3].cells[4].value).toBe(SAMPLE_FIRST_HOLIDAY_DATE);
+ expect(nonWorkingDaysSheet.rows[3].cells[5].value).toBe(SAMPLE_FIRST_HOLIDAY_DATE);
+ expect(nonWorkingDaysSheet.rows[3].cells[6].value).toBe(SAMPLE_FIRST_HOLIDAY_DATE);
+ expect(nonWorkingDaysSheet.rows[3].cells[7].value).toBe("false");
+ expect(nonWorkingDaysSheet.rows[3].cells[4].fillColor).toBe(EDITABLE_FILL);
+ expect(nonWorkingDaysSheet.rows[3].cells[5].fillColor).toBe(EDITABLE_FILL);
+ expect(nonWorkingDaysSheet.rows[3].cells[6].fillColor).toBe(EDITABLE_FILL);
+ expect(nonWorkingDaysSheet.rows[3].cells[7].fillColor).toBe(EDITABLE_FILL);
+ expect(nonWorkingDaysSheet.rows).toHaveLength(SAMPLE_HOLIDAY_COUNT + 3);
+ });
+
+ it("can generate a real xlsx from ProjectModel through the workbook adapter", () => {
+ const { excelIo, xml, projectXlsx } = bootModules();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ const bytes = codec.exportWorkbook(workbook);
+ const entries = codec.listEntries(bytes);
+ const projectSheetXml = new TextDecoder().decode(codec.unpackEntries(bytes)["xl/worksheets/sheet1.xml"]);
+
+ expect(entries).toContain("xl/workbook.xml");
+ expect(entries).toContain("xl/worksheets/sheet1.xml");
+ expect(entries).toContain("xl/styles.xml");
+ expect(projectSheetXml).not.toContain('ref="A1:B1"');
+ expect(projectSheetXml).toContain('ref="A11:B11"');
+ expect(projectSheetXml).toContain('t="inlineStr">1 ');
+ expect(projectSheetXml).toContain('t="inlineStr">true ');
+ });
+
+ it("imports limited task fields from workbook rows back into ProjectModel", () => {
+ const { xml, projectXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ const tasksSheet = workbook.sheets.find((sheet) => sheet.name === "Tasks");
+
+ tasksSheet.rows[5].cells[2].value = "初期実装 Updated";
+ tasksSheet.rows[5].cells[6].value = "2026-03-17";
+ tasksSheet.rows[5].cells[7].value = "2026-03-18";
+ tasksSheet.rows[5].cells[9].value = 80;
+ tasksSheet.rows[5].cells[10].value = 90;
+ tasksSheet.rows[5].cells[16].value = "Updated Notes";
+
+ const importedModel = projectXlsx.importProjectWorkbook(workbook, model);
+ const designTask = importedModel.tasks.find((task) => task.uid === "3");
+ const implementationTask = importedModel.tasks.find((task) => task.uid === "4");
+
+ expect(designTask.name).toBe("初期実装 Updated");
+ expect(designTask.start).toBe("2026-03-17");
+ expect(designTask.finish).toBe("2026-03-18");
+ expect(designTask.percentComplete).toBe(80);
+ expect(designTask.percentWorkComplete).toBe(90);
+ expect(designTask.notes).toBe("Updated Notes");
+ expect(implementationTask.name).toBe("round-trip拡張(MS Project XML → 内部JSON形式 → MS Project XML の往復対応)");
+ });
+
+ it("imports limited resource and assignment fields from workbook rows back into ProjectModel", () => {
+ const { xml, projectXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ const importedModel = projectXlsx.importProjectWorkbook(workbook, model);
+
+ expect(importedModel.resources).toEqual([]);
+ expect(importedModel.assignments).toEqual([]);
+ });
+
+ it("imports limited project fields from workbook rows back into ProjectModel", () => {
+ const { xml, projectXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ const projectSheet = workbook.sheets.find((sheet) => sheet.name === "Project");
+
+ projectSheet.rows[3].cells[1].value = "Renamed Project";
+ projectSheet.rows[7].cells[1].value = "2026-03-15 09:00:00";
+ projectSheet.rows[12].cells[1].value = "2";
+ projectSheet.rows[13].cells[1].value = 420;
+ projectSheet.rows[16].cells[1].value = false;
+
+ const importedModel = projectXlsx.importProjectWorkbook(workbook, model);
+
+ expect(importedModel.project.name).toBe("Renamed Project");
+ expect(importedModel.project.startDate).toBe("2026-03-15T09:00:00");
+ expect(importedModel.project.calendarUID).toBe("2");
+ expect(importedModel.project.minutesPerDay).toBe(420);
+ expect(importedModel.project.scheduleFromStart).toBe(false);
+ });
+
+ it("imports project calendar and schedule mode fields from workbook rows back into ProjectModel", () => {
+ const { xml, projectXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ const projectSheet = workbook.sheets.find((sheet) => sheet.name === "Project");
+
+ projectSheet.rows[12].cells[1].value = "2";
+ projectSheet.rows[16].cells[1].value = false;
+
+ const importedModel = projectXlsx.importProjectWorkbook(workbook, model);
+
+ expect(importedModel.project.calendarUID).toBe("2");
+ expect(importedModel.project.scheduleFromStart).toBe(false);
+ });
+
+ it("imports project metadata and date fields from workbook rows back into ProjectModel", () => {
+ const { xml, projectXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ const projectSheet = workbook.sheets.find((sheet) => sheet.name === "Project");
+
+ projectSheet.rows[4].cells[1].value = "Updated Title";
+ projectSheet.rows[6].cells[1].value = "Updated Company";
+ projectSheet.rows[7].cells[1].value = "2026-03-15T09:00:00";
+ projectSheet.rows[8].cells[1].value = "2026-03-28T18:00:00";
+
+ const importedModel = projectXlsx.importProjectWorkbook(workbook, model);
+
+ expect(importedModel.project.title).toBe("Updated Title");
+ expect(importedModel.project.company).toBe("Updated Company");
+ expect(importedModel.project.startDate).toBe("2026-03-15T09:00:00");
+ expect(importedModel.project.finishDate).toBe("2026-03-28T18:00:00");
+ });
+
+ it("imports project author field from workbook rows back into ProjectModel", () => {
+ const { xml, projectXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ const projectSheet = workbook.sheets.find((sheet) => sheet.name === "Project");
+
+ projectSheet.rows[5].cells[1].value = "Author From XLSX";
+
+ const importedModel = projectXlsx.importProjectWorkbook(workbook, model);
+
+ expect(importedModel.project.author).toBe("Author From XLSX");
+ });
+
+ it("imports project current and status dates plus weekly settings from workbook rows back into ProjectModel", () => {
+ const { xml, projectXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ const projectSheet = workbook.sheets.find((sheet) => sheet.name === "Project");
+
+ projectSheet.rows[9].cells[1].value = "2026-03-18T09:00:00";
+ projectSheet.rows[10].cells[1].value = "2026-03-22T09:00:00";
+ projectSheet.rows[14].cells[1].value = 2100;
+ projectSheet.rows[15].cells[1].value = 18;
+
+ const importedModel = projectXlsx.importProjectWorkbook(workbook, model);
+
+ expect(importedModel.project.currentDate).toBe("2026-03-18T09:00:00");
+ expect(importedModel.project.statusDate).toBe("2026-03-22T09:00:00");
+ expect(importedModel.project.minutesPerWeek).toBe(2100);
+ expect(importedModel.project.daysPerMonth).toBe(18);
+ });
+
+ it("reports field-level import changes", () => {
+ const { xml, projectXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ const tasksSheet = workbook.sheets.find((sheet) => sheet.name === "Tasks");
+
+ tasksSheet.rows[5].cells[2].value = "初期実装 Updated";
+ tasksSheet.rows[5].cells[9].value = 80;
+ tasksSheet.rows[5].cells[16].value = "Updated Notes";
+
+ const result = projectXlsx.importProjectWorkbookDetailed(workbook, model);
+
+ expect(result.changes).toEqual([
+ { scope: "tasks", uid: "3", label: "初期実装(MS Project XML 調査・基軸フォーマット選定・内部モデルの概要確定)", field: "Name", before: "初期実装(MS Project XML 調査・基軸フォーマット選定・内部モデルの概要確定)", after: "初期実装 Updated" },
+ { scope: "tasks", uid: "3", label: "初期実装(MS Project XML 調査・基軸フォーマット選定・内部モデルの概要確定)", field: "PercentComplete", before: 100, after: 80 },
+ { scope: "tasks", uid: "3", label: "初期実装(MS Project XML 調査・基軸フォーマット選定・内部モデルの概要確定)", field: "Notes", before: undefined, after: "Updated Notes" }
+ ]);
+ });
+
+ it("reports project-level import changes", () => {
+ const { xml, projectXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ const projectSheet = workbook.sheets.find((sheet) => sheet.name === "Project");
+
+ projectSheet.rows[3].cells[1].value = "Renamed Project";
+ projectSheet.rows[13].cells[1].value = 420;
+
+ const result = projectXlsx.importProjectWorkbookDetailed(workbook, model);
+
+ expect(result.changes).toEqual([
+ { scope: "project", uid: "project", label: "mikuproject開発", field: "Name", before: "mikuproject開発", after: "Renamed Project" },
+ { scope: "project", uid: "project", label: "mikuproject開発", field: "MinutesPerDay", before: undefined, after: 420 }
+ ]);
+ });
+
+ it("imports limited calendar fields from workbook rows back into ProjectModel", () => {
+ const { xml, projectXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ const calendarsSheet = workbook.sheets.find((sheet) => sheet.name === "Calendars");
+
+ calendarsSheet.rows[3].cells[1].value = "Standard Updated";
+ calendarsSheet.rows[3].cells[3].value = "2";
+
+ const importedModel = projectXlsx.importProjectWorkbook(workbook, model);
+
+ expect(importedModel.calendars.find((calendar) => calendar.uid === "1").name).toBe("Standard Updated");
+ expect(importedModel.calendars.find((calendar) => calendar.uid === "1").baseCalendarUID).toBe("2");
+ });
+
+ it("reports calendar-level import changes", () => {
+ const { xml, projectXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ const calendarsSheet = workbook.sheets.find((sheet) => sheet.name === "Calendars");
+
+ calendarsSheet.rows[3].cells[1].value = "Standard Updated";
+ calendarsSheet.rows[3].cells[3].value = "2";
+
+ const result = projectXlsx.importProjectWorkbookDetailed(workbook, model);
+
+ expect(result.changes).toEqual([
+ { scope: "calendars", uid: "1", label: "Standard", field: "Name", before: "Standard", after: "Standard Updated" },
+ { scope: "calendars", uid: "1", label: "Standard", field: "BaseCalendarUID", before: undefined, after: "2" }
+ ]);
+ });
+
+ it("imports calendar isBaseCalendar field from workbook rows back into ProjectModel", () => {
+ const { xml, projectXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ const calendarsSheet = workbook.sheets.find((sheet) => sheet.name === "Calendars");
+
+ calendarsSheet.rows[3].cells[2].value = false;
+
+ const importedModel = projectXlsx.importProjectWorkbook(workbook, model);
+
+ expect(importedModel.calendars.find((calendar) => calendar.uid === "1").isBaseCalendar).toBe(false);
+ });
+
+ it("ignores calendar WeekDays, Exceptions, and WorkWeeks workbook edits", () => {
+ const { xml, projectXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ const calendarsSheet = workbook.sheets.find((sheet) => sheet.name === "Calendars");
+
+ calendarsSheet.rows[3].cells[4].value = 77;
+ calendarsSheet.rows[3].cells[5].value = 88;
+ calendarsSheet.rows[3].cells[6].value = 99;
+
+ const result = projectXlsx.importProjectWorkbookDetailed(workbook, model);
+
+ expect(result.model.calendars.find((calendar) => calendar.uid === "1").weekDays).toHaveLength(7);
+ expect(result.model.calendars.find((calendar) => calendar.uid === "1").exceptions).toHaveLength(SAMPLE_HOLIDAY_COUNT);
+ expect(result.model.calendars.find((calendar) => calendar.uid === "1").workWeeks).toHaveLength(0);
+ expect(result.changes).toEqual([]);
+ });
+
+ it("imports non-working day sheet fields from workbook rows back into ProjectModel", () => {
+ const { xml, projectXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ const nonWorkingDaysSheet = workbook.sheets.find((sheet) => sheet.name === "NonWorkingDays");
+
+ nonWorkingDaysSheet.rows[3].cells[3].value = "Spring Holiday";
+ nonWorkingDaysSheet.rows[3].cells[4].value = "2026-03-21";
+ nonWorkingDaysSheet.rows[3].cells[7].value = false;
+
+ const importedModel = projectXlsx.importProjectWorkbook(workbook, model);
+ const exception = importedModel.calendars.find((calendar) => calendar.uid === "1").exceptions[0];
+
+ expect(exception.name).toBe("Spring Holiday");
+ expect(exception.fromDate).toBe("2026-03-21T00:00:00");
+ expect(exception.toDate).toBe("2026-03-21T23:59:59");
+ expect(exception.dayWorking).toBe(false);
+ });
+
+ it("reports non-working day import changes", () => {
+ const { xml, projectXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+ const nonWorkingDaysSheet = workbook.sheets.find((sheet) => sheet.name === "NonWorkingDays");
+
+ nonWorkingDaysSheet.rows[3].cells[3].value = "Spring Holiday";
+ nonWorkingDaysSheet.rows[3].cells[4].value = "2026-03-21";
+
+ const result = projectXlsx.importProjectWorkbookDetailed(workbook, model);
+
+ expect(result.changes).toEqual([
+ { scope: "calendars", uid: "1", label: "Standard", field: "Exception1.Name", before: SAMPLE_FIRST_HOLIDAY_NAME, after: "Spring Holiday" },
+ { scope: "calendars", uid: "1", label: "Standard", field: "Exception1.FromDate", before: `${SAMPLE_FIRST_HOLIDAY_DATE}T00:00:00`, after: "2026-03-21T00:00:00" },
+ { scope: "calendars", uid: "1", label: "Standard", field: "Exception1.ToDate", before: `${SAMPLE_FIRST_HOLIDAY_DATE}T23:59:59`, after: "2026-03-21T23:59:59" }
+ ]);
+ });
+
+ it("reports assignment percent work complete import changes", () => {
+ const { projectXlsx, model, workbook } = buildSampleWorkbook((nextWorkbook) => {
+ const assignmentsSheet = nextWorkbook.sheets.find((sheet) => sheet.name === "Assignments");
+ expect(assignmentsSheet.rows).toHaveLength(4);
+ });
+
+ const result = projectXlsx.importProjectWorkbookDetailed(workbook, model);
+
+ expect(result.changes).toEqual([]);
+ });
+
+ it("can validate imported task percent complete out of range without UI boot", () => {
+ const { xml, projectXlsx, model, workbook } = buildSampleWorkbook((nextWorkbook) => {
+ const tasksSheet = nextWorkbook.sheets.find((sheet) => sheet.name === "Tasks");
+ tasksSheet.rows[5].cells[9].value = 120;
+ });
+
+ const importedModel = projectXlsx.importProjectWorkbook(workbook, model);
+ const issues = xml.validateProjectModel(importedModel);
+
+ expect(importedModel.tasks.find((task) => task.uid === "3").percentComplete).toBe(120);
+ expect(issues).toHaveLength(1);
+ expect(issues[0].level).toBe("warning");
+ expect(issues[0].message).toContain("PercentComplete");
+ expect(issues[0].message).toContain("0..100");
+ });
+
+ it("can validate imported task start later than finish without UI boot", () => {
+ const { xml, projectXlsx, model, workbook } = buildSampleWorkbook((nextWorkbook) => {
+ const tasksSheet = nextWorkbook.sheets.find((sheet) => sheet.name === "Tasks");
+ tasksSheet.rows[5].cells[6].value = "2026-03-19";
+ tasksSheet.rows[5].cells[7].value = "2026-03-18";
+ });
+
+ const importedModel = projectXlsx.importProjectWorkbook(workbook, model);
+ const issues = xml.validateProjectModel(importedModel);
+
+ expect(importedModel.tasks.find((task) => task.uid === "3").start).toBe("2026-03-19");
+ expect(importedModel.tasks.find((task) => task.uid === "3").finish).toBe("2026-03-18");
+ expect(issues).toHaveLength(1);
+ expect(issues[0].level).toBe("warning");
+ expect(issues[0].message).toContain("Start");
+ expect(issues[0].message).toContain("Finish");
+ });
+
+ it("can validate imported missing calendar baseCalendarUID without UI boot", () => {
+ const { xml, projectXlsx, model, workbook } = buildSampleWorkbook((nextWorkbook) => {
+ const calendarsSheet = nextWorkbook.sheets.find((sheet) => sheet.name === "Calendars");
+ calendarsSheet.rows[3].cells[3].value = "99";
+ });
+
+ const importedModel = projectXlsx.importProjectWorkbook(workbook, model);
+ const issues = xml.validateProjectModel(importedModel);
+
+ expect(importedModel.calendars.find((calendar) => calendar.uid === "1").baseCalendarUID).toBe("99");
+ expect(issues).toHaveLength(1);
+ expect(issues[0].level).toBe("warning");
+ expect(issues[0].message).toContain("BaseCalendarUID");
+ expect(issues[0].message).toContain("指していません");
+ });
+
+ it("can validate imported self-referencing calendar baseCalendarUID without UI boot", () => {
+ const { xml, projectXlsx, model, workbook } = buildSampleWorkbook((nextWorkbook) => {
+ const calendarsSheet = nextWorkbook.sheets.find((sheet) => sheet.name === "Calendars");
+ calendarsSheet.rows[3].cells[3].value = "1";
+ });
+
+ const importedModel = projectXlsx.importProjectWorkbook(workbook, model);
+ const issues = xml.validateProjectModel(importedModel);
+
+ expect(importedModel.calendars.find((calendar) => calendar.uid === "1").baseCalendarUID).toBe("1");
+ expect(issues).toHaveLength(1);
+ expect(issues[0].level).toBe("warning");
+ expect(issues[0].message).toContain("BaseCalendarUID");
+ expect(issues[0].message).toContain("自身を指しています");
+ });
+
+ it("can export xml after imported xlsx edits without UI boot", () => {
+ const { xml, projectXlsx, model, workbook } = buildSampleWorkbook((nextWorkbook) => {
+ const tasksSheet = nextWorkbook.sheets.find((sheet) => sheet.name === "Tasks");
+ tasksSheet.rows[5].cells[2].value = "初期実装 Saved From XLSX";
+ });
+
+ const importedModel = projectXlsx.importProjectWorkbook(workbook, model);
+ const exportedXml = xml.exportMsProjectXml(importedModel);
+
+ expect(exportedXml).toContain("初期実装 Saved From XLSX ");
+ });
+
+ it("can export xml after imported invalid xlsx edits without UI boot", () => {
+ const { xml, projectXlsx, model, workbook } = buildSampleWorkbook((nextWorkbook) => {
+ const tasksSheet = nextWorkbook.sheets.find((sheet) => sheet.name === "Tasks");
+ tasksSheet.rows[5].cells[6].value = "2026-03-19";
+ tasksSheet.rows[5].cells[7].value = "2026-03-18";
+ });
+
+ const importedModel = projectXlsx.importProjectWorkbook(workbook, model);
+ const issues = xml.validateProjectModel(importedModel);
+ const exportedXml = xml.exportMsProjectXml(importedModel);
+
+ expect(issues).toHaveLength(1);
+ expect(issues[0].level).toBe("warning");
+ expect(issues[0].message).toContain("Start");
+ expect(issues[0].message).toContain("Finish");
+ expect(exportedXml).toContain("2026-03-19 ");
+ expect(exportedXml).toContain("2026-03-18 ");
+ });
+
+ it("round-trips editable fields through workbook and xlsx bytes", () => {
+ const { excelIo, xml, projectXlsx } = bootModules();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const workbook = projectXlsx.exportProjectWorkbook(model);
+
+ const tasksSheet = workbook.sheets.find((sheet) => sheet.name === "Tasks");
+ const resourcesSheet = workbook.sheets.find((sheet) => sheet.name === "Resources");
+ const assignmentsSheet = workbook.sheets.find((sheet) => sheet.name === "Assignments");
+ const calendarsSheet = workbook.sheets.find((sheet) => sheet.name === "Calendars");
+ const nonWorkingDaysSheet = workbook.sheets.find((sheet) => sheet.name === "NonWorkingDays");
+
+ tasksSheet.rows[5].cells[2].value = "初期実装 via XLSX";
+ tasksSheet.rows[5].cells[9].value = 60;
+ tasksSheet.rows[5].cells[16].value = "初期実装 notes via XLSX";
+ calendarsSheet.rows[3].cells[1].value = "Standard via XLSX";
+ calendarsSheet.rows[3].cells[2].value = true;
+ calendarsSheet.rows[3].cells[3].value = "1";
+ nonWorkingDaysSheet.rows[3].cells[3].value = "Holiday via XLSX";
+ nonWorkingDaysSheet.rows[3].cells[4].value = "2026-03-22";
+
+ const bytes = codec.exportWorkbook(workbook);
+ const importedWorkbook = codec.importWorkbook(bytes);
+ const importedModel = projectXlsx.importProjectWorkbook(importedWorkbook, model);
+
+ expect(importedModel.tasks.find((task) => task.uid === "3").name).toBe("初期実装 via XLSX");
+ expect(importedModel.tasks.find((task) => task.uid === "3").percentComplete).toBe(60);
+ expect(importedModel.tasks.find((task) => task.uid === "3").notes).toBe("初期実装 notes via XLSX");
+ expect(importedModel.resources).toEqual([]);
+ expect(importedModel.assignments).toEqual([]);
+ expect(importedModel.calendars.find((calendar) => calendar.uid === "1").name).toBe("Standard via XLSX");
+ expect(importedModel.calendars.find((calendar) => calendar.uid === "1").isBaseCalendar).toBe(true);
+ expect(importedModel.calendars.find((calendar) => calendar.uid === "1").baseCalendarUID).toBe("1");
+ expect(importedModel.calendars.find((calendar) => calendar.uid === "1").exceptions[0].name).toBe("Holiday via XLSX");
+ expect(importedModel.calendars.find((calendar) => calendar.uid === "1").exceptions[0].fromDate).toBe("2026-03-22T00:00:00");
+ });
+});
diff --git a/tests/mikuproject-single-html.test.js b/tests/mikuproject-single-html.test.js
new file mode 100644
index 0000000..87b2a36
--- /dev/null
+++ b/tests/mikuproject-single-html.test.js
@@ -0,0 +1,24 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+import { buildSingleHtmlFromSource } from "../scripts/lib/single-html.mjs";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const ROOT = path.resolve(__dirname, "..");
+
+describe("mikuproject single html build", () => {
+ it("inlines local app assets and does not reference removed mermaid runtime", () => {
+ const srcHtmlPath = path.resolve(ROOT, "mikuproject-src.html");
+ const sourceHtml = readFileSync(srcHtmlPath, "utf8");
+ const builtHtml = buildSingleHtmlFromSource(sourceHtml, srcHtmlPath);
+
+ expect(builtHtml).not.toContain('src="src/js/main.js"');
+ expect(builtHtml).not.toContain("src/vendor/mermaid/");
+ expect(builtHtml).not.toContain("mermaid.min.js");
+ expect(builtHtml).toContain("function initialize()");
+ });
+});
diff --git a/tests/mikuproject-wbs-markdown.test.js b/tests/mikuproject-wbs-markdown.test.js
new file mode 100644
index 0000000..28f80dc
--- /dev/null
+++ b/tests/mikuproject-wbs-markdown.test.js
@@ -0,0 +1,196 @@
+// @vitest-environment jsdom
+
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const typesCode = readFileSync(
+ path.resolve(__dirname, "../src/js/types.js"),
+ "utf8"
+);
+const markdownEscapeCode = readFileSync(
+ path.resolve(__dirname, "../src/js/markdown-escape.js"),
+ "utf8"
+);
+const msProjectXmlCode = readFileSync(
+ path.resolve(__dirname, "../src/js/msproject-xml.js"),
+ "utf8"
+);
+const wbsMarkdownCode = readFileSync(
+ path.resolve(__dirname, "../src/js/wbs-markdown.js"),
+ "utf8"
+);
+
+function bootModules() {
+ new Function(`${typesCode}\n${markdownEscapeCode}\n${msProjectXmlCode}\n${wbsMarkdownCode}`)();
+ return {
+ xml: globalThis.__mikuprojectXml,
+ wbsMarkdown: globalThis.__mikuprojectWbsMarkdown
+ };
+}
+
+function createDerivedTask(baseTask, overrides) {
+ return {
+ ...baseTask,
+ ...overrides,
+ milestone: false,
+ summary: false,
+ critical: false,
+ percentWorkComplete: overrides.percentComplete,
+ predecessors: [],
+ extendedAttributes: [],
+ baselines: [],
+ timephasedData: []
+ };
+}
+
+describe("mikuproject wbs markdown", () => {
+ it("exports one markdown document with tree first and table after it", () => {
+ const { xml, wbsMarkdown } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+
+ const markdown = wbsMarkdown.exportWbsMarkdown(model, {
+ useBusinessDaysForDisplayRange: true,
+ useBusinessDaysForProgressBand: true
+ });
+
+ expect(markdown).toContain("# プロジェクト情報");
+ expect(markdown).toContain("# WBS ツリー");
+ expect(markdown).toContain("# WBS テーブル");
+ expect(markdown).toContain("# サマリ");
+ expect(markdown.indexOf("# WBS ツリー")).toBeLessThan(markdown.indexOf("# WBS テーブル"));
+ expect(markdown.indexOf("# WBS テーブル")).toBeLessThan(markdown.indexOf("# サマリ"));
+ expect(markdown).toContain("| プロジェクト名 | mikuproject開発 |");
+ expect(markdown).toContain("~~~text");
+ expect(markdown).toContain("1 基盤整備 (3/16 - 3/17): 100%");
+ expect(markdown).toContain("┗ 1.1 着手 (3/16): 100%");
+ expect(markdown).toContain("┗ 1.1");
+ expect(markdown).toContain("| 1 | フェーズ | 1 | 基盤整備 | 2026-03-16 | 2026-03-17 |");
+ expect(markdown).toContain("| WBS | 種別 | 階層 | 名称 | 開始 | 終了 | 期間 | タスク詳細 | 進捗 | 担当 | リソース | 先行 |");
+ });
+
+ it("shows notes in the tree section and summary after the table", () => {
+ const { xml, wbsMarkdown } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ model.tasks[1].notes = "補足A\n補足B";
+
+ const markdown = wbsMarkdown.exportWbsMarkdown(model, {
+ displayDaysBeforeBaseDate: 1,
+ displayDaysAfterBaseDate: 2,
+ useBusinessDaysForDisplayRange: true,
+ useBusinessDaysForProgressBand: true
+ });
+
+ expect(markdown).toContain("詳細: 補足A");
+ expect(markdown).toContain(" 補足B");
+ expect(markdown).toContain("| 前日数 | 1 |");
+ expect(markdown).toContain("| 後日数 | 2 |");
+ expect(markdown).toContain("| 表示 | 営業日 |");
+ expect(markdown).toContain("| 進捗 | 営業日 |");
+ });
+
+ it("keeps long names, deep hierarchy, and notes readable in the sample-oriented markdown", () => {
+ const { xml, wbsMarkdown } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const longNameTarget = model.tasks.find((task) => task.outlineNumber === "1.2");
+ if (!longNameTarget) {
+ throw new Error("Expected task 1.2 in sample model");
+ }
+ longNameTarget.name = "初期実装(MS Project XML 調査・基軸フォーマット選定・内部モデルの概要確定)";
+ longNameTarget.notes = "前提と制約を整理する\n関係者レビューを実施する";
+ const insertIndex = model.tasks.findIndex((task) => task.outlineNumber === "1.2");
+ model.tasks.splice(
+ insertIndex + 1,
+ 0,
+ createDerivedTask(longNameTarget, {
+ uid: "901",
+ id: "901",
+ name: "内部 JSON 形式への写像方針を確認する",
+ outlineLevel: 3,
+ outlineNumber: "1.2.1",
+ wbs: "1.2.1",
+ start: "2026-03-17T09:00:00",
+ finish: "2026-03-18T18:00:00",
+ duration: "PT16H0M0S",
+ percentComplete: 40,
+ notes: "説明責務と round-trip 観点を切り分ける"
+ }),
+ createDerivedTask(longNameTarget, {
+ uid: "902",
+ id: "902",
+ name: "フィールド差分の洗い出し結果を整理する",
+ outlineLevel: 4,
+ outlineNumber: "1.2.1.1",
+ wbs: "1.2.1.1",
+ start: "2026-03-18T09:00:00",
+ finish: "2026-03-18T18:00:00",
+ duration: "PT8H0M0S",
+ percentComplete: 10,
+ notes: "XML と内部モデルの差分を箇条書きで残す"
+ }),
+ createDerivedTask(longNameTarget, {
+ uid: "903",
+ id: "903",
+ name: "長い説明文の折り返しを確認するための task",
+ outlineLevel: 5,
+ outlineNumber: "1.2.1.1.1",
+ wbs: "1.2.1.1.1",
+ start: "2026-03-18T09:00:00",
+ finish: "2026-03-19T18:00:00",
+ duration: "PT16H0M0S",
+ percentComplete: 0,
+ notes: "かなり長い補足説明をここへ入れて、Markdown tree の見え方と table の見え方を同時に確認する"
+ })
+ );
+
+ const markdown = wbsMarkdown.exportWbsMarkdown(model, {
+ useBusinessDaysForDisplayRange: true,
+ useBusinessDaysForProgressBand: true
+ });
+
+ expect(markdown).toContain("初期実装(MS Project XML 調査・基軸フォーマット選定・内部モデルの概要確定)");
+ expect(markdown).toContain(" ┗ 1.2.1");
+ expect(markdown).toContain(" ┗ 1.2.1.1");
+ expect(markdown).toContain(" ┗ 1.2.1.1.1");
+ expect(markdown).toContain("詳細: 前提と制約を整理する");
+ expect(markdown).toContain(" 関係者レビューを実施する");
+ expect(markdown).toContain("詳細: かなり長い補足説明をここへ入れて、Markdown tree の見え方と table の見え方を同時に確認する");
+ });
+
+ it("preserves multiline notes in tree and escapes markdown-sensitive text in table cells", () => {
+ const { xml, wbsMarkdown } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ model.tasks[1].notes = "# 見出し風\n- 箇条書き風\n1. 番号付き風\nA | B & text";
+
+ const markdown = wbsMarkdown.exportWbsMarkdown(model, {
+ useBusinessDaysForDisplayRange: true,
+ useBusinessDaysForProgressBand: true
+ });
+
+ expect(markdown).toContain("詳細: # 見出し風");
+ expect(markdown).toContain(" - 箇条書き風");
+ expect(markdown).toContain(" 1. 番号付き風");
+ expect(markdown).toContain("\\# 見出し風 \\- 箇条書き風 1\\. 番号付き風 A \\\\| B <tag> & text");
+ });
+
+ it("uses a fence that does not break when tree text includes backticks", () => {
+ const { xml, wbsMarkdown } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ model.tasks[1].name = "``` fenced name";
+ model.tasks[1].notes = "line 1\n``` fenced note";
+
+ const markdown = wbsMarkdown.exportWbsMarkdown(model, {
+ useBusinessDaysForDisplayRange: true,
+ useBusinessDaysForProgressBand: true
+ });
+
+ expect(markdown).toContain("~~~text");
+ expect(markdown).toContain("``` fenced name");
+ expect(markdown).toContain("``` fenced note");
+ });
+});
diff --git a/tests/mikuproject-wbs-xlsx.test.js b/tests/mikuproject-wbs-xlsx.test.js
new file mode 100644
index 0000000..8b6691d
--- /dev/null
+++ b/tests/mikuproject-wbs-xlsx.test.js
@@ -0,0 +1,739 @@
+// @vitest-environment jsdom
+
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it, vi } from "vitest";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const typesCode = readFileSync(
+ path.resolve(__dirname, "../src/js/types.js"),
+ "utf8"
+);
+const excelIoCode = readFileSync(
+ path.resolve(__dirname, "../src/js/excel-io.js"),
+ "utf8"
+);
+const msProjectXmlCode = readFileSync(
+ path.resolve(__dirname, "../src/js/msproject-xml.js"),
+ "utf8"
+);
+const wbsXlsxCode = readFileSync(
+ path.resolve(__dirname, "../src/js/wbs-xlsx.js"),
+ "utf8"
+);
+
+function bootModules() {
+ new Function(`${typesCode}\n${excelIoCode}\n${msProjectXmlCode}\n${wbsXlsxCode}`)();
+ return {
+ excelIo: globalThis.__mikuprojectExcelIo,
+ xml: globalThis.__mikuprojectXml,
+ wbsXlsx: globalThis.__mikuprojectWbsXlsx
+ };
+}
+
+function findRowIndexByCellValue(sheet, value, columnIndex = 0) {
+ return sheet.rows.findIndex((row) => row.cells[columnIndex]?.value === value);
+}
+
+function findRowIndexByPredicate(sheet, predicate) {
+ return sheet.rows.findIndex((row) => predicate(row.cells));
+}
+
+const SAMPLE_HOLIDAY_COUNT = 1;
+
+describe("mikuproject wbs xlsx", () => {
+ it("provides Excel-style layout references for WBS worksheet tuning", () => {
+ const { wbsXlsx } = bootModules();
+
+ expect(wbsXlsx.layout.columnIndex("A")).toBe(0);
+ expect(wbsXlsx.layout.columnIndex("S")).toBe(18);
+ expect(wbsXlsx.layout.columnName(18)).toBe("S");
+ expect(wbsXlsx.layout.reference(17, 2)).toBe("C17");
+ expect(wbsXlsx.layout.range("A1", "C17")).toBe("A1:C17");
+ expect(wbsXlsx.layout.parseCellReference("C17")).toEqual({
+ reference: "C17",
+ rowNumber: 17,
+ rowIndex: 16,
+ columnName: "C",
+ columnIndex: 2
+ });
+ expect(wbsXlsx.layout.describeCell("C17")).toBe("C17 (row 17, rowIndex 16, column C, columnIndex 2)");
+ });
+
+ it("can log WBS layout cell references on demand", () => {
+ const { wbsXlsx } = bootModules();
+ const messages = [];
+
+ const message = wbsXlsx.layout.logCell("S12", "week header", (line) => {
+ messages.push(line);
+ });
+
+ expect(message).toBe("week header: S12 (row 12, rowIndex 11, column S, columnIndex 18)");
+ expect(messages).toEqual([
+ "week header: S12 (row 12, rowIndex 11, column S, columnIndex 18)"
+ ]);
+ });
+
+ it("collects holiday dates from calendar exceptions", () => {
+ const { xml, wbsXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const holidayDates = wbsXlsx.collectWbsHolidayDates(model);
+
+ expect(holidayDates).toHaveLength(SAMPLE_HOLIDAY_COUNT);
+ expect(holidayDates).toContain("2026-03-20");
+ expect(holidayDates[0]).toBe("2026-03-20");
+ expect(holidayDates.at(-1)).toBe("2026-03-20");
+ });
+
+ it("exports a dedicated WBS workbook from ProjectModel", () => {
+ const { xml, wbsXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-29T22:49:00+09:00"));
+
+ const workbook = wbsXlsx.exportWbsWorkbook(model);
+ const sheet = workbook.sheets[0];
+
+ expect(workbook.sheets.map((item) => item.name)).toEqual(["WBS"]);
+ expect(sheet.columns[0].width).toBeCloseTo(6.43, 2);
+ expect(sheet.columns[1].width).toBeCloseTo(6.43, 2);
+ expect(sheet.columns[2].width).toBeCloseTo(9.29, 2);
+ expect(sheet.columns[3].width).toBeCloseTo(8.57, 2);
+ expect(sheet.columns[4].width).toBeCloseTo(6.43, 2);
+ expect(sheet.columns[5].width).toBe(42);
+ expect(sheet.columns[6].width).toBeCloseTo(12.14, 2);
+ expect(sheet.columns[7].width).toBeCloseTo(12.14, 2);
+ expect(sheet.columns[8].width).toBeCloseTo(9.29, 2);
+ expect(sheet.columns[9].width).toBe(28);
+ expect(sheet.columns[11].width).toBe(18);
+ expect(sheet.columns[11].hidden).toBe(true);
+ expect(sheet.columns[12].width).toBe(12);
+ expect(sheet.columns[12].hidden).toBe(true);
+ expect(sheet.columns[13].width).toBe(12);
+ expect(sheet.columns[13].hidden).toBe(true);
+ expect(sheet.columns[14].width).toBe(12);
+ expect(sheet.columns[14].hidden).toBe(true);
+ expect(sheet.columns[15].width).toBeCloseTo(12.14, 2);
+ expect(sheet.columns[16].hidden).toBe(true);
+ expect(sheet.columns[16].width).toBe(12);
+ expect(sheet.columns[17].hidden).toBe(true);
+ expect(sheet.columns[17].width).toBe(20);
+ expect(sheet.columns[18].hidden).toBe(true);
+ expect(sheet.columns[18].width).toBe(18);
+ expect(sheet.mergedRanges).toContain("A1:E1");
+ const projectInfoHeaderIndex = findRowIndexByCellValue(sheet, "プロジェクト情報", 0);
+ expect(projectInfoHeaderIndex).toBe(0);
+ expect(sheet.rows[projectInfoHeaderIndex].cells[0].fontSize).toBe(14);
+ expect(sheet.rows[projectInfoHeaderIndex + 1].cells[0].value).toBe("プロジェクト名");
+ expect(sheet.rows[projectInfoHeaderIndex + 1].cells[2].value).toBe("mikuproject開発");
+ expect(sheet.rows[1].cells[9].value).toBe("出力日時 2026-03-29 22:49");
+ expect(sheet.rows[projectInfoHeaderIndex + 2].cells[0].value).toBe("カレンダ");
+ expect(sheet.rows[projectInfoHeaderIndex + 2].cells[2].value).toBe("1 Standard");
+ expect(sheet.rows[projectInfoHeaderIndex + 3].cells[0].value).toBe("開始日");
+ expect(sheet.rows[projectInfoHeaderIndex + 3].cells[2].value).toBe("2026-03-16");
+ expect(sheet.rows[projectInfoHeaderIndex + 4].cells[0].value).toBe("終了日");
+ expect(sheet.rows[projectInfoHeaderIndex + 4].cells[2].value).toBe("2026-04-01");
+ expect(sheet.rows[projectInfoHeaderIndex + 5].cells[0].value).toBe("現在日");
+ expect(sheet.rows[projectInfoHeaderIndex + 5].cells[2].value).toBe("2026-03-23");
+ expect(sheet.rows[projectInfoHeaderIndex + 6].cells[0].value).toBe("祝日");
+ expect(sheet.rows[projectInfoHeaderIndex + 6].cells[2].value).toBe("1");
+ const summaryHeaderIndex = findRowIndexByCellValue(sheet, "サマリ", 0);
+ expect(sheet.rows[summaryHeaderIndex].height).toBe(24);
+ expect(sheet.rows[summaryHeaderIndex].cells[0].fontSize).toBe(14);
+ expect(sheet.rows[summaryHeaderIndex].cells[0].fillColor).toBe("#E1EDF8");
+ expect(sheet.rows[summaryHeaderIndex + 1].cells[0].value).toBe("表示日");
+ expect(sheet.rows[summaryHeaderIndex + 1].cells[1].value).toBe("17");
+ expect(sheet.rows[summaryHeaderIndex + 1].cells[0].horizontalAlign).toBe("right");
+ expect(sheet.rows[summaryHeaderIndex + 1].cells[1].horizontalAlign).toBe("center");
+ expect(sheet.rows[summaryHeaderIndex + 1].cells[1].bold).toBe(true);
+ expect(sheet.rows[projectInfoHeaderIndex + 1].cells[2].horizontalAlign).toBe("left");
+ expect(sheet.rows[projectInfoHeaderIndex + 3].cells[2].horizontalAlign).toBe("left");
+ expect(sheet.rows[summaryHeaderIndex + 3].cells[0].value).toBe("営業日");
+ expect(sheet.rows[summaryHeaderIndex + 3].cells[1].value).toBe("12");
+ expect(sheet.rows[summaryHeaderIndex + 4].cells[0].value).toBe("前日数");
+ expect(sheet.rows[summaryHeaderIndex + 4].cells[1].value).toBe("-");
+ expect(sheet.rows[summaryHeaderIndex + 5].cells[0].value).toBe("後日数");
+ expect(sheet.rows[summaryHeaderIndex + 5].cells[1].value).toBe("-");
+ expect(sheet.rows[summaryHeaderIndex + 6].cells[0].value).toBe("表示");
+ expect(sheet.rows[summaryHeaderIndex + 6].cells[1].value).toBe("暦日");
+ expect(sheet.rows[summaryHeaderIndex + 7].cells[0].value).toBe("進捗");
+ expect(sheet.rows[summaryHeaderIndex + 7].cells[1].value).toBe("暦日");
+ expect(sheet.rows[summaryHeaderIndex + 8].cells[0].value).toBe("基準日");
+ expect(sheet.rows[summaryHeaderIndex + 8].cells[1].value).toBe("2026-03-23");
+ expect(sheet.rows[summaryHeaderIndex + 9].cells[0].value).toBe("タスク");
+ expect(sheet.rows[summaryHeaderIndex + 9].cells[1].value).toBe("13");
+ expect(sheet.rows[summaryHeaderIndex + 10].cells[0].value).toBe("リソース");
+ expect(sheet.rows[summaryHeaderIndex + 10].cells[1].value).toBe("0");
+ expect(sheet.rows[summaryHeaderIndex + 11].cells[0].value).toBe("割当");
+ expect(sheet.rows[summaryHeaderIndex + 11].cells[1].value).toBe("0");
+ expect(sheet.rows[summaryHeaderIndex + 12].cells[0].value).toBe("カレンダ");
+ expect(sheet.rows[summaryHeaderIndex + 12].cells[1].value).toBe("1");
+ const headerRowIndex = findRowIndexByCellValue(sheet, "UID");
+ const dateRowIndex = headerRowIndex - 1;
+ expect(headerRowIndex).toBe(8);
+ expect(dateRowIndex).toBe(7);
+ expect(sheet.rows[headerRowIndex].cells.slice(0, 19).map((cell) => cell.value)).toEqual([
+ "UID",
+ "ID",
+ "WBS",
+ "種別",
+ "階層",
+ "名称",
+ "開始",
+ "終了",
+ "期間",
+ "タスク詳細",
+ "進捗",
+ "作業進捗",
+ "マイル",
+ "サマリ",
+ "クリティカル",
+ "担当",
+ "カレンダ",
+ "リソース",
+ "先行"
+ ]);
+ expect(sheet.rows[headerRowIndex].cells[19].fillColor).toBe("#D9E2EA");
+ expect(sheet.rows[dateRowIndex].cells.slice(20).map((cell) => cell.value)).toEqual([
+ "3/16",
+ "3/17",
+ "3/18",
+ "3/19",
+ "3/20",
+ "3/21",
+ "3/22",
+ "3/23",
+ "3/24",
+ "3/25",
+ "3/26",
+ "3/27",
+ "3/28",
+ "3/29",
+ "3/30",
+ "3/31",
+ "4/1"
+ ]);
+ expect(sheet.rows[headerRowIndex].cells.slice(20).map((cell) => cell.value)).toEqual([
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ "Mon",
+ "Tue",
+ "Wed"
+ ]);
+ expect(sheet.rows[headerRowIndex].cells[0].fillColor).toBe("#E1EDF8");
+ expect(sheet.rows[headerRowIndex].cells[2].fillColor).toBe("#E6F0DF");
+ expect(sheet.rows[headerRowIndex].cells[5].horizontalAlign).toBe("center");
+ expect(sheet.rows[headerRowIndex].cells[15].horizontalAlign).toBe("center");
+ expect(sheet.rows[headerRowIndex].cells[15].verticalAlign).toBe("center");
+ expect(sheet.rows[headerRowIndex].cells[6].fillColor).toBe("#FDE7D3");
+ expect(sheet.rows[headerRowIndex].cells[10].fillColor).toBe("#FBE4EC");
+ expect(sheet.rows[headerRowIndex].cells[15].fillColor).toBe("#E2F1EF");
+ expect(sheet.rows[dateRowIndex].cells[20].fillColor).toBe("#D9EAF7");
+ expect(sheet.rows[dateRowIndex].cells[20].verticalAlign).toBe("center");
+ const firstTaskRow = sheet.rows[headerRowIndex + 1];
+ const secondTaskRow = sheet.rows[headerRowIndex + 2];
+ const thirdTaskRow = sheet.rows[headerRowIndex + 3];
+ expect(firstTaskRow.cells[3].value).toBe("フェーズ");
+ expect(firstTaskRow.cells[3].fillColor).toBe("#EEF7E8");
+ expect(firstTaskRow.cells[0].fillColor).toBe("#EEF7E8");
+ expect(firstTaskRow.cells[5].bold).toBe(true);
+ expect(firstTaskRow.cells[9].value).toBe("-");
+ expect(firstTaskRow.cells[9].fillColor).toBe("#F5F7FA");
+ expect(firstTaskRow.cells[9].horizontalAlign).toBe("left");
+ expect(firstTaskRow.cells[15].value).toBe("-");
+ expect(firstTaskRow.cells[15].fillColor).toBe("#F5F7FA");
+ expect(firstTaskRow.cells[15].horizontalAlign).toBe("center");
+ expect(firstTaskRow.cells[17].value).toBe("-");
+ expect(firstTaskRow.cells[18].value).toBe("-");
+ expect(firstTaskRow.cells[10].value).toBe("100%\n[##########]");
+ expect(firstTaskRow.cells[11].value).toBe("");
+ expect(firstTaskRow.cells[13].value).toBe("Sum");
+ expect(firstTaskRow.cells[6].value).toBe("2026-03-16");
+ expect(firstTaskRow.cells[7].value).toBe("2026-03-17");
+ expect(firstTaskRow.cells[8].value).toBe("2日");
+ expect(firstTaskRow.cells[20].value).toBe("━");
+ expect(firstTaskRow.cells[21].value).toBe("━");
+ expect(secondTaskRow.cells[3].value).toBe("マイル");
+ expect(secondTaskRow.cells[3].fillColor).toBe("#FFF4E0");
+ expect(secondTaskRow.cells[0].fillColor).toBe("#FFF4E0");
+ expect(firstTaskRow.cells[5].value).toBe("> 基盤整備");
+ expect(secondTaskRow.cells[5].value).toBe(" * 着手");
+ expect(secondTaskRow.cells[5].fillColor).toBe("#FFF4E0");
+ expect(secondTaskRow.cells[5].horizontalAlign).toBe("left");
+ expect(secondTaskRow.cells[5].wrapText).toBe(true);
+ expect(secondTaskRow.cells[6].fillColor).toBe("#FFF4E0");
+ expect(secondTaskRow.cells[9].value).toBe("-");
+ expect(secondTaskRow.cells[9].fillColor).toBe("#F5F7FA");
+ expect(secondTaskRow.cells[9].wrapText).toBeUndefined();
+ expect(secondTaskRow.cells[15].value).toBe("-");
+ expect(secondTaskRow.cells[15].fillColor).toBe("#F5F7FA");
+ expect(secondTaskRow.cells[15].horizontalAlign).toBe("center");
+ expect(secondTaskRow.cells[15].verticalAlign).toBe("center");
+ expect(secondTaskRow.cells[16].value).toBe("1 Standard");
+ expect(secondTaskRow.cells[17].value).toBe("-");
+ expect(secondTaskRow.cells[17].horizontalAlign).toBe("center");
+ expect(secondTaskRow.cells[6].value).toBe("2026-03-16");
+ expect(secondTaskRow.cells[7].value).toBe("2026-03-16");
+ expect(secondTaskRow.cells[8].value).toBe("1日");
+ expect(secondTaskRow.cells[10].value).toBe("100%\n[##########]");
+ expect(secondTaskRow.cells[10].fillColor).toBe("#FFF4E0");
+ expect(secondTaskRow.cells[11].value).toBe("");
+ expect(secondTaskRow.cells[20].value).toBe("◆");
+ expect(secondTaskRow.cells[20].fillColor).toBe("#5BAE9C");
+ expect(secondTaskRow.cells[21].value).toBe("");
+ expect(thirdTaskRow.cells[5].value).toBe(" - 初期実装(MS Project XML 調査・基軸フォーマット選定・内部モデルの概要確定)");
+ expect(thirdTaskRow.cells[9].value).toBe("-");
+ expect(thirdTaskRow.cells[18].value).toBe("-");
+ expect(thirdTaskRow.cells[6].value).toBe("2026-03-16");
+ expect(thirdTaskRow.cells[7].value).toBe("2026-03-16");
+ expect(thirdTaskRow.cells[8].value).toBe("1日");
+ expect(thirdTaskRow.cells[10].value).toBe("100%\n[##########]");
+ expect(thirdTaskRow.cells[11].value).toBe("");
+ expect(thirdTaskRow.cells[24].value).toBe("");
+ const legendHeaderIndex = findRowIndexByCellValue(sheet, "凡例", 0);
+ expect(legendHeaderIndex).toBe(headerRowIndex + 15);
+ expect(sheet.rows[legendHeaderIndex - 1].height).toBe(28);
+ expect(sheet.rows[legendHeaderIndex - 1].cells[0].value).toBeUndefined();
+ expect(sheet.rows[legendHeaderIndex].height).toBe(24);
+ expect(sheet.rows[legendHeaderIndex].cells[0].fontSize).toBe(14);
+ expect(sheet.rows[legendHeaderIndex + 1].height).toBe(24);
+ expect(sheet.rows[legendHeaderIndex + 1].cells[0].value).toBe("進捗済み");
+ expect(sheet.rows[legendHeaderIndex + 1].cells[0].bold).toBe(true);
+ expect(sheet.rows[legendHeaderIndex + 1].cells[0].fillColor).toBe("#5BAE9C");
+ expect(sheet.rows[legendHeaderIndex + 7].cells[0].value).toBe("━:フェーズ");
+ expect(sheet.rows[legendHeaderIndex + 8].cells[0].value).toBe("■:進捗済みタスク");
+ expect(sheet.rows[legendHeaderIndex + 9].cells[0].value).toBe("□:予定タスク");
+ expect(sheet.rows[legendHeaderIndex + 10].cells[0].fillColor).toBe("#FFF4E0");
+ expect(sheet.rows[legendHeaderIndex + 11].cells[0].fillColor).toBe("#FBE4EC");
+ expect(sheet.rows[legendHeaderIndex + 12].cells[0].fillColor).toBe("#F7EAF0");
+ expect(sheet.rows[legendHeaderIndex + 13].cells[0].fillColor).toBe("#F3E1E9");
+ expect(sheet.rows[legendHeaderIndex + 11].cells[0].value).toBe("Mil:マイルストーン");
+ expect(sheet.rows[legendHeaderIndex + 12].cells[0].value).toBe("Sum:サマリ");
+ expect(sheet.rows[legendHeaderIndex + 13].cells[0].value).toBe("Crit:クリティカル");
+ expect(sheet.rows[legendHeaderIndex + 14].cells[0].value).toBe("-:未設定");
+ expect(summaryHeaderIndex).toBe(legendHeaderIndex + 16);
+ vi.useRealTimers();
+ });
+
+ it("can generate a real xlsx from the dedicated WBS workbook", () => {
+ const { excelIo, xml, wbsXlsx } = bootModules();
+ const codec = new excelIo.XlsxWorkbookCodec();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-29T22:49:00+09:00"));
+
+ const workbook = wbsXlsx.exportWbsWorkbook(model);
+ const bytes = codec.exportWorkbook(workbook);
+ const entries = codec.listEntries(bytes);
+ const unpackedEntries = codec.unpackEntries(bytes);
+ const sheetXml = new TextDecoder().decode(unpackedEntries["xl/worksheets/sheet1.xml"]);
+ const stylesXml = new TextDecoder().decode(unpackedEntries["xl/styles.xml"]);
+
+ expect(entries).toContain("xl/workbook.xml");
+ expect(entries).toContain("xl/worksheets/sheet1.xml");
+ expect(sheetXml).toContain('ref="A1:E1"');
+ expect(sheetXml).toContain('s="1"');
+ expect(stylesXml).toContain(' ');
+ expect(sheetXml).toContain('min="12" max="12" width="18" customWidth="1" hidden="1"');
+ expect(sheetXml).toContain('min="13" max="13" width="12" customWidth="1" hidden="1"');
+ expect(sheetXml).toContain('min="14" max="14" width="12" customWidth="1" hidden="1"');
+ expect(sheetXml).toContain('min="15" max="15" width="12" customWidth="1" hidden="1"');
+ expect(sheetXml).toContain('min="17" max="17" width="12" customWidth="1" hidden="1"');
+ expect(sheetXml).toContain('min="18" max="18" width="20" customWidth="1" hidden="1"');
+ expect(sheetXml).toContain('min="19" max="19" width="18" customWidth="1" hidden="1"');
+ expect(sheetXml).not.toContain("1 ');
+ vi.useRealTimers();
+ });
+
+ it("marks weekend date-band cells with weekend fill", () => {
+ const { xml, wbsXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+
+ model.project.startDate = "2026-03-20T09:00:00";
+ model.project.finishDate = "2026-03-23T18:00:00";
+ model.project.currentDate = "2026-03-21T09:00:00";
+
+ const workbook = wbsXlsx.exportWbsWorkbook(model);
+ const sheet = workbook.sheets[0];
+
+ const headerRowIndex = findRowIndexByCellValue(sheet, "UID");
+ const dateRowIndex = headerRowIndex - 1;
+ expect(sheet.rows[dateRowIndex].cells.slice(20).map((cell) => cell.value)).toEqual([
+ "3/20",
+ "3/21",
+ "3/22",
+ "3/23"
+ ]);
+ expect(sheet.rows[headerRowIndex].cells.slice(20).map((cell) => cell.value)).toEqual([
+ "Fri",
+ "Sat",
+ "Sun",
+ "Mon"
+ ]);
+ expect(sheet.rows[dateRowIndex].cells[21].fillColor).toBe("#FFE6A7");
+ expect(sheet.rows[dateRowIndex].cells[22].fillColor).toBe("#C9D3E1");
+ expect(sheet.rows[headerRowIndex].cells[21].fillColor).toBe("#8EA9DB");
+ expect(sheet.rows[headerRowIndex].cells[22].fillColor).toBe("#E6B8AF");
+ });
+
+ it("uses project calendar weekdays instead of hardcoded weekends for non-working fill", () => {
+ const { xml, wbsXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+
+ model.project.startDate = "2026-03-20T09:00:00";
+ model.project.finishDate = "2026-03-23T18:00:00";
+ model.project.currentDate = "2026-03-20T09:00:00";
+ model.calendars[0].weekDays = [
+ { dayType: 1, dayWorking: false, workingTimes: [] },
+ { dayType: 2, dayWorking: true, workingTimes: [{ fromTime: "09:00:00", toTime: "18:00:00" }] },
+ { dayType: 3, dayWorking: true, workingTimes: [{ fromTime: "09:00:00", toTime: "18:00:00" }] },
+ { dayType: 4, dayWorking: true, workingTimes: [{ fromTime: "09:00:00", toTime: "18:00:00" }] },
+ { dayType: 5, dayWorking: true, workingTimes: [{ fromTime: "09:00:00", toTime: "18:00:00" }] },
+ { dayType: 6, dayWorking: false, workingTimes: [] },
+ { dayType: 7, dayWorking: true, workingTimes: [{ fromTime: "09:00:00", toTime: "18:00:00" }] }
+ ];
+
+ const workbook = wbsXlsx.exportWbsWorkbook(model);
+ const sheet = workbook.sheets[0];
+ const headerRowIndex = findRowIndexByCellValue(sheet, "UID");
+ const dateRowIndex = headerRowIndex - 1;
+
+ expect(sheet.rows[dateRowIndex].cells.slice(20).map((cell) => cell.value)).toEqual([
+ "3/20",
+ "3/21",
+ "3/22",
+ "3/23"
+ ]);
+ expect(sheet.rows[headerRowIndex].cells.slice(20).map((cell) => cell.value)).toEqual([
+ "Fri",
+ "Sat",
+ "Sun",
+ "Mon"
+ ]);
+ expect(sheet.rows[dateRowIndex].cells[20].fillColor).toBe("#FFE6A7");
+ expect(sheet.rows[dateRowIndex].cells[21].fillColor).toBe("#D9EAF7");
+ expect(sheet.rows[dateRowIndex].cells[22].fillColor).toBe("#C9D3E1");
+ expect(sheet.rows[headerRowIndex].cells[21].fillColor).toBe("#D9EAF7");
+ expect(sheet.rows[headerRowIndex].cells[22].fillColor).toBe("#E6B8AF");
+ });
+
+ it("suppresses task bands on weekly non-working days and configured holidays", () => {
+ const { xml, wbsXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+ const task = model.tasks.find((item) => !item.summary && !item.milestone);
+ if (!task) {
+ throw new Error("Expected a non-summary task in sample model");
+ }
+
+ model.project.startDate = "2026-03-26T09:00:00";
+ model.project.finishDate = "2026-03-29T18:00:00";
+ model.project.currentDate = "2026-03-26T09:00:00";
+ task.start = "2026-03-26T09:00:00";
+ task.finish = "2026-03-29T18:00:00";
+ task.percentComplete = 50;
+ model.calendars[0].weekDays = [
+ { dayType: 1, dayWorking: false, workingTimes: [] },
+ { dayType: 2, dayWorking: true, workingTimes: [{ fromTime: "09:00:00", toTime: "18:00:00" }] },
+ { dayType: 3, dayWorking: true, workingTimes: [{ fromTime: "09:00:00", toTime: "18:00:00" }] },
+ { dayType: 4, dayWorking: true, workingTimes: [{ fromTime: "09:00:00", toTime: "18:00:00" }] },
+ { dayType: 5, dayWorking: true, workingTimes: [{ fromTime: "09:00:00", toTime: "18:00:00" }] },
+ { dayType: 6, dayWorking: false, workingTimes: [] },
+ { dayType: 7, dayWorking: true, workingTimes: [{ fromTime: "09:00:00", toTime: "18:00:00" }] }
+ ];
+ model.calendars[0].exceptions = [{
+ name: "祝日",
+ fromDate: "2026-03-28T00:00:00",
+ toDate: "2026-03-28T23:59:59",
+ dayWorking: false,
+ workingTimes: []
+ }];
+
+ const workbook = wbsXlsx.exportWbsWorkbook(model);
+ const sheet = workbook.sheets[0];
+ const headerRowIndex = findRowIndexByCellValue(sheet, "UID");
+ const dateRowIndex = headerRowIndex - 1;
+ const dateColumns = new Map(
+ sheet.rows[dateRowIndex].cells.map((cell, index) => [cell.value, index])
+ );
+ const taskRowIndex = headerRowIndex + 1 + model.tasks.indexOf(task);
+
+ expect(taskRowIndex).toBeGreaterThan(headerRowIndex);
+ expect(sheet.rows[taskRowIndex].cells[dateColumns.get("3/26")].value).toBe("■");
+ expect(sheet.rows[taskRowIndex].cells[dateColumns.get("3/27")].value).toBe("");
+ expect(sheet.rows[taskRowIndex].cells[dateColumns.get("3/28")].value).toBe("");
+ expect(sheet.rows[taskRowIndex].cells[dateColumns.get("3/29")].value).toBe("□");
+ expect(sheet.rows[taskRowIndex].cells[dateColumns.get("3/27")].fillColor).toBe("#C9D3E1");
+ expect(sheet.rows[taskRowIndex].cells[dateColumns.get("3/28")].fillColor).toBe("#FCE4EC");
+ expect(sheet.rows[taskRowIndex].cells[dateColumns.get("3/29")].fillColor).toBe("#9FD5C9");
+ });
+
+ it("marks week-start date-band cells with week-start fill", () => {
+ const { xml, wbsXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+
+ model.project.startDate = "2026-03-16T09:00:00";
+ model.project.finishDate = "2026-03-23T18:00:00";
+ model.project.currentDate = "2026-03-18T09:00:00";
+
+ const workbook = wbsXlsx.exportWbsWorkbook(model);
+ const sheet = workbook.sheets[0];
+
+ const headerRowIndex = findRowIndexByCellValue(sheet, "UID");
+ const dateRowIndex = headerRowIndex - 1;
+ expect(sheet.rows[dateRowIndex].cells[27].value).toBe("3/23");
+ expect(sheet.rows[headerRowIndex].cells[27].value).toBe("Mon");
+ expect(sheet.rows[dateRowIndex].cells[27].fillColor).toBe("#D9EAF7");
+ expect(sheet.rows[headerRowIndex + 1].cells[27].fillColor).toBe("#F4F7FB");
+ });
+
+ it("does not emit a dedicated week-label row even when a month boundary exists", () => {
+ const { xml, wbsXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+
+ model.project.startDate = "2026-03-30T09:00:00";
+ model.project.finishDate = "2026-04-03T18:00:00";
+ model.project.currentDate = "2026-04-01T09:00:00";
+
+ const workbook = wbsXlsx.exportWbsWorkbook(model);
+ const sheet = workbook.sheets[0];
+
+ expect(findRowIndexByCellValue(sheet, "週", 18)).toBe(-1);
+ });
+
+ it("emphasizes month-start date headers", () => {
+ const { xml, wbsXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+
+ model.project.startDate = "2026-03-30T09:00:00";
+ model.project.finishDate = "2026-04-03T18:00:00";
+ model.project.currentDate = "2026-03-31T09:00:00";
+
+ const workbook = wbsXlsx.exportWbsWorkbook(model);
+ const sheet = workbook.sheets[0];
+
+ const headerRowIndex = findRowIndexByCellValue(sheet, "UID");
+ expect(sheet.rows[headerRowIndex - 1].cells[22].value).toBe("4/1");
+ expect(sheet.rows[headerRowIndex].cells[22].value).toBe("Wed");
+ expect(sheet.rows[headerRowIndex - 1].cells[22].fillColor).toBe("#DCEAF7");
+ });
+
+ it("renders milestone bands with a diamond marker", () => {
+ const { xml, wbsXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+
+ model.tasks[2].milestone = true;
+ model.tasks[2].finish = model.tasks[2].start;
+
+ const workbook = wbsXlsx.exportWbsWorkbook(model);
+ const sheet = workbook.sheets[0];
+
+ const headerRowIndex = findRowIndexByCellValue(sheet, "UID");
+ const milestoneRow = sheet.rows[headerRowIndex + 3];
+ expect(milestoneRow.cells[3].value).toBe("マイル");
+ expect(milestoneRow.cells[3].fillColor).toBe("#FFF4E0");
+ expect(milestoneRow.cells[12].value).toBe("Mil");
+ expect(milestoneRow.cells[20].value).toBe("◆");
+ });
+
+ it("renders critical flags with an exclamation marker", () => {
+ const { xml, wbsXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+
+ model.tasks[1].critical = true;
+
+ const workbook = wbsXlsx.exportWbsWorkbook(model);
+ const sheet = workbook.sheets[0];
+
+ const headerRowIndex = findRowIndexByCellValue(sheet, "UID");
+ expect(sheet.rows[headerRowIndex + 2].cells[14].value).toBe("Crit");
+ });
+
+ it("marks configured holidays in the date band", () => {
+ const { xml, wbsXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+
+ const workbook = wbsXlsx.exportWbsWorkbook(model, {
+ holidayDates: ["2026-03-20"]
+ });
+ const sheet = workbook.sheets[0];
+
+ const projectInfoHeaderIndex = findRowIndexByCellValue(sheet, "プロジェクト情報", 0);
+ expect(sheet.rows[projectInfoHeaderIndex + 6].cells[2].value).toBe("1");
+ const headerRowIndex = findRowIndexByCellValue(sheet, "UID");
+ expect(sheet.rows[headerRowIndex - 1].cells[24].value).toBe("3/20");
+ expect(sheet.rows[headerRowIndex].cells[24].value).toBe("Fri");
+ expect(sheet.rows[headerRowIndex - 1].cells[24].fillColor).toBe("#FCE4EC");
+ expect(sheet.rows[headerRowIndex + 1].cells[24].value).toBe("");
+ expect(sheet.rows[headerRowIndex + 1].cells[24].fillColor).toBe("#FCE4EC");
+ });
+
+ it("can limit the displayed date band around base date", () => {
+ const { xml, wbsXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+
+ const workbook = wbsXlsx.exportWbsWorkbook(model, {
+ displayDaysBeforeBaseDate: 1,
+ displayDaysAfterBaseDate: 2
+ });
+ const sheet = workbook.sheets[0];
+ const summaryHeaderIndex = findRowIndexByCellValue(sheet, "サマリ", 0);
+ const headerRowIndex = findRowIndexByCellValue(sheet, "UID");
+ const dateRowIndex = headerRowIndex - 1;
+
+ expect(sheet.rows[dateRowIndex].cells.slice(20).map((cell) => cell.value)).toEqual([
+ "3/22",
+ "3/23",
+ "3/24",
+ "3/25"
+ ]);
+ expect(sheet.rows[summaryHeaderIndex + 4].cells[1].value).toBe("1");
+ expect(sheet.rows[summaryHeaderIndex + 5].cells[1].value).toBe("2");
+ expect(sheet.rows[summaryHeaderIndex + 6].cells[1].value).toBe("暦日");
+ expect(sheet.rows[summaryHeaderIndex + 7].cells[1].value).toBe("暦日");
+ });
+
+ it("can limit the displayed date band around base date using business days", () => {
+ const { xml, wbsXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+
+ model.project.startDate = "2026-03-16T09:00:00";
+ model.project.finishDate = "2026-03-24T18:00:00";
+ model.project.currentDate = "2026-03-18T09:00:00";
+
+ const workbook = wbsXlsx.exportWbsWorkbook(model, {
+ holidayDates: ["2026-03-20"],
+ displayDaysBeforeBaseDate: 1,
+ displayDaysAfterBaseDate: 2,
+ useBusinessDaysForDisplayRange: true
+ });
+ const sheet = workbook.sheets[0];
+ const summaryHeaderIndex = findRowIndexByCellValue(sheet, "サマリ", 0);
+ const headerRowIndex = findRowIndexByCellValue(sheet, "UID");
+ const dateRowIndex = headerRowIndex - 1;
+
+ expect(sheet.rows[dateRowIndex].cells.slice(20).map((cell) => cell.value)).toEqual([
+ "3/17",
+ "3/18",
+ "3/19",
+ "3/20",
+ "3/21",
+ "3/22",
+ "3/23"
+ ]);
+ expect(sheet.rows[summaryHeaderIndex + 3].cells[1].value).toBe("4");
+ expect(sheet.rows[summaryHeaderIndex + 6].cells[1].value).toBe("営業日");
+ expect(sheet.rows[summaryHeaderIndex + 7].cells[1].value).toBe("暦日");
+ });
+
+ it("can calculate progress band using business days", () => {
+ const { xml, wbsXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+
+ model.project.currentDate = "2026-03-25T09:00:00";
+ model.tasks[2].start = "2026-03-16T09:00:00";
+ model.tasks[2].finish = "2026-03-22T18:00:00";
+ model.tasks[2].percentComplete = 50;
+
+ const workbook = wbsXlsx.exportWbsWorkbook(model, {
+ holidayDates: ["2026-03-20"],
+ useBusinessDaysForProgressBand: true
+ });
+ const sheet = workbook.sheets[0];
+ const summaryHeaderIndex = findRowIndexByCellValue(sheet, "サマリ", 0);
+ const headerRowIndex = findRowIndexByCellValue(sheet, "UID");
+ const designRow = sheet.rows[headerRowIndex + 3];
+
+ expect(sheet.rows[summaryHeaderIndex + 7].cells[1].value).toBe("営業日");
+ expect(designRow.cells[8].value).toBe("4営業日");
+ expect(designRow.cells[20].fillColor).toBe("#5BAE9C");
+ expect(designRow.cells[21].fillColor).toBe("#5BAE9C");
+ expect(designRow.cells[22].fillColor).toBe("#9FD5C9");
+ expect(designRow.cells[24].fillColor).toBe("#FCE4EC");
+ expect(designRow.cells[25].fillColor).toBe("#C9D3E1");
+ expect(designRow.cells[26].fillColor).toBe("#9FD5C9");
+ });
+
+ it("shows task band on non-working endpoints only", () => {
+ const { xml, wbsXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+
+ model.tasks[2].start = "2026-03-20T09:00:00";
+ model.tasks[2].finish = "2026-03-23T18:00:00";
+ model.tasks[2].percentComplete = 0;
+
+ const workbook = wbsXlsx.exportWbsWorkbook(model, {
+ holidayDates: ["2026-03-20"]
+ });
+ const sheet = workbook.sheets[0];
+ const headerRowIndex = findRowIndexByCellValue(sheet, "UID");
+ const designRow = sheet.rows[headerRowIndex + 3];
+
+ expect(designRow.cells[24].value).toBe("□");
+ expect(designRow.cells[25].value).toBe("");
+ expect(designRow.cells[26].value).toBe("");
+ expect(designRow.cells[27].value).toBe("□");
+ });
+
+ it("truncates long owner, resources, and predecessors labels for wbs display", () => {
+ const { xml, wbsXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+
+ model.project.name = "Sample Project Name Very Long";
+ model.calendars[0].name = "Standard Calendar Very Long";
+ model.tasks[2].predecessors = [{ predecessorUid: "1", type: 1, lag: "0" }];
+
+ const workbook = wbsXlsx.exportWbsWorkbook(model);
+ const sheet = workbook.sheets[0];
+ const projectInfoHeaderIndex = findRowIndexByCellValue(sheet, "プロジェクト情報", 0);
+ const headerRowIndex = findRowIndexByCellValue(sheet, "UID");
+ const secondTaskRow = sheet.rows[headerRowIndex + 3];
+ const thirdTaskRow = sheet.rows[headerRowIndex + 4];
+
+ expect(sheet.rows[projectInfoHeaderIndex + 1].cells[0].value).toBe("プロジェクト名");
+ expect(sheet.rows[projectInfoHeaderIndex + 1].cells[2].value).toBe("Sample Project ...");
+ expect(secondTaskRow.cells[15].value).toBe("-");
+ expect(secondTaskRow.cells[16].value).toBe("1 Standa...");
+ expect(secondTaskRow.cells[17].value).toBe("-");
+ expect(thirdTaskRow.cells[18].value).toBe("-");
+ });
+
+ it("uses taller rows for long task names in wbs display", () => {
+ const { xml, wbsXlsx } = bootModules();
+ const model = xml.importMsProjectXml(xml.SAMPLE_XML);
+
+ model.tasks[2].name = "Design task with a very long title for wrapped display";
+ model.tasks[2].notes = "Detailed note line one\nDetailed note line two with additional context";
+
+ const workbook = wbsXlsx.exportWbsWorkbook(model);
+ const sheet = workbook.sheets[0];
+ const headerRowIndex = findRowIndexByCellValue(sheet, "UID");
+ const designRow = sheet.rows[headerRowIndex + 3];
+
+ expect(designRow.height).toBe(82);
+ expect(designRow.cells[5].wrapText).toBe(true);
+ expect(designRow.cells[9].wrapText).toBe(true);
+ });
+});