From 39b969d3f0d8dfabf8d801e6b6eb70fd41d4a638 Mon Sep 17 00:00:00 2001 From: riiim Date: Thu, 12 Nov 2026 11:41:23 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E7=94=BB=E9=9D=A2=E3=81=A8?= =?UTF-8?q?=E5=90=8C=E3=81=98=E3=82=BF=E3=82=B9=E3=82=AF=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E3=82=92=E3=83=AD=E3=83=BC=E3=82=AB=E3=83=ABAPI=E3=81=AB?= =?UTF-8?q?=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 既存の作成・更新・削除はそのままに、検索、並び替え、タグ、設定、コピー、リザルトを同じ data.json で扱えるようにする。 --- src-tauri/.gitignore | 1 + src-tauri/core/Cargo.lock | 774 ++++++++++++++++++++++++ src-tauri/core/Cargo.toml | 18 + src-tauri/core/src/bin/fasttask-api.rs | 39 ++ src-tauri/core/src/dispatch.rs | 262 ++++++++ src-tauri/core/src/google_file.rs | 164 +++++ src-tauri/core/src/lib.rs | 79 +++ src-tauri/core/src/model.rs | 285 +++++++++ src-tauri/core/src/parse.rs | 360 +++++++++++ src-tauri/core/src/settings.rs | 169 ++++++ src-tauri/core/src/tags.rs | 129 ++++ src-tauri/core/src/tasks.rs | 803 +++++++++++++++++++++++++ src-tauri/core/src/view.rs | 479 +++++++++++++++ src-tauri/core/tests/http.rs | 480 +++++++++++++++ 14 files changed, 4042 insertions(+) create mode 100644 src-tauri/core/Cargo.lock create mode 100644 src-tauri/core/Cargo.toml create mode 100644 src-tauri/core/src/bin/fasttask-api.rs create mode 100644 src-tauri/core/src/dispatch.rs create mode 100644 src-tauri/core/src/google_file.rs create mode 100644 src-tauri/core/src/lib.rs create mode 100644 src-tauri/core/src/model.rs create mode 100644 src-tauri/core/src/parse.rs create mode 100644 src-tauri/core/src/settings.rs create mode 100644 src-tauri/core/src/tags.rs create mode 100644 src-tauri/core/src/tasks.rs create mode 100644 src-tauri/core/src/view.rs create mode 100644 src-tauri/core/tests/http.rs diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index b21bd68..b22bd3e 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -1,6 +1,7 @@ # Generated by Cargo # will have compiled files and executables /target/ +core/target/ # Generated by Tauri # will have schema files for capabilities auto-completion diff --git a/src-tauri/core/Cargo.lock b/src-tauri/core/Cargo.lock new file mode 100644 index 0000000..caaa3e8 --- /dev/null +++ b/src-tauri/core/Cargo.lock @@ -0,0 +1,774 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "fasttask-core" +version = "1.4.1" +dependencies = [ + "chrono", + "regex", + "serde_json", + "tiny_http", + "url", + "uuid", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "itoa" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4" + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149309498a5f7334b12ddae0cad8aba81ce785fe91755068867e017cf98fee46" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "uuid" +version = "1.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bed99139f7dac9b5cfe5f17be4d66dc7079a34dee1d74bcc77217119240e267c" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.10", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/src-tauri/core/Cargo.toml b/src-tauri/core/Cargo.toml new file mode 100644 index 0000000..5dede69 --- /dev/null +++ b/src-tauri/core/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "fasttask-core" +version = "1.4.1" +edition = "2021" +description = "FastTask local data API" +publish = false + +[dependencies] +chrono = "0.4.44" +regex = "1.12" +serde_json = "1" +tiny_http = "0.12" +url = "2.5" +uuid = { version = "1.23", features = ["v4"] } + +[[bin]] +name = "fasttask-api" +path = "src/bin/fasttask-api.rs" diff --git a/src-tauri/core/src/bin/fasttask-api.rs b/src-tauri/core/src/bin/fasttask-api.rs new file mode 100644 index 0000000..ab6d741 --- /dev/null +++ b/src-tauri/core/src/bin/fasttask-api.rs @@ -0,0 +1,39 @@ +//! 画面なしで data.json API を立てる。アプリ本体と同じルーティング。 +//! Google への push とタスクリスト取得はアプリプロセス内だけで動く。 +use std::env; +use std::io::Read; +use std::path::PathBuf; + +fn main() { + let dir = PathBuf::from(env::var("FASTTASK_DIR").expect("FASTTASK_DIR is required")); + let token = env::var("FASTTASK_TOKEN").expect("FASTTASK_TOKEN is required"); + let port: u16 = env::var("FASTTASK_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .expect("FASTTASK_PORT is required"); + let server = tiny_http::Server::http(("127.0.0.1", port)).expect("bind"); + eprintln!("fasttask-api listening"); + for mut req in server.incoming_requests() { + let method = req.method().as_str().to_string(); + let url = req.url().to_string(); + let auth = req + .headers() + .iter() + .find(|h| h.field.equiv("Authorization")) + .map(|h| h.value.as_str().to_string()); + let mut body = String::new(); + if matches!(method.as_str(), "POST" | "PATCH" | "PUT") { + let _ = req + .as_reader() + .take((fasttask_core::MAX_BODY as u64) + 1) + .read_to_string(&mut body); + } + let res = fasttask_core::handle(&dir, &token, &method, &url, auth.as_deref(), &body); + let header = + tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(); + let response = tiny_http::Response::from_string(res.body.to_string()) + .with_status_code(res.status) + .with_header(header); + let _ = req.respond(response); + } +} diff --git a/src-tauri/core/src/dispatch.rs b/src-tauri/core/src/dispatch.rs new file mode 100644 index 0000000..4299a44 --- /dev/null +++ b/src-tauri/core/src/dispatch.rs @@ -0,0 +1,262 @@ +use std::path::Path; + +use serde_json::{json, Value}; + +use crate::google_file; +use crate::model::{err, load, parse_object, percent_decode, put_tags, query_pairs, save, tags_of, tasks_of, ApiResult, Change, Outcome}; +use crate::settings::{add_focus, effective_settings, patch_settings}; +use crate::tags::{apply_keywords, string_list}; +use crate::tasks::{ + clear_data, create_batch, create_task, delete_ids, get_task, list_tasks, reorder_projects, + reorder_tasks, set_status, update_task, +}; +use crate::view::{daily_result, export_markdown}; + +pub fn dispatch(dir: &Path, method: &str, path: &str, query: &str, body: &str) -> ApiResult { + let segments: Vec = path + .trim_matches('/') + .split('/') + .filter(|segment| !segment.is_empty()) + .map(|segment| percent_decode(segment)) + .collect(); + let query = query_pairs(query); + let segment_refs: Vec<&str> = segments.iter().map(String::as_str).collect(); + match (method, segment_refs.as_slice()) { + ("GET", ["tasks"]) => read(dir, |data| list_tasks(data, &query)), + ("POST", ["tasks"]) => write(dir, |data| create_task(data, &parse_object(body)?)), + ("POST", ["tasks", "batch"]) => write(dir, |data| create_batch(data, &parse_object(body)?)), + ("POST", ["tasks", "reorder"]) => write(dir, |data| { + let body = parse_object(body)?; + let ids = string_ids(&body, "ids")?; + reorder_tasks(data, &ids) + }), + ("POST", ["tasks", "status"]) => write(dir, |data| set_status(data, &parse_object(body)?)), + ("POST", ["tasks", "delete"]) => write(dir, |data| { + let body = parse_object(body)?; + let ids = ids_from(&body)?; + delete_ids(data, &ids) + }), + ("GET", ["tasks", id]) => { + let id = (*id).to_string(); + read(dir, |data| get_task(data, &id)) + } + ("PATCH", ["tasks", id]) => { + let id = (*id).to_string(); + write(dir, |data| update_task(data, &id, &parse_object(body)?)) + } + ("DELETE", ["tasks", id]) => { + let id = (*id).to_string(); + write(dir, |data| delete_ids(data, &[id])) + } + ("POST", ["projects", "reorder"]) => write(dir, |data| { + let body = parse_object(body)?; + let projects = project_order(&body)?; + reorder_projects(data, &projects) + }), + ("GET", ["tags"]) => read(dir, |data| Ok(json!({ "tags": tags_of(data) }))), + ("POST", ["tags"]) => write(dir, |data| save_tag(data, None, &parse_object(body)?)), + ("PATCH", ["tags", id]) => { + let id = (*id).to_string(); + write(dir, |data| save_tag(data, Some(&id), &parse_object(body)?)) + } + ("DELETE", ["tags", id]) => { + let id = (*id).to_string(); + write(dir, |data| delete_tag(data, &id)) + } + ("GET", ["settings"]) => read(dir, |data| Ok(effective_settings(data))), + ("PATCH", ["settings"]) => write(dir, |data| patch_settings(data, &parse_object(body)?)), + ("GET", ["export"]) => read(dir, |data| export_markdown(data, &query)), + ("GET", ["result"]) => read(dir, |data| daily_result(data, &query)), + ("POST", ["focus"]) => write(dir, |data| { + let body = parse_object(body)?; + let minutes = body + .get("minutes") + .and_then(Value::as_f64) + .ok_or_else(|| err(400, "minutes must be a number"))?; + let date = body + .get("date") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(crate::model::local_today); + add_focus(data, minutes, &date) + }), + ("POST", ["data", "clear"]) => write(dir, |data| clear_data(data, &parse_object(body)?)), + ("GET", ["google"]) => { + let body = google_file::public_config(dir)?; + Ok(Outcome { + body, + changed: Change::None, + }) + } + ("PATCH", ["google"]) => { + let body = google_file::patch_config(dir, &parse_object(body)?)?; + Ok(Outcome { + body, + changed: Change::Google, + }) + } + ("POST", ["google", "push"]) => Err(err(501, "google push runs inside the FastTask app")), + ("GET", ["google", "tasklists"]) => { + Err(err(501, "google task lists are available inside the FastTask app")) + } + _ => Err(err(404, "not found")), + } +} + +fn read(dir: &Path, run: impl FnOnce(&Value) -> ApiResult) -> ApiResult { + let data = load(dir)?; + Ok(Outcome { + body: run(&data)?, + changed: Change::None, + }) +} + +fn write(dir: &Path, run: impl FnOnce(&mut Value) -> ApiResult) -> ApiResult { + let mut data = load(dir)?; + let body = run(&mut data)?; + if data.get("version").and_then(Value::as_str).is_none() { + data["version"] = json!("1"); + } + save(dir, &data)?; + Ok(Outcome { + body, + changed: Change::Data, + }) +} + +fn string_ids(body: &Value, field: &str) -> ApiResult> { + string_list(body, field) +} + +fn ids_from(body: &Value) -> ApiResult> { + let mut ids = Vec::new(); + if let Some(id) = body.get("id").and_then(Value::as_str) { + ids.push(id.to_string()); + } + if body.get("ids").is_some() { + ids.extend(string_list(body, "ids")?); + } + if ids.is_empty() { + return Err(err(400, "id or ids is required")); + } + Ok(ids) +} + +fn project_order(body: &Value) -> ApiResult>> { + let Some(items) = body.get("projects").and_then(Value::as_array) else { + return Err(err(400, "projects must be an array")); + }; + let mut projects = Vec::new(); + for item in items { + match item { + Value::Null => projects.push(None), + Value::String(name) => { + let name = name.trim(); + if name.is_empty() || name == "none" || name == "__none__" { + projects.push(None); + } else { + projects.push(Some(name.to_string())); + } + } + _ => return Err(err(400, "projects must be strings or null")), + } + } + Ok(projects) +} + +fn id_of_tag(tag: &Value) -> Option<&str> { + tag.get("id").and_then(Value::as_str).filter(|id| !id.is_empty()) +} + +fn save_tag(data: &mut Value, id: Option<&str>, body: &Value) -> ApiResult { + let mut tags = tags_of(data); + let mut tasks = tasks_of(data); + let existing = id.and_then(|id| tags.iter().position(|tag| id_of_tag(tag) == Some(id))); + if id.is_some() && existing.is_none() { + return Err(err(404, "tag not found")); + } + let current = existing.map(|index| tags[index].clone()); + let name = match body.get("name") { + Some(Value::String(name)) => { + let name = name.trim(); + if name.is_empty() || name.chars().count() > 100 { + return Err(err(400, "name must be a non-empty string")); + } + name.to_string() + } + Some(_) => return Err(err(400, "name must be a non-empty string")), + None => current + .as_ref() + .and_then(|tag| tag.get("name").and_then(Value::as_str)) + .ok_or_else(|| err(400, "name is required"))? + .to_string(), + }; + let color = match body.get("color") { + Some(Value::String(color)) => { + let color = color.trim(); + if color.is_empty() || color.chars().count() > 32 { + return Err(err(400, "color is invalid")); + } + color.to_string() + } + Some(_) => return Err(err(400, "color must be a string")), + None => current + .as_ref() + .and_then(|tag| tag.get("color").and_then(Value::as_str)) + .unwrap_or("#8E8E93") + .to_string(), + }; + let keywords = if body.get("keywords").is_some() { + string_list(body, "keywords")? + } else { + current + .as_ref() + .and_then(|tag| tag.get("keywords")) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() + }; + let id = id + .map(str::to_string) + .or_else(|| current.as_ref().and_then(|tag| id_of_tag(tag).map(str::to_string))) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let tag = json!({ + "id": id, + "name": name, + "color": color, + "keywords": keywords, + }); + if let Some(index) = existing { + tags[index] = tag.clone(); + } else { + tags.push(tag.clone()); + } + apply_keywords(&mut tasks, &tag); + put_tags(data, tags); + crate::model::put_tasks(data, tasks); + Ok(tag) +} + +fn delete_tag(data: &mut Value, id: &str) -> ApiResult { + crate::model::require_id(id)?; + let mut tags = tags_of(data); + let before = tags.len(); + tags.retain(|tag| id_of_tag(tag) != Some(id)); + if tags.len() == before { + return Err(err(404, "tag not found")); + } + let mut tasks = tasks_of(data); + for task in &mut tasks { + if let Some(items) = task.get_mut("tags").and_then(|value| value.as_array_mut()) { + items.retain(|item| item.as_str() != Some(id)); + } + } + put_tags(data, tags); + crate::model::put_tasks(data, tasks); + Ok(json!({ "deleted": id })) +} diff --git a/src-tauri/core/src/google_file.rs b/src-tauri/core/src/google_file.rs new file mode 100644 index 0000000..f3ee805 --- /dev/null +++ b/src-tauri/core/src/google_file.rs @@ -0,0 +1,164 @@ +//! google.json の公開ビュー。client_secret と refresh_token は読み書きの応答に出さない。 +use std::fs; +use std::path::Path; + +use serde_json::{json, Value}; + +use crate::model::{err, ApiResult}; + +const SECRET_KEYS: &[&str] = &[ + "clientSecret", + "client_secret", + "refreshToken", + "refresh_token", + "accessToken", + "access_token", + "token", +]; + +pub fn public_config(dir: &Path) -> ApiResult { + let stored = read_raw(dir)?; + Ok(public_view(&stored)) +} + +pub fn patch_config(dir: &Path, patch: &Value) -> ApiResult { + let Some(patch) = patch.as_object() else { + return Err(err(400, "body must be a JSON object")); + }; + if patch.is_empty() { + return Err(err(400, "google patch is empty")); + } + for key in SECRET_KEYS { + if patch.contains_key(*key) { + return Err(err( + 400, + "client secrets and tokens are set in the FastTask settings window", + )); + } + } + let mut stored = read_raw(dir)?; + let object = stored + .as_object_mut() + .ok_or_else(|| err(500, "google config must be an object"))?; + for (key, value) in patch { + match key.as_str() { + "autoPush" => { + object.insert("auto_push".into(), json!(expect_bool(value, "autoPush")?)); + } + "pushCalendar" => { + object.insert("push_calendar".into(), json!(expect_bool(value, "pushCalendar")?)); + } + "pushTasks" => { + object.insert("push_tasks".into(), json!(expect_bool(value, "pushTasks")?)); + } + "calendarId" => { + let text = expect_string(value, "calendarId")?; + object.insert( + "calendar_id".into(), + json!(if text.is_empty() { "primary".to_string() } else { text }), + ); + } + "tasklistId" => { + let text = expect_string(value, "tasklistId")?; + object.insert( + "tasklist_id".into(), + json!(if text.is_empty() { "@default".to_string() } else { text }), + ); + } + "clientId" => { + object.insert("client_id".into(), json!(expect_string(value, "clientId")?)); + } + "email" => return Err(err(400, "email is set when FastTask connects to Google")), + _ => return Err(err(400, format!("unknown google field: {key}"))), + } + } + write_raw(dir, &stored)?; + Ok(public_view(&stored)) +} + +fn expect_bool(value: &Value, field: &str) -> ApiResult { + value + .as_bool() + .ok_or_else(|| err(400, format!("{field} must be a boolean"))) +} + +fn expect_string(value: &Value, field: &str) -> ApiResult { + value + .as_str() + .map(str::trim) + .map(str::to_string) + .ok_or_else(|| err(400, format!("{field} must be a string"))) +} + +fn read_raw(dir: &Path) -> ApiResult { + let path = dir.join("google.json"); + let mut value = defaults(); + if !path.exists() { + return Ok(value); + } + let text = fs::read_to_string(&path).map_err(|e| err(500, format!("read error: {e}")))?; + let parsed: Value = + serde_json::from_str(&text).map_err(|e| err(500, format!("parse error: {e}")))?; + let Some(parsed) = parsed.as_object() else { + return Err(err(500, "google.json must be an object")); + }; + let object = value.as_object_mut().unwrap(); + for (key, item) in parsed { + object.insert(key.clone(), item.clone()); + } + Ok(value) +} + +fn defaults() -> Value { + json!({ + "client_id": "", + "client_secret": "", + "refresh_token": "", + "email": "", + "calendar_id": "primary", + "tasklist_id": "@default", + "auto_push": false, + "push_calendar": true, + "push_tasks": true + }) +} + +fn public_view(stored: &Value) -> Value { + let text = |key: &str| stored.get(key).and_then(Value::as_str).unwrap_or("").to_string(); + let flag = |key: &str, default: bool| stored.get(key).and_then(Value::as_bool).unwrap_or(default); + let secret = text("client_secret"); + let refresh = text("refresh_token"); + json!({ + "connected": !refresh.is_empty(), + "hasSecret": !secret.is_empty(), + "clientId": text("client_id"), + "email": text("email"), + "calendarId": if text("calendar_id").is_empty() { "primary".into() } else { text("calendar_id") }, + "tasklistId": if text("tasklist_id").is_empty() { "@default".into() } else { text("tasklist_id") }, + "autoPush": flag("auto_push", false), + "pushCalendar": flag("push_calendar", true), + "pushTasks": flag("push_tasks", true), + }) +} + +fn write_raw(dir: &Path, value: &Value) -> ApiResult<()> { + fs::create_dir_all(dir).map_err(|e| err(500, format!("mkdir error: {e}")))?; + let tmp = dir.join("google.json.tmp"); + let text = serde_json::to_string_pretty(value).map_err(|e| err(500, format!("serialize: {e}")))?; + fs::write(&tmp, text).map_err(|e| err(500, format!("write error: {e}")))?; + restrict(&tmp)?; + fs::rename(&tmp, dir.join("google.json")).map_err(|e| err(500, format!("rename error: {e}")))?; + Ok(()) +} + +#[cfg(unix)] +fn restrict(path: &Path) -> ApiResult<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .map_err(|e| err(500, format!("chmod error: {e}"))) +} + +#[cfg(not(unix))] +fn restrict(_path: &Path) -> ApiResult<()> { + Ok(()) +} diff --git a/src-tauri/core/src/lib.rs b/src-tauri/core/src/lib.rs new file mode 100644 index 0000000..2cae95e --- /dev/null +++ b/src-tauri/core/src/lib.rs @@ -0,0 +1,79 @@ +//! FastTask の data.json を読み書きするローカル API。 +//! Tauri アプリの HTTP サーバと、テスト用バイナリの両方がここを呼ぶ。 +//! 呼び出し側は同時に一つのリクエストだけを通すこと(アプリ側は Mutex)。 + +mod dispatch; +mod google_file; +mod model; +mod parse; +mod settings; +mod tags; +mod tasks; +mod view; + +use std::path::Path; + +use serde_json::{json, Value}; + +pub use dispatch::dispatch; +pub use model::{local_today, Change, Outcome}; + +pub const MAX_BODY: usize = 1 << 20; + +#[derive(Debug)] +pub struct HttpResponse { + pub status: u16, + pub body: Value, + pub changed: Change, +} + +/// 認証・ボディ上限・ルーティングまで含めた 1 リクエスト分。 +pub fn handle( + dir: &Path, + token: &str, + method: &str, + url: &str, + authorization: Option<&str>, + body: &str, +) -> HttpResponse { + if !bearer_ok(token, authorization) { + return HttpResponse { + status: 401, + body: json!({ "error": "unauthorized" }), + changed: Change::None, + }; + } + if body.len() > MAX_BODY { + return HttpResponse { + status: 413, + body: json!({ "error": "body too large" }), + changed: Change::None, + }; + } + let (path, query) = split_url(url); + match dispatch(dir, method, &path, &query, body) { + Ok(outcome) => HttpResponse { + status: 200, + body: outcome.body, + changed: outcome.changed, + }, + Err((status, msg)) => HttpResponse { + status, + body: json!({ "error": msg }), + changed: Change::None, + }, + } +} + +fn bearer_ok(token: &str, authorization: Option<&str>) -> bool { + authorization + .and_then(|h| h.strip_prefix("Bearer ")) + .is_some_and(|got| !token.is_empty() && got == token) +} + +fn split_url(url: &str) -> (String, String) { + match url.split_once('?') { + Some((path, query)) => (path.to_string(), query.to_string()), + None => (url.to_string(), String::new()), + } +} diff --git a/src-tauri/core/src/model.rs b/src-tauri/core/src/model.rs new file mode 100644 index 0000000..34c9620 --- /dev/null +++ b/src-tauri/core/src/model.rs @@ -0,0 +1,285 @@ +use std::collections::HashSet; +use std::fs; +use std::path::Path; + +use serde_json::{json, Map, Value}; + +pub type ApiError = (u16, String); +pub type ApiResult = Result; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Change { + None, + Data, + Google, +} + +#[derive(Debug)] +pub struct Outcome { + pub body: Value, + pub changed: Change, +} + +pub fn err(code: u16, msg: impl Into) -> ApiError { + (code, msg.into()) +} + +pub fn load(dir: &Path) -> ApiResult { + let path = dir.join("data.json"); + if !path.exists() { + return Ok(json!({ "tasks": [], "tags": [], "version": "1" })); + } + let text = fs::read_to_string(&path).map_err(|e| err(500, format!("read error: {e}")))?; + let value: Value = + serde_json::from_str(&text).map_err(|e| err(500, format!("parse error: {e}")))?; + if !value.is_object() { + return Err(err(500, "data.json must be an object")); + } + Ok(value) +} + +pub fn save(dir: &Path, data: &Value) -> ApiResult<()> { + fs::create_dir_all(dir).map_err(|e| err(500, format!("mkdir error: {e}")))?; + let tmp = dir.join("data.json.tmp"); + let text = + serde_json::to_string_pretty(data).map_err(|e| err(500, format!("serialize: {e}")))?; + fs::write(&tmp, text).map_err(|e| err(500, format!("write error: {e}")))?; + fs::rename(&tmp, dir.join("data.json")).map_err(|e| err(500, format!("rename error: {e}")))?; + Ok(()) +} + +pub fn tasks_of(data: &Value) -> Vec { + data.get("tasks") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() +} + +pub fn tags_of(data: &Value) -> Vec { + data.get("tags") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() +} + +pub fn put_tasks(data: &mut Value, tasks: Vec) { + data["tasks"] = Value::Array(tasks); +} + +pub fn put_tags(data: &mut Value, tags: Vec) { + data["tags"] = Value::Array(tags); +} + +pub fn now_rfc3339() -> String { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + +pub fn local_today() -> String { + chrono::Local::now().format("%Y-%m-%d").to_string() +} + +pub fn id_of(task: &Value) -> Option<&str> { + task.get("id").and_then(Value::as_str).filter(|s| !s.is_empty()) +} + +pub fn str_field<'a>(task: &'a Value, key: &str) -> Option<&'a str> { + task.get(key) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) +} + +pub fn status_of(task: &Value) -> &str { + task.get("status").and_then(Value::as_str).unwrap_or("") +} + +pub fn order_of(task: &Value) -> i64 { + task.get("order").and_then(Value::as_i64).unwrap_or(0) +} + +pub fn body_of(task: &Value) -> String { + let title = task.get("title").and_then(Value::as_str).unwrap_or(""); + if let Some(project) = str_field(task, "projectName") { + let prefix = format!(":{project} "); + if let Some(rest) = title.strip_prefix(&prefix) { + return rest.to_string(); + } + } + title.to_string() +} + +pub fn find_index(tasks: &[Value], id: &str) -> Option { + tasks.iter().position(|task| id_of(task) == Some(id)) +} + +pub fn require_id(id: &str) -> ApiResult<()> { + if id.is_empty() || id.len() > 200 || id.contains('/') || id == "." || id == ".." { + return Err(err(400, "invalid id")); + } + Ok(()) +} + +pub fn parse_object(body: &str) -> ApiResult { + let value: Value = + serde_json::from_str(body).map_err(|e| err(400, format!("invalid json: {e}")))?; + if !value.is_object() { + return Err(err(400, "body must be a JSON object")); + } + Ok(value) +} + +pub fn object_mut(value: &mut Value) -> ApiResult<&mut Map> { + value + .as_object_mut() + .ok_or_else(|| err(500, "expected object")) +} + +pub fn descendants(tasks: &[Value], id: &str) -> Vec { + let mut result = Vec::new(); + let mut queue = vec![id.to_string()]; + let mut seen = HashSet::new(); + seen.insert(id.to_string()); + while let Some(current) = queue.pop() { + for task in tasks { + if task.get("parentId").and_then(Value::as_str) == Some(current.as_str()) { + if let Some(child) = id_of(task) { + if seen.insert(child.to_string()) { + result.push(child.to_string()); + queue.push(child.to_string()); + } + } + } + } + } + result +} + +pub fn depth(tasks: &[Value], id: &str) -> usize { + let mut depth = 0usize; + let mut current = id.to_string(); + let mut seen = HashSet::new(); + while seen.insert(current.clone()) { + let Some(task) = tasks.iter().find(|task| id_of(task) == Some(current.as_str())) else { + break; + }; + let Some(parent) = task + .get("parentId") + .and_then(Value::as_str) + .filter(|parent| !parent.is_empty()) + else { + break; + }; + if tasks.iter().all(|task| id_of(task) != Some(parent)) { + break; + } + depth += 1; + current = parent.to_string(); + } + depth +} + +pub fn is_must(task: &Value, today: &str) -> bool { + if task.get("isMinimum").and_then(Value::as_bool) == Some(true) { + return true; + } + status_of(task) == "todo" && task.get("dueDate").and_then(Value::as_str) == Some(today) +} + +pub fn bool_flag(data: &Value, key: &str, default: bool) -> bool { + data.get("settings") + .and_then(|settings| settings.get(key)) + .and_then(Value::as_bool) + .unwrap_or(default) +} + +pub fn percent_decode(input: &str) -> String { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + if let Ok(byte) = u8::from_str_radix( + std::str::from_utf8(&bytes[index + 1..index + 3]).unwrap_or(""), + 16, + ) { + out.push(byte); + index += 3; + continue; + } + } + out.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +pub fn query_pairs(query: &str) -> Vec<(String, String)> { + url::form_urlencoded::parse(query.as_bytes()) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect() +} + +pub fn next_todo_order(tasks: &[Value]) -> i64 { + tasks + .iter() + .filter(|task| status_of(task) == "todo") + .map(order_of) + .max() + .map_or(0, |max| max + 1) +} + +/// 未完了タスクを画面と同じ親子順に並べ、order を振り直す。 +pub fn reassign_todo_order(tasks: &mut [Value]) { + let todos: Vec = tasks + .iter() + .filter(|task| status_of(task) == "todo") + .cloned() + .collect(); + let ordered = dfs_ids(&todos); + for (index, id) in ordered.into_iter().enumerate() { + if let Some(task) = tasks.iter_mut().find(|task| id_of(task) == Some(id.as_str())) { + task["order"] = json!(index as i64); + } + } +} + +pub fn dfs_ids(tasks: &[Value]) -> Vec { + let ids: HashSet = tasks.iter().filter_map(|task| id_of(task).map(str::to_string)).collect(); + let mut groups: Vec<(Option, Vec<(i64, String)>)> = Vec::new(); + for task in tasks { + let Some(id) = id_of(task) else { continue }; + let parent = task + .get("parentId") + .and_then(Value::as_str) + .filter(|parent| ids.contains(*parent)) + .map(str::to_string); + let entry = (order_of(task), id.to_string()); + if let Some((_, children)) = groups.iter_mut().find(|(key, _)| key == &parent) { + children.push(entry); + } else { + groups.push((parent, vec![entry])); + } + } + for (_, children) in &mut groups { + children.sort_by_key(|(order, _)| *order); + } + let mut result = Vec::new(); + fn walk( + parent: Option<&str>, + groups: &[(Option, Vec<(i64, String)>)], + result: &mut Vec, + ) { + let Some((_, children)) = groups.iter().find(|(key, _)| match (key, parent) { + (None, None) => true, + (Some(key), Some(parent)) => key == parent, + _ => false, + }) else { + return; + }; + for (_, id) in children { + result.push(id.clone()); + walk(Some(id), groups, result); + } + } + walk(None, &groups, &mut result); + result +} diff --git a/src-tauri/core/src/parse.rs b/src-tauri/core/src/parse.rs new file mode 100644 index 0000000..c8a9c4e --- /dev/null +++ b/src-tauri/core/src/parse.rs @@ -0,0 +1,360 @@ +//! 入力欄と同じ解釈。`:project 本文`、URL、自然言語の日付、カンマ区切り。 +use chrono::{Datelike, Duration, NaiveDate, Weekday}; +use regex::Regex; +use std::sync::OnceLock; + +pub fn parse_project(input: &str) -> (Option, String) { + if !input.starts_with(':') { + return (None, input.to_string()); + } + let rest = &input[1..]; + match rest.find(char::is_whitespace) { + None => { + if rest.is_empty() { + (None, String::new()) + } else { + (Some(rest.to_string()), String::new()) + } + } + Some(index) => { + let name = &rest[..index]; + let body = rest[index + 1..].to_string(); + if name.is_empty() { + (None, body) + } else { + (Some(name.to_string()), body) + } + } + } +} + +pub fn extract_url(input: &str) -> (String, Option) { + let Some((start, end)) = find_url(input) else { + return (input.to_string(), None); + }; + let url = input[start..end].to_string(); + let mut text = String::new(); + text.push_str(&input[..start]); + text.push_str(&input[end..]); + let text = collapse_ws(&text); + (text, Some(url)) +} + +fn find_url(input: &str) -> Option<(usize, usize)> { + let mut best: Option = None; + for prefix in ["https://", "http://", "file://"] { + if let Some(index) = input.find(prefix) { + best = Some(best.map_or(index, |current| current.min(index))); + } + } + let start = best?; + let rest = &input[start..]; + let end = rest + .find(char::is_whitespace) + .map(|index| start + index) + .unwrap_or(input.len()); + Some((start, end)) +} + +fn collapse_ws(input: &str) -> String { + let mut out = String::new(); + let mut pending = false; + for ch in input.chars() { + if ch.is_whitespace() { + pending = !out.is_empty(); + } else { + if pending { + out.push(' '); + pending = false; + } + out.push(ch); + } + } + out +} + +/// 日付を取り除いた本文と、見つかった日付。画面のパターン順に合わせてある。 +pub fn parse_date_in(input: &str, today: NaiveDate) -> Option<(NaiveDate, String, String)> { + if let Some(found) = match_re(today_re(), input, today, |_, base| Some(base)) { + return Some(found); + } + if let Some(found) = match_re(tomorrow_re(), input, today, |_, base| Some(base + Duration::days(1))) { + return Some(found); + } + if let Some(found) = match_re(day_after_re(), input, today, |_, base| Some(base + Duration::days(2))) { + return Some(found); + } + if let Some(found) = match_re(in_days_re(), input, today, |caps, base| { + let days: i64 = caps.get(1)?.as_str().parse().ok()?; + Some(base + Duration::days(days)) + }) { + return Some(found); + } + if let Some(found) = match_re(ja_weekday_re(), input, today, |caps, base| { + let kanji = caps.get(1)?.as_str().chars().next()?; + let index = "日月火水木金土".find(kanji)? / 3; + weekday_from_sunday(index).map(|day| next_weekday(base, day)) + }) { + return Some(found); + } + if let Some(found) = match_re(en_weekday_re(), input, today, |caps, base| { + let short = caps.get(1)?.as_str().get(..3)?.to_lowercase(); + let index = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] + .iter() + .position(|name| *name == short)?; + weekday_from_sunday(index).map(|day| next_weekday(base, day)) + }) { + return Some(found); + } + if let Some(found) = match_re(ymd_re(), input, today, |caps, _| { + ymd( + caps.get(1)?.as_str().parse().ok()?, + caps.get(2)?.as_str().parse().ok()?, + caps.get(3)?.as_str().parse().ok()?, + ) + }) { + return Some(found); + } + if let Some(found) = match_re(jp_md_re(), input, today, |caps, base| { + let month: u32 = caps.get(1)?.as_str().parse().ok()?; + let day: u32 = caps.get(2)?.as_str().parse().ok()?; + ymd(base.year(), month, day).map(|date| roll_forward(date, base)) + }) { + return Some(found); + } + find_md(input, today) +} + +fn match_re( + re: &Regex, + input: &str, + today: NaiveDate, + resolve: impl Fn(®ex::Captures, NaiveDate) -> Option, +) -> Option<(NaiveDate, String, String)> { + let caps = re.captures(input)?; + let date = resolve(&caps, today)?; + let matched = caps.get(0)?.as_str().to_string(); + let text = remove_once(input, &matched); + Some((date, text, matched)) +} + +fn find_md(input: &str, today: NaiveDate) -> Option<(NaiveDate, String, String)> { + let re = md_re(); + for caps in re.captures_iter(input) { + let whole = caps.get(0)?; + let before = whole.start(); + let after = whole.end(); + let prev_digit = before > 0 && input.as_bytes().get(before - 1).is_some_and(|b| b.is_ascii_digit()); + let next_digit = input.as_bytes().get(after).is_some_and(|b| b.is_ascii_digit()); + if prev_digit || next_digit { + continue; + } + let month: u32 = caps.get(1)?.as_str().parse().ok()?; + let day: u32 = caps.get(2)?.as_str().parse().ok()?; + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + continue; + } + let date = ymd(today.year(), month, day)?; + let matched = whole.as_str().to_string(); + return Some((roll_forward(date, today), remove_once(input, &matched), matched)); + } + None +} + +fn ymd(year: i32, month: u32, day: u32) -> Option { + NaiveDate::from_ymd_opt(year, month, day) +} + +fn roll_forward(date: NaiveDate, today: NaiveDate) -> NaiveDate { + if date < today { + NaiveDate::from_ymd_opt(date.year() + 1, date.month(), date.day()).unwrap_or(date) + } else { + date + } +} + +fn weekday_from_sunday(index: usize) -> Option { + [ + Weekday::Sun, + Weekday::Mon, + Weekday::Tue, + Weekday::Wed, + Weekday::Thu, + Weekday::Fri, + Weekday::Sat, + ] + .get(index) + .copied() +} + +fn next_weekday(base: NaiveDate, target: Weekday) -> NaiveDate { + let current = base.weekday().num_days_from_sunday() as i64; + let wanted = target.num_days_from_sunday() as i64; + let mut diff = wanted - current; + if diff <= 0 { + diff += 7; + } + base + Duration::days(diff) +} + +fn remove_once(input: &str, matched: &str) -> String { + let Some(index) = input.find(matched) else { + return collapse_ws(input.trim()); + }; + let mut text = String::new(); + text.push_str(&input[..index]); + text.push_str(&input[index + matched.len()..]); + collapse_ws(text.trim()) +} + +fn today_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"今日|today").unwrap()) +} +fn tomorrow_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"(?i)明日|あした|tomorrow").unwrap()) +} +fn day_after_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"明後日").unwrap()) +} +fn in_days_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"(\d+)日後").unwrap()) +} +fn ja_weekday_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"([月火水木金土日])曜日?").unwrap()) +} +fn en_weekday_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"(?i)\b(sun(?:day)?|mon(?:day)?|tue(?:sday)?|wed(?:nesday)?|thu(?:rsday)?|fri(?:day)?|sat(?:urday)?)\b").unwrap() + }) +} +fn ymd_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"(\d{4})[/\-](\d{1,2})[/\-](\d{1,2})").unwrap()) +} +fn jp_md_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"(\d{1,2})月(\d{1,2})日").unwrap()) +} +fn md_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"(\d{1,2})[/\-](\d{1,2})").unwrap()) +} + +pub struct PreparedLine { + pub text: String, + pub due: Option, +} + +/// 日付を本文から外し、`:project` を付け直す。カンマ分割の前に一度だけ行う。 +pub fn prepare_line(raw: &str, today: NaiveDate) -> PreparedLine { + let (project, body) = parse_project(raw); + if let Some((date, without, _)) = parse_date_in(&body, today) { + let text = match project { + Some(name) => format!(":{name} {without}"), + None => without, + }; + PreparedLine { + text, + due: Some(date.format("%Y-%m-%d").to_string()), + } + } else { + PreparedLine { + text: raw.to_string(), + due: None, + } + } +} + +pub fn split_tasks(input: &str) -> Vec { + let parts: Vec = input + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(str::to_string) + .collect(); + if parts.is_empty() { + return Vec::new(); + } + let (first_project, _) = parse_project(&parts[0]); + parts + .into_iter() + .enumerate() + .map(|(index, part)| { + if index > 0 { + if let Some(project) = &first_project { + if !part.starts_with(':') { + return format!(":{project} {part}"); + } + } + } + part + }) + .collect() +} + +pub struct Materialized { + pub title: String, + pub project: Option, + pub url: Option, + pub body: String, +} + +pub fn materialize(part: &str, url_enabled: bool) -> Materialized { + let (text, url) = if url_enabled { + extract_url(part) + } else { + (part.to_string(), None) + }; + let (project, body) = parse_project(&text); + let title = match &project { + Some(name) => format!(":{name} {body}"), + None => body.clone(), + }; + Materialized { + title, + project, + url, + body, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn day(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).unwrap() + } + + #[test] + fn parses_project_url_and_tomorrow() { + let today = day(2026, 11, 12); + let prepared = prepare_line(":work 資料 tomorrow https://example.com/a", today); + assert_eq!(prepared.due.as_deref(), Some("2026-11-13")); + let item = materialize(&prepared.text, true); + assert_eq!(item.project.as_deref(), Some("work")); + assert_eq!(item.body, "資料"); + assert_eq!(item.url.as_deref(), Some("https://example.com/a")); + assert_eq!(item.title, ":work 資料"); + } + + #[test] + fn comma_split_inherits_project() { + let parts = split_tasks(":work 資料, レビュー"); + assert_eq!(parts, vec![":work 資料".to_string(), ":work レビュー".to_string()]); + } + + #[test] + fn weekday_skips_today() { + // 2026-11-12 is Thursday. Next Thursday is the 19th. + let found = parse_date_in("ship thu", day(2026, 11, 12)).unwrap(); + assert_eq!(found.0, day(2026, 11, 19)); + assert_eq!(found.1, "ship"); + } +} diff --git a/src-tauri/core/src/settings.rs b/src-tauri/core/src/settings.rs new file mode 100644 index 0000000..f801187 --- /dev/null +++ b/src-tauri/core/src/settings.rs @@ -0,0 +1,169 @@ +use serde_json::{json, Map, Value}; + +use crate::model::{bool_flag, err, ApiResult}; + +pub fn effective_settings(data: &Value) -> Value { + let mut settings = defaults(); + if let Some(stored) = data.get("settings").and_then(Value::as_object) { + let object = settings.as_object_mut().unwrap(); + for (key, value) in stored { + object.insert(key.clone(), value.clone()); + } + } + settings +} + +pub fn defaults() -> Value { + json!({ + "autoDeleteOldCompleted": true, + "groupingEnabled": true, + "copyGroupingEnabled": true, + "showDueDate": false, + "tagsEnabled": false, + "urlEnabled": true, + "soundEnabled": false, + "focusShortcut": "", + "lastFocusMinutes": 25, + "theme": "system", + "language": "ja" + }) +} + +pub fn language_of(data: &Value) -> String { + effective_settings(data) + .get("language") + .and_then(Value::as_str) + .unwrap_or("ja") + .to_string() +} + +pub fn url_enabled(data: &Value) -> bool { + bool_flag(data, "urlEnabled", true) +} + +pub fn patch_settings(data: &mut Value, patch: &Value) -> ApiResult { + let Some(patch) = patch.as_object() else { + return Err(err(400, "body must be a JSON object")); + }; + if patch.is_empty() { + return Err(err(400, "settings patch is empty")); + } + let mut stored = data + .get("settings") + .and_then(Value::as_object) + .cloned() + .unwrap_or_else(Map::new); + for (key, value) in patch { + match key.as_str() { + "autoDeleteOldCompleted" + | "groupingEnabled" + | "copyGroupingEnabled" + | "showDueDate" + | "tagsEnabled" + | "urlEnabled" + | "soundEnabled" => { + let flag = value + .as_bool() + .ok_or_else(|| err(400, format!("{key} must be a boolean")))?; + stored.insert(key.clone(), json!(flag)); + } + "theme" => { + let theme = value + .as_str() + .ok_or_else(|| err(400, "theme must be light, dark, or system"))?; + if !matches!(theme, "light" | "dark" | "system") { + return Err(err(400, "theme must be light, dark, or system")); + } + stored.insert(key.clone(), json!(theme)); + } + "language" => { + let language = value + .as_str() + .ok_or_else(|| err(400, "language must be ja or en"))?; + if !matches!(language, "ja" | "en") { + return Err(err(400, "language must be ja or en")); + } + stored.insert(key.clone(), json!(language)); + } + "focusShortcut" => { + let shortcut = value + .as_str() + .ok_or_else(|| err(400, "focusShortcut must be a string"))?; + if shortcut.chars().count() > 100 { + return Err(err(400, "focusShortcut is too long")); + } + stored.insert(key.clone(), json!(shortcut)); + } + "lastFocusMinutes" => { + let minutes = value + .as_f64() + .ok_or_else(|| err(400, "lastFocusMinutes must be a number"))?; + if !(1.0..=120.0).contains(&minutes) || minutes.fract() != 0.0 { + return Err(err(400, "lastFocusMinutes must be an integer from 1 to 120")); + } + stored.insert(key.clone(), json!(minutes as i64)); + } + "focusMinutesByDate" => { + validate_focus_map(value)?; + stored.insert(key.clone(), value.clone()); + } + _ => return Err(err(400, format!("unknown setting: {key}"))), + } + } + data["settings"] = Value::Object(stored); + Ok(effective_settings(data)) +} + +pub fn add_focus(data: &mut Value, minutes: f64, date: &str) -> ApiResult { + if !(minutes > 0.0 && minutes <= 24.0 * 60.0) { + return Err(err(400, "minutes must be greater than 0 and at most 1440")); + } + if !is_iso_day(date) { + return Err(err(400, "date must be YYYY-MM-DD")); + } + let mut stored = data + .get("settings") + .and_then(Value::as_object) + .cloned() + .unwrap_or_else(Map::new); + let mut map = stored + .get("focusMinutesByDate") + .and_then(Value::as_object) + .cloned() + .unwrap_or_else(Map::new); + let prev = map.get(date).and_then(Value::as_f64).unwrap_or(0.0); + map.insert(date.to_string(), json!(prev + minutes)); + let total = prev + minutes; + stored.insert("focusMinutesByDate".into(), Value::Object(map)); + data["settings"] = Value::Object(stored); + Ok(json!({ "date": date, "focusMinutes": total })) +} + +fn validate_focus_map(value: &Value) -> ApiResult<()> { + let Some(map) = value.as_object() else { + return Err(err(400, "focusMinutesByDate must be an object")); + }; + for (day, minutes) in map { + if !is_iso_day(day) { + return Err(err(400, "focusMinutesByDate keys must be YYYY-MM-DD")); + } + let Some(minutes) = minutes.as_f64() else { + return Err(err(400, "focus minutes must be numbers")); + }; + if minutes < 0.0 || minutes > 24.0 * 60.0 * 31.0 { + return Err(err(400, "focus minutes are out of range")); + } + } + Ok(()) +} + +pub fn is_iso_day(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' { + return false; + } + let Ok(year) = value[0..4].parse::() else { return false }; + let Ok(month) = value[5..7].parse::() else { return false }; + let Ok(day) = value[8..10].parse::() else { return false }; + chrono::NaiveDate::from_ymd_opt(year, month, day).is_some() +} diff --git a/src-tauri/core/src/tags.rs b/src-tauri/core/src/tags.rs new file mode 100644 index 0000000..e5200e9 --- /dev/null +++ b/src-tauri/core/src/tags.rs @@ -0,0 +1,129 @@ +use serde_json::{json, Value}; + +use crate::model::{err, id_of, str_field, ApiResult}; + +pub fn resolve_tag(tags: &[Value], raw: &str) -> String { + if tags.iter().any(|tag| id_of(tag) == Some(raw)) { + return raw.to_string(); + } + if let Some(tag) = tags.iter().find(|tag| tag.get("name").and_then(Value::as_str) == Some(raw)) { + if let Some(id) = id_of(tag) { + return id.to_string(); + } + } + let lower = raw.to_lowercase(); + if let Some(tag) = tags.iter().find(|tag| { + tag.get("name") + .and_then(Value::as_str) + .is_some_and(|name| name.to_lowercase() == lower) + }) { + if let Some(id) = id_of(tag) { + return id.to_string(); + } + } + raw.to_string() +} + +pub fn resolve_tags(tags: &[Value], raw: &[Value]) -> ApiResult> { + let mut out = Vec::new(); + for item in raw { + let Some(text) = item.as_str() else { + return Err(err(400, "tags must be an array of strings")); + }; + out.push(json!(resolve_tag(tags, text))); + } + Ok(out) +} + +/// 既存の並びを保ったまま、新しく付いたタグだけ末尾に足す。 +pub fn merge_tags(existing: &[Value], extra: &[String]) -> Vec { + let mut out: Vec = existing + .iter() + .filter_map(|value| value.as_str().map(str::to_string)) + .collect(); + for id in extra { + if !out.iter().any(|current| current == id) { + out.push(id.clone()); + } + } + out.into_iter().map(|id| json!(id)).collect() +} + +pub fn auto_ids(body: &str, tags: &[Value]) -> Vec { + let lower = body.to_lowercase(); + let mut matched = Vec::new(); + for tag in tags { + let Some(id) = id_of(tag) else { continue }; + let Some(keywords) = tag.get("keywords").and_then(Value::as_array) else { + continue; + }; + for keyword in keywords { + let Some(keyword) = keyword.as_str().filter(|word| !word.is_empty()) else { + continue; + }; + if lower.contains(&keyword.to_lowercase()) { + matched.push(id.to_string()); + break; + } + } + } + matched +} + +pub fn apply_keywords(tasks: &mut [Value], tag: &Value) { + let Some(id) = id_of(tag) else { return }; + let Some(keywords) = tag.get("keywords").and_then(Value::as_array) else { + return; + }; + for task in tasks.iter_mut() { + let body = crate::model::body_of(task); + let lower = body.to_lowercase(); + let matches = keywords.iter().any(|keyword| { + keyword + .as_str() + .is_some_and(|word| !word.is_empty() && lower.contains(&word.to_lowercase())) + }); + if !matches { + continue; + } + let mut current = task + .get("tags") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if current.iter().any(|item| item.as_str() == Some(id)) { + continue; + } + current.push(json!(id)); + task["tags"] = Value::Array(current); + } +} + +pub fn string_list(value: &Value, field: &str) -> ApiResult> { + let Some(items) = value.get(field) else { + return Err(err(400, format!("{field} is required"))); + }; + let Some(items) = items.as_array() else { + return Err(err(400, format!("{field} must be an array of strings"))); + }; + let mut out = Vec::new(); + for item in items { + let Some(text) = item.as_str().map(str::trim).filter(|text| !text.is_empty()) else { + return Err(err(400, format!("{field} must be an array of strings"))); + }; + if text.chars().count() > 100 { + return Err(err(400, format!("{field} item is too long"))); + } + out.push(text.to_string()); + } + if out.len() > 50 { + return Err(err(400, format!("too many {field}"))); + } + Ok(out) +} + +pub fn tag_name<'a>(tags: &'a [Value], id: &str) -> Option<&'a str> { + tags.iter() + .find(|tag| id_of(tag) == Some(id)) + .and_then(|tag| str_field(tag, "name")) +} diff --git a/src-tauri/core/src/tasks.rs b/src-tauri/core/src/tasks.rs new file mode 100644 index 0000000..229e635 --- /dev/null +++ b/src-tauri/core/src/tasks.rs @@ -0,0 +1,803 @@ +use std::collections::HashSet; + +use chrono::NaiveDate; +use serde_json::{json, Value}; + +use crate::model::{ + body_of, depth, descendants, err, find_index, id_of, is_must, next_todo_order, now_rfc3339, + object_mut, order_of, put_tasks, reassign_todo_order, require_id, status_of, str_field, + tasks_of, ApiResult, +}; +use crate::parse::{materialize, prepare_line, split_tasks}; +use crate::settings::{is_iso_day, url_enabled}; +use crate::tags::{auto_ids, merge_tags, resolve_tag, resolve_tags, tag_name}; + +pub fn list_tasks(data: &Value, query: &[(String, String)]) -> ApiResult { + let today = crate::model::local_today(); + let tags = crate::model::tags_of(data); + let mut tasks = tasks_of(data); + let mut status: Option = None; + let mut keywords = Vec::new(); + let mut project: Option = None; + let mut tag_ids = Vec::new(); + let mut due: Option = None; + let mut must: Option = None; + let mut pending: Option = None; + let mut parent: Option = None; + let mut ui_order = false; + for (key, value) in query { + match key.as_str() { + "status" => { + if value != "todo" && value != "done" { + return Err(err(400, "status must be todo or done")); + } + status = Some(value.clone()); + } + "q" => { + keywords.extend( + value + .split_whitespace() + .map(|word| word.to_lowercase()) + .filter(|word| !word.is_empty()), + ); + } + "project" => project = Some(value.clone()), + "tag" => tag_ids.push(resolve_tag(&tags, value)), + "due" => { + if !matches!(value.as_str(), "today" | "overdue" | "upcoming" | "none") + && !is_iso_day(value) + { + return Err(err(400, "due must be today, overdue, upcoming, none, or YYYY-MM-DD")); + } + due = Some(value.clone()); + } + "must" => must = Some(parse_bool(value, "must")?), + "pending" => pending = Some(parse_bool(value, "pending")?), + "parent" => parent = Some(value.clone()), + "order" if value == "ui" => ui_order = true, + _ => {} + } + } + tasks.retain(|task| task_matches(task, &tags, &today, status.as_deref(), &keywords, project.as_deref(), &tag_ids, due.as_deref(), must, pending, parent.as_deref())); + if ui_order { + tasks = ui_sorted(tasks); + } + let count = tasks.len(); + Ok(json!({ "tasks": tasks, "count": count })) +} + +fn task_matches( + task: &Value, + tags: &[Value], + today: &str, + status: Option<&str>, + keywords: &[String], + project: Option<&str>, + tag_ids: &[String], + due: Option<&str>, + must: Option, + pending: Option, + parent: Option<&str>, +) -> bool { + if let Some(status) = status { + if status_of(task) != status { + return false; + } + } + if let Some(project) = project { + let current = str_field(task, "projectName"); + let wants_none = project.is_empty() || project == "none" || project == "__none__"; + if wants_none { + if current.is_some() { + return false; + } + } else if current != Some(project) { + return false; + } + } + if !tag_ids.is_empty() { + let owned = task.get("tags").and_then(Value::as_array); + let hit = owned.is_some_and(|items| { + items.iter().any(|item| item.as_str().is_some_and(|id| tag_ids.iter().any(|want| want == id))) + }); + if !hit { + return false; + } + } + if let Some(due) = due { + let value = task.get("dueDate").and_then(Value::as_str).filter(|text| !text.is_empty()); + let ok = match due { + "none" => value.is_none(), + "today" => value == Some(today), + "overdue" => value.is_some_and(|day| day < today) && status_of(task) == "todo", + "upcoming" => value.is_some_and(|day| day >= today), + exact => value == Some(exact), + }; + if !ok { + return false; + } + } + if let Some(must) = must { + if is_must(task, today) != must { + return false; + } + } + if let Some(pending) = pending { + let flag = task.get("isPending").and_then(Value::as_bool).unwrap_or(false); + if flag != pending { + return false; + } + } + if let Some(parent) = parent { + let current = task.get("parentId").and_then(Value::as_str).filter(|id| !id.is_empty()); + if parent == "none" || parent.is_empty() { + if current.is_some() { + return false; + } + } else if current != Some(parent) { + return false; + } + } + if !keywords.is_empty() { + let body = body_of(task).to_lowercase(); + let project = str_field(task, "projectName").unwrap_or("").to_lowercase(); + let tag_text = task + .get("tags") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str()) + .map(|id| tag_name(tags, id).unwrap_or("")) + .collect::>() + .join(" ") + .to_lowercase() + }) + .unwrap_or_default(); + let haystack = format!("{body} {project} {tag_text}"); + if !keywords.iter().all(|word| haystack.contains(word)) { + return false; + } + } + true +} + +fn ui_sorted(tasks: Vec) -> Vec { + let mut todos: Vec = tasks.iter().filter(|task| status_of(task) == "todo").cloned().collect(); + let ids = crate::model::dfs_ids(&todos); + let mut ordered = Vec::new(); + for id in ids { + if let Some(index) = todos.iter().position(|task| id_of(task) == Some(id.as_str())) { + ordered.push(todos.remove(index)); + } + } + ordered.extend(todos); + let mut done: Vec = tasks.into_iter().filter(|task| status_of(task) != "todo").collect(); + done.sort_by(|a, b| { + let left = a.get("completedAt").and_then(Value::as_str).unwrap_or(""); + let right = b.get("completedAt").and_then(Value::as_str).unwrap_or(""); + right.cmp(left) + }); + ordered.extend(done); + ordered +} + +fn parse_bool(value: &str, field: &str) -> ApiResult { + match value { + "true" | "1" => Ok(true), + "false" | "0" => Ok(false), + _ => Err(err(400, format!("{field} must be true or false"))), + } +} + +pub fn get_task(data: &Value, id: &str) -> ApiResult { + require_id(id)?; + tasks_of(data) + .into_iter() + .find(|task| id_of(task) == Some(id)) + .ok_or_else(|| err(404, "task not found")) +} + +pub fn create_task(data: &mut Value, body: &Value) -> ApiResult { + let mut created = create_many(data, std::slice::from_ref(body))?; + created.pop().ok_or_else(|| err(500, "create failed")) +} + +pub fn create_batch(data: &mut Value, body: &Value) -> ApiResult { + let has_text = body.get("text").is_some(); + let has_tasks = body.get("tasks").is_some(); + if has_text == has_tasks { + return Err(err(400, "provide either text or tasks")); + } + let created = if has_text { + create_from_text(data, body)? + } else { + let items = body + .get("tasks") + .and_then(Value::as_array) + .ok_or_else(|| err(400, "tasks must be an array"))?; + if items.len() > 100 { + return Err(err(400, "too many tasks")); + } + create_many(data, items)? + }; + Ok(json!({ "tasks": created })) +} + +fn create_from_text(data: &mut Value, body: &Value) -> ApiResult> { + let text = body + .get("text") + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .ok_or_else(|| err(400, "title is required"))?; + let today = NaiveDate::parse_from_str(&crate::model::local_today(), "%Y-%m-%d") + .map_err(|_| err(500, "bad local date"))?; + let prepared = prepare_line(text, today); + let parts = split_tasks(&prepared.text); + if parts.is_empty() { + return Err(err(400, "title is required")); + } + if parts.len() > 100 { + return Err(err(400, "too many tasks")); + } + let mut items = Vec::new(); + for part in parts { + let mut item = json!({ "title": part, "parseInput": true }); + if let Some(due) = body.get("dueDate") { + item["dueDate"] = due.clone(); + } else if let Some(due) = &prepared.due { + item["dueDate"] = json!(due); + } + for key in ["url", "tags", "parentId", "isMinimum", "isPending", "projectName"] { + if let Some(value) = body.get(key) { + item[key] = value.clone(); + } + } + items.push(item); + } + create_many(data, &items) +} + +fn create_many(data: &mut Value, items: &[Value]) -> ApiResult> { + if items.is_empty() { + return Err(err(400, "title is required")); + } + let tags = crate::model::tags_of(data); + let url_on = url_enabled(data); + let mut tasks = tasks_of(data); + let mut created = Vec::new(); + for item in items { + let task = build_task(item, &tags, url_on, &tasks)?; + if let Some(parent) = item.get("parentId").and_then(Value::as_str).filter(|id| !id.is_empty()) { + check_parent(&tasks, id_of(&task).unwrap_or(""), parent, str_field(&task, "projectName"))?; + } + tasks.push(task.clone()); + created.push(task); + } + if items.iter().any(|item| item.get("parentId").and_then(Value::as_str).is_some()) { + reassign_todo_order(&mut tasks); + for task in &mut created { + if let Some(id) = id_of(task) { + if let Some(stored) = tasks.iter().find(|candidate| id_of(candidate) == Some(id)) { + *task = stored.clone(); + } + } + } + } + put_tasks(data, tasks); + Ok(created) +} + +fn build_task(body: &Value, tags: &[Value], url_on: bool, existing: &[Value]) -> ApiResult { + let parse_input = body.get("parseInput").and_then(Value::as_bool).unwrap_or(false); + let raw_title = body + .get("title") + .and_then(Value::as_str) + .map(str::trim) + .filter(|title| !title.is_empty()) + .ok_or_else(|| err(400, "title is required"))?; + if raw_title.chars().count() > 1000 { + return Err(err(400, "title too long")); + } + let mut title = raw_title.to_string(); + let mut project = body.get("projectName").and_then(Value::as_str).filter(|name| !name.is_empty()).map(str::to_string); + let mut url = match body.get("url") { + None | Some(Value::Null) => None, + Some(Value::String(url)) => Some(url.clone()), + Some(_) => return Err(err(400, "url must be a string")), + }; + let mut due = match body.get("dueDate") { + None | Some(Value::Null) => None, + Some(Value::String(due)) => Some(due.clone()), + Some(_) => return Err(err(400, "dueDate must be a string")), + }; + let explicit_project = body.get("projectName").is_some(); + let explicit_url = body.get("url").is_some(); + let explicit_due = body.get("dueDate").is_some(); + let mut tag_body = title.clone(); + if parse_input { + let today = NaiveDate::parse_from_str(&crate::model::local_today(), "%Y-%m-%d") + .map_err(|_| err(500, "bad local date"))?; + let prepared = prepare_line(raw_title, today); + let item = materialize(&prepared.text, url_on); + title = item.title; + tag_body = item.body; + if !explicit_project { + project = item.project; + } + if !explicit_url { + url = item.url; + } + if !explicit_due { + due = prepared.due; + } + } else if explicit_project { + if let Some(name) = &project { + let body_text = body_of(&json!({ "title": title, "projectName": name })); + let prefix = format!(":{name} "); + if !title.starts_with(&prefix) { + title = format!(":{name} {body_text}"); + } + } + } + if let Some(name) = &project { + if name.chars().count() > 100 || name.contains(char::is_whitespace) { + return Err(err(400, "projectName must be a single token")); + } + } + if title.chars().count() > 1000 { + return Err(err(400, "title too long")); + } + if title.trim().is_empty() { + return Err(err(400, "title is required")); + } + let mut tag_values = match body.get("tags") { + None | Some(Value::Null) => Vec::new(), + Some(Value::Array(items)) => resolve_tags(tags, items)?, + Some(_) => return Err(err(400, "tags must be an array of strings")), + }; + if parse_input { + let auto = auto_ids(&tag_body, tags); + tag_values = merge_tags(&tag_values, &auto); + } + let mut task = json!({ + "id": uuid::Uuid::new_v4().to_string(), + "title": title, + "status": "todo", + "tags": tag_values, + "createdAt": now_rfc3339(), + "order": next_todo_order(existing), + }); + if let Some(project) = project { + task["projectName"] = json!(project); + } + if let Some(url) = url { + task["url"] = json!(url); + } + if let Some(due) = due { + task["dueDate"] = json!(due); + } + if let Some(value) = body.get("isMinimum") { + let flag = value.as_bool().ok_or_else(|| err(400, "isMinimum must be a boolean"))?; + task["isMinimum"] = json!(flag); + } + if let Some(value) = body.get("isPending") { + let flag = value.as_bool().ok_or_else(|| err(400, "isPending must be a boolean"))?; + task["isPending"] = json!(flag); + } + if let Some(parent) = body.get("parentId").and_then(Value::as_str).filter(|id| !id.is_empty()) { + task["parentId"] = json!(parent); + } + Ok(task) +} + +fn check_parent(tasks: &[Value], id: &str, parent: &str, project: Option<&str>) -> ApiResult<()> { + require_id(parent)?; + if parent == id { + return Err(err(400, "task cannot be its own parent")); + } + let Some(parent_task) = tasks.iter().find(|task| id_of(task) == Some(parent)) else { + return Err(err(404, "parent not found")); + }; + if descendants(tasks, id).iter().any(|child| child == parent) { + return Err(err(400, "parent would create a cycle")); + } + let parent_project = str_field(parent_task, "projectName"); + if parent_project != project { + return Err(err(400, "parent must be in the same project")); + } + if depth(tasks, parent) + 1 >= 5 { + return Err(err(400, "max depth reached")); + } + Ok(()) +} + +pub fn update_task(data: &mut Value, id: &str, body: &Value) -> ApiResult { + require_id(id)?; + let tags = crate::model::tags_of(data); + let mut tasks = tasks_of(data); + let index = find_index(&tasks, id).ok_or_else(|| err(404, "task not found"))?; + let parse_title = body.get("parseTitle").and_then(Value::as_bool).unwrap_or(false); + if let Some(value) = body.get("title") { + if parse_title { + apply_parsed_title(&mut tasks[index], value, &tags, url_enabled(data), body)?; + } else { + let title = value + .as_str() + .map(str::trim) + .filter(|title| !title.is_empty() && title.chars().count() <= 1000) + .ok_or_else(|| err(400, "title must be a non-empty string"))?; + tasks[index]["title"] = json!(title); + } + } + if let Some(value) = body.get("status") { + let status = value.as_str().unwrap_or(""); + if status != "todo" && status != "done" { + return Err(err(400, "status must be todo or done")); + } + tasks[index]["status"] = json!(status); + if status == "done" { + tasks[index]["completedAt"] = json!(now_rfc3339()); + } else { + tasks[index]["completedAt"] = Value::Null; + } + } + for key in ["dueDate", "url"] { + match body.get(key) { + None => {} + Some(Value::Null) => tasks[index][key] = Value::Null, + Some(Value::String(text)) => tasks[index][key] = json!(text), + Some(_) => return Err(err(400, format!("{key} must be a string or null"))), + } + } + if let Some(value) = body.get("tags") { + match value { + Value::Array(items) => tasks[index]["tags"] = Value::Array(resolve_tags(&tags, items)?), + _ => return Err(err(400, "tags must be an array of strings")), + } + } + if let Some(value) = body.get("projectName") { + apply_project(&mut tasks[index], value)?; + } + if let Some(value) = body.get("isMinimum") { + let flag = value.as_bool().ok_or_else(|| err(400, "isMinimum must be a boolean"))?; + tasks[index]["isMinimum"] = json!(flag); + cascade_flag(&mut tasks, id, "isMinimum", flag); + } + if let Some(value) = body.get("isPending") { + let flag = value.as_bool().ok_or_else(|| err(400, "isPending must be a boolean"))?; + tasks[index]["isPending"] = json!(flag); + cascade_flag(&mut tasks, id, "isPending", flag); + } + if let Some(value) = body.get("parentId") { + let parent = match value { + Value::Null => None, + Value::String(text) if text.is_empty() => None, + Value::String(text) => Some(text.as_str()), + _ => return Err(err(400, "parentId must be a string or null")), + }; + let project = str_field(&tasks[index], "projectName").map(str::to_string); + if let Some(parent) = parent { + check_parent(&tasks, id, parent, project.as_deref())?; + tasks[index]["parentId"] = json!(parent); + } else { + object_mut(&mut tasks[index])?.remove("parentId"); + } + reassign_todo_order(&mut tasks); + } + if parse_title { + let body_text = body_of(&tasks[index]); + let existing = tasks[index].get("tags").and_then(Value::as_array).cloned().unwrap_or_default(); + tasks[index]["tags"] = Value::Array(merge_tags(&existing, &auto_ids(&body_text, &tags))); + } + let updated = tasks[index].clone(); + put_tasks(data, tasks); + Ok(updated) +} + +fn apply_parsed_title(task: &mut Value, value: &Value, _tags: &[Value], url_on: bool, body: &Value) -> ApiResult<()> { + let raw = value + .as_str() + .map(str::trim) + .filter(|title| !title.is_empty()) + .ok_or_else(|| err(400, "title must be a non-empty string"))?; + if raw.chars().count() > 1000 { + return Err(err(400, "title must be a non-empty string")); + } + // 画面のタイトル編集と同じ。プロジェクトと URL だけ解釈し、日付は入力欄(追加時)だけ。 + let item = materialize(raw, url_on); + if item.title.chars().count() > 1000 || item.title.trim().is_empty() { + return Err(err(400, "title must be a non-empty string")); + } + task["title"] = json!(item.title); + match item.project { + Some(project) => task["projectName"] = json!(project), + None => { + object_mut(task)?.remove("projectName"); + } + } + if body.get("url").is_none() { + if let Some(url) = item.url { + task["url"] = json!(url); + } + } + Ok(()) +} + +fn apply_project(task: &mut Value, value: &Value) -> ApiResult<()> { + match value { + Value::Null => { + let body = body_of(task); + object_mut(task)?.remove("projectName"); + task["title"] = json!(body); + } + Value::String(name) if name.trim().is_empty() => { + let body = body_of(task); + object_mut(task)?.remove("projectName"); + task["title"] = json!(body); + } + Value::String(name) => { + let name = name.trim(); + if name.chars().count() > 100 || name.contains(char::is_whitespace) { + return Err(err(400, "projectName must be a single token")); + } + let body = body_of(task); + task["projectName"] = json!(name); + task["title"] = json!(format!(":{name} {body}")); + } + _ => return Err(err(400, "projectName must be a string or null")), + } + Ok(()) +} + +fn cascade_flag(tasks: &mut [Value], id: &str, key: &str, flag: bool) { + let children = descendants(tasks, id); + for task in tasks.iter_mut() { + if id_of(task).is_some_and(|task_id| children.iter().any(|child| child == task_id)) { + task[key] = json!(flag); + } + } +} + +pub fn set_status(data: &mut Value, body: &Value) -> ApiResult { + let mut ids = Vec::new(); + if let Some(id) = body.get("id").and_then(Value::as_str) { + require_id(id)?; + ids.push(id.to_string()); + } + if let Some(list) = body.get("ids").and_then(Value::as_array) { + for item in list { + let id = item.as_str().ok_or_else(|| err(400, "ids must be an array of strings"))?; + require_id(id)?; + ids.push(id.to_string()); + } + } + if ids.is_empty() { + return Err(err(400, "id or ids is required")); + } + ids.sort(); + ids.dedup(); + let status = body.get("status").and_then(Value::as_str).unwrap_or(""); + if !matches!(status, "todo" | "done" | "toggle") { + return Err(err(400, "status must be todo, done, or toggle")); + } + let cascade = body.get("cascade").and_then(Value::as_bool).unwrap_or(false); + let mut tasks = tasks_of(data); + for id in &ids { + if find_index(&tasks, id).is_none() { + return Err(err(404, "task not found")); + } + } + let original: Vec<(String, String)> = ids + .iter() + .map(|id| { + let current = tasks + .iter() + .find(|task| id_of(task) == Some(id.as_str())) + .map(status_of) + .unwrap_or("") + .to_string(); + (id.clone(), current) + }) + .collect(); + let marking_done: Vec = original + .iter() + .filter(|(_, current)| match status { + "done" => true, + "todo" => false, + _ => current == "todo", + }) + .map(|(id, _)| id.clone()) + .collect(); + if !cascade { + for id in &marking_done { + let blocked = descendants(&tasks, id).iter().any(|child| { + tasks.iter().find(|task| id_of(task) == Some(child.as_str())).is_some_and(|task| status_of(task) == "todo") + }); + if blocked { + return Err(err(409, "children pending")); + } + } + } + let now = now_rfc3339(); + let mut updated = Vec::new(); + for id in &marking_done { + let mut targets = vec![id.clone()]; + if cascade { + targets.extend(descendants(&tasks, id)); + } + for target in targets { + if let Some(task) = tasks.iter_mut().find(|task| id_of(task) == Some(target.as_str())) { + task["status"] = json!("done"); + task["completedAt"] = json!(now.clone()); + if !updated.iter().any(|seen: &String| seen == &target) { + updated.push(target); + } + } + } + } + if status != "done" { + for (id, current) in &original { + let reopen = status == "todo" || (status == "toggle" && current == "done"); + if reopen { + if let Some(task) = tasks.iter_mut().find(|task| id_of(task) == Some(id.as_str())) { + task["status"] = json!("todo"); + task["completedAt"] = Value::Null; + updated.push(id.clone()); + } + } + } + } + put_tasks(data, tasks); + Ok(json!({ "updated": updated })) +} + +pub fn delete_ids(data: &mut Value, ids: &[String]) -> ApiResult { + if ids.is_empty() { + return Err(err(400, "id or ids is required")); + } + let mut tasks = tasks_of(data); + for id in ids { + require_id(id)?; + if find_index(&tasks, id).is_none() { + return Err(err(404, "task not found")); + } + } + let mut remove = Vec::new(); + for id in ids { + if !remove.iter().any(|seen: &String| seen == id) { + remove.push(id.clone()); + } + for child in descendants(&tasks, id) { + if !remove.iter().any(|seen| seen == &child) { + remove.push(child); + } + } + } + let requested: HashSet<&str> = ids.iter().map(String::as_str).collect(); + tasks.retain(|task| id_of(task).is_none_or(|id| !remove.iter().any(|gone| gone == id))); + let also: Vec<&String> = remove.iter().filter(|id| !requested.contains(id.as_str())).collect(); + put_tasks(data, tasks); + if ids.len() == 1 { + Ok(json!({ "deleted": ids[0], "alsoDeleted": also })) + } else { + Ok(json!({ "deleted": ids, "alsoDeleted": also })) + } +} + +pub fn reorder_tasks(data: &mut Value, ids: &[String]) -> ApiResult { + if ids.is_empty() { + return Err(err(400, "ids is required")); + } + let mut seen = HashSet::new(); + for id in ids { + require_id(id)?; + if !seen.insert(id.clone()) { + return Err(err(400, "duplicate id in ids")); + } + } + let original = tasks_of(data); + for id in ids { + if find_index(&original, id).is_none() { + return Err(err(404, "task not found")); + } + } + let mut others = Vec::new(); + let mut picked = Vec::new(); + for task in original { + if id_of(&task).is_some_and(|id| ids.iter().any(|want| want == id)) { + picked.push(task); + } else { + others.push(task); + } + } + let mut reordered = Vec::new(); + for (index, id) in ids.iter().enumerate() { + let mut task = picked + .iter() + .find(|task| id_of(task) == Some(id.as_str())) + .cloned() + .ok_or_else(|| err(404, "task not found"))?; + task["order"] = json!(index as i64); + reordered.push(task); + } + let mut all = others; + all.extend(reordered); + let orders: Vec<(String, i64)> = all + .iter() + .filter_map(|task| id_of(task).map(|id| (id.to_string(), order_of(task)))) + .collect(); + for task in &mut all { + let Some(parent) = task.get("parentId").and_then(Value::as_str) else { + continue; + }; + let order = order_of(task); + match orders.iter().find(|(id, _)| id == parent) { + None => task["parentId"] = Value::Null, + Some((_, parent_order)) if order <= *parent_order => task["parentId"] = Value::Null, + Some(_) => {} + } + } + put_tasks(data, all); + Ok(json!({ "ok": true })) +} + +pub fn reorder_projects(data: &mut Value, projects: &[Option]) -> ApiResult { + let original = tasks_of(data); + let mut others = Vec::new(); + let mut sections: Vec<(String, Vec)> = Vec::new(); + for task in original { + if status_of(&task) != "todo" { + others.push(task); + continue; + } + let key = str_field(&task, "projectName").unwrap_or("__none__").to_string(); + if let Some((_, items)) = sections.iter_mut().find(|(name, _)| name == &key) { + items.push(task); + } else { + sections.push((key, vec![task])); + } + } + let mut order = 0i64; + let mut flat = Vec::new(); + let mut used = HashSet::new(); + for project in projects { + let key = project.as_deref().filter(|name| !name.is_empty()).unwrap_or("__none__"); + if !used.insert(key.to_string()) { + continue; + } + if let Some((_, mut items)) = sections.iter().find(|(name, _)| name == key).map(|(name, items)| (name.clone(), items.clone())) { + items.sort_by_key(order_of); + for mut task in items { + task["order"] = json!(order); + order += 1; + flat.push(task); + } + let _ = items; + } + } + for (key, mut items) in sections { + if used.contains(&key) { + continue; + } + items.sort_by_key(order_of); + for mut task in items { + task["order"] = json!(order); + order += 1; + flat.push(task); + } + } + flat.extend(others); + put_tasks(data, flat); + Ok(json!({ "ok": true })) +} + +pub fn clear_data(data: &mut Value, body: &Value) -> ApiResult { + if body.get("confirm").and_then(Value::as_str) != Some("delete") { + return Err(err(400, "confirm must be \"delete\"")); + } + put_tasks(data, Vec::new()); + crate::model::put_tags(data, Vec::new()); + Ok(json!({ "cleared": true })) +} diff --git a/src-tauri/core/src/view.rs b/src-tauri/core/src/view.rs new file mode 100644 index 0000000..acfd16d --- /dev/null +++ b/src-tauri/core/src/view.rs @@ -0,0 +1,479 @@ +//! コピー用 Markdown と日次リザルト。画面の copy.ts / result.ts と同じ定義。 +use chrono::{Duration, NaiveDate}; +use serde_json::{json, Value}; + +use crate::model::{body_of, dfs_ids, id_of, is_must, status_of, str_field, tasks_of}; +use crate::settings::{effective_settings, is_iso_day, language_of}; +use crate::tags::tag_name; + +pub fn export_markdown(data: &Value, query: &[(String, String)]) -> Result { + let target = query + .iter() + .find(|(key, _)| key == "target") + .map(|(_, value)| value.as_str()) + .ok_or_else(|| (400, "target is required".to_string()))?; + if !matches!(target, "today" | "all" | "todo") { + return Err((400, "target must be today, all, or todo".into())); + } + let settings = effective_settings(data); + let grouping = query_bool(query, "grouping").unwrap_or_else(|| { + settings + .get("copyGroupingEnabled") + .and_then(Value::as_bool) + .unwrap_or(true) + }); + let include_url = query_bool(query, "url").unwrap_or_else(|| { + settings + .get("urlEnabled") + .and_then(Value::as_bool) + .unwrap_or(true) + }); + let lang = query + .iter() + .find(|(key, _)| key == "lang") + .map(|(_, value)| value.clone()) + .unwrap_or_else(|| language_of(data)); + if !matches!(lang.as_str(), "ja" | "en") { + return Err((400, "lang must be ja or en".into())); + } + let today = query + .iter() + .find(|(key, _)| key == "date") + .map(|(_, value)| value.clone()) + .unwrap_or_else(crate::model::local_today); + if !is_iso_day(&today) { + return Err((400, "date must be YYYY-MM-DD".into())); + } + let tasks = tasks_of(data); + let tags = crate::model::tags_of(data); + let markdown = match target { + "today" => { + let done = tasks + .iter() + .filter(|task| done_on(task, &today)) + .cloned() + .collect::>(); + let header = format!("## {today} {}\n\n", heading(&lang, "today")); + render(&done, &tasks, &tags, include_url, grouping, &lang, &header, "-", false) + } + "all" => { + let done = tasks + .iter() + .filter(|task| status_of(task) == "done") + .cloned() + .collect::>(); + let header = format!("## {}\n\n", heading(&lang, "all")); + render(&done, &tasks, &tags, include_url, grouping, &lang, &header, "-", false) + } + _ => { + let todo = ordered_todos(&tasks); + let header = format!("## {}\n\n", heading(&lang, "todo")); + render(&todo, &tasks, &tags, include_url, grouping, &lang, &header, "- [ ]", true) + } + }; + Ok(json!({ "target": target, "markdown": markdown })) +} + +fn render( + rows: &[Value], + all: &[Value], + tags: &[Value], + include_url: bool, + grouping: bool, + lang: &str, + header: &str, + prefix: &str, + with_depth: bool, +) -> String { + if !grouping { + if rows.is_empty() { + return header.to_string(); + } + let lines = rows + .iter() + .map(|task| { + let indent = if with_depth { + " ".repeat(task_depth(task, all)) + } else { + String::new() + }; + format!("{indent}{prefix} {}", line_of(task, tags, include_url)) + }) + .collect::>() + .join("\n"); + return format!("{header}{lines}\n"); + } + let mut keys = Vec::new(); + let mut groups: Vec<(String, Vec)> = Vec::new(); + for task in rows { + let key = str_field(task, "projectName").unwrap_or("__none__").to_string(); + if let Some((_, items)) = groups.iter_mut().find(|(name, _)| name == &key) { + items.push(task.clone()); + } else { + keys.push(key.clone()); + groups.push((key, vec![task.clone()])); + } + } + let mut named: Vec = keys.into_iter().filter(|key| key != "__none__").collect(); + named.sort(); + let mut lines = Vec::new(); + for key in named { + lines.push(format!("### {key}")); + if let Some((_, items)) = groups.iter().find(|(name, _)| name == &key) { + for task in items { + lines.push(prefixed(task, all, tags, include_url, prefix, with_depth)); + } + } + lines.push(String::new()); + } + if let Some((_, items)) = groups.iter().find(|(name, _)| name == "__none__") { + lines.push(format!("### {}", if lang == "en" { "Uncategorized" } else { "未分類" })); + for task in items { + lines.push(prefixed(task, all, tags, include_url, prefix, with_depth)); + } + lines.push(String::new()); + } + format!("{header}{}\n", lines.join("\n").trim_end()) +} + +fn prefixed(task: &Value, all: &[Value], tags: &[Value], include_url: bool, prefix: &str, with_depth: bool) -> String { + let indent = if with_depth { + " ".repeat(task_depth(task, all)) + } else { + String::new() + }; + format!("{indent}{prefix} {}", line_of(task, tags, include_url)) +} + +fn line_of(task: &Value, tags: &[Value], include_url: bool) -> String { + let mut line = body_of(task); + let labels = task + .get("tags") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str()) + .filter_map(|id| tag_name(tags, id)) + .map(|name| format!("#{name}")) + .collect::>() + }) + .unwrap_or_default(); + if !labels.is_empty() { + line.push(' '); + line.push_str(&labels.join(" ")); + } + if include_url { + if let Some(url) = str_field(task, "url") { + line.push_str(" "); + line.push_str(url); + } + } + line +} + +fn heading(lang: &str, kind: &str) -> &'static str { + match (lang, kind) { + ("en", "today") => "Completed Tasks", + ("en", "all") => "All Completed Tasks", + ("en", "todo") => "Todo Tasks", + (_, "all") => "全完了タスク", + (_, "todo") => "未完了タスク", + _ => "完了タスク", + } +} + +fn done_on(task: &Value, day: &str) -> bool { + status_of(task) == "done" + && task + .get("completedAt") + .and_then(Value::as_str) + .is_some_and(|stamp| stamp.len() >= 10 && &stamp[..10] == day) +} + +fn ordered_todos(tasks: &[Value]) -> Vec { + let todos: Vec = tasks + .iter() + .filter(|task| status_of(task) == "todo") + .cloned() + .collect(); + dfs_ids(&todos) + .into_iter() + .filter_map(|id| todos.iter().find(|task| id_of(task) == Some(id.as_str())).cloned()) + .collect() +} + +fn task_depth(task: &Value, tasks: &[Value]) -> usize { + let Some(id) = id_of(task) else { return 0 }; + crate::model::depth(tasks, id) +} + +pub fn daily_result(data: &Value, query: &[(String, String)]) -> Result { + let date = query + .iter() + .find(|(key, _)| key == "date") + .map(|(_, value)| value.clone()) + .unwrap_or_else(crate::model::local_today); + if !is_iso_day(&date) { + return Err((400, "date must be YYYY-MM-DD".into())); + } + let focus = data + .get("settings") + .and_then(|settings| settings.get("focusMinutesByDate")) + .and_then(|map| map.get(&date)) + .and_then(Value::as_f64) + .unwrap_or(0.0); + let tasks = tasks_of(data); + let tags = crate::model::tags_of(data); + let mut result = compute(&tasks, &tags, focus, &date); + let lang = language_of(data); + let share = share_text(&result, &lang); + if let Some(object) = result.as_object_mut() { + object.insert("shareText".into(), json!(share)); + } + Ok(result) +} + +fn compute(tasks: &[Value], tags: &[Value], focus_minutes: f64, today: &str) -> Value { + let done_today: Vec<&Value> = tasks.iter().filter(|task| done_on(task, today)).collect(); + let completed = done_today.len(); + let must_done = done_today + .iter() + .filter(|task| task.get("isMinimum").and_then(Value::as_bool) == Some(true)) + .count(); + let must_remaining = tasks + .iter() + .filter(|task| status_of(task) == "todo" && is_must(task, today)) + .count(); + let must_total = must_done + must_remaining; + let rate_target = completed + must_remaining; + let rate = if rate_target == 0 { + if completed > 0 { 100 } else { 0 } + } else { + ((completed as f64 / rate_target as f64) * 100.0).round() as i64 + }; + let projects: Vec = done_today.iter().filter_map(|task| str_field(task, "projectName").map(str::to_string)).collect(); + let top_project = mode_strings(&projects).map(|(name, count)| json!({ "name": name, "count": count })); + let tag_ids: Vec = done_today + .iter() + .flat_map(|task| { + task.get("tags") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| item.as_str().map(str::to_string)) + }) + .collect(); + let top_tag = mode_strings(&tag_ids).and_then(|(id, count)| { + tag_name(tags, &id).map(|name| json!({ "name": name, "count": count })) + }); + let mut by_day: Vec<(String, usize)> = Vec::new(); + for task in tasks { + if status_of(task) != "done" { + continue; + } + let Some(stamp) = task.get("completedAt").and_then(Value::as_str) else { + continue; + }; + if stamp.len() < 10 { + continue; + } + let day = stamp[..10].to_string(); + if let Some((_, count)) = by_day.iter_mut().find(|(key, _)| key == &day) { + *count += 1; + } else { + by_day.push((day, 1)); + } + } + let count_on = |day: &str| by_day.iter().find(|(key, _)| key == day).map(|(_, count)| *count).unwrap_or(0); + let mut streak = 0usize; + let mut cursor = if count_on(today) > 0 { + today.to_string() + } else { + shift_day(today, -1) + }; + while count_on(&cursor) > 0 { + streak += 1; + cursor = shift_day(&cursor, -1); + if streak > 3660 { + break; + } + } + let week: Vec = (0..7) + .map(|index| { + let date = shift_day(today, index - 6); + json!({ "date": date, "count": count_on(&date) }) + }) + .collect(); + let distinct = { + let mut names = projects.clone(); + names.sort(); + names.dedup(); + names.len() + }; + let share = top_project + .as_ref() + .and_then(|value| value.get("count")) + .and_then(Value::as_u64) + .map(|count| if completed == 0 { 0.0 } else { count as f64 / completed as f64 }) + .unwrap_or(0.0); + let kind = diagnose(completed, must_done, must_remaining, focus_minutes, share, distinct, streak); + json!({ + "date": today, + "completedCount": completed, + "rate": rate, + "rateTarget": rate_target, + "focusMinutes": focus_minutes, + "mustDone": must_done, + "mustRemaining": must_remaining, + "mustTotal": must_total, + "mustAllClear": must_done > 0 && must_remaining == 0, + "topProject": top_project.unwrap_or(Value::Null), + "topTag": top_tag.unwrap_or(Value::Null), + "streak": streak, + "week": week, + "type": kind, + }) +} + +fn mode_strings(values: &[String]) -> Option<(String, usize)> { + let mut counts: Vec<(String, usize)> = Vec::new(); + for value in values { + if let Some((_, count)) = counts.iter_mut().find(|(key, _)| key == value) { + *count += 1; + } else { + counts.push((value.clone(), 1)); + } + } + let mut best: Option<(String, usize)> = None; + for (value, count) in counts { + if best.as_ref().is_none_or(|(_, best_count)| count > *best_count) { + best = Some((value, count)); + } + } + best +} + +fn diagnose( + completed: usize, + must_done: usize, + must_remaining: usize, + focus: f64, + top_share: f64, + distinct: usize, + streak: usize, +) -> Value { + let kind = if completed == 0 { + ("WARMING UP", "ウォーミングアップ", "今日はまだこれから。最初の一件を片付けよう。", "Warming Up", "Nothing done yet today. Clear the first one.") + } else if must_remaining == 0 && must_done >= 2 { + ("MUST HUNTER", "MUSTハンター", "最優先タスクを全部仕留めた日。", "Must Hunter", "You hunted down every top-priority task.") + } else if focus >= 90.0 && completed >= 3 { + ("DEEP FOCUSER", "集中型", "長い集中で押し切った日。", "Deep Focuser", "Long focus sessions carried the day.") + } else if completed >= 4 && top_share >= 0.6 { + ("SINGLE FOCUS", "一点集中型", "一つのプロジェクトに絞って進めた日。", "Single Focus", "You drilled into one project.") + } else if completed >= 10 { + ("SPRINTER", "大量処理型", "件数で圧倒した日。", "Sprinter", "Sheer volume won the day.") + } else if distinct >= 3 { + ("MULTI PLAYER", "並行型", "複数プロジェクトをバランスよく回した日。", "Multi Player", "You juggled several projects in balance.") + } else if streak >= 3 { + ("STREAK KEEPER", "継続型", "連続記録を守り抜いた日。", "Streak Keeper", "You kept the streak alive.") + } else { + ("STEADY", "安定型", "着実に積み上げた日。", "Steady", "A solid, steady day.") + }; + json!({ + "code": kind.0, + "ja": kind.1, + "commentJa": kind.2, + "en": kind.3, + "commentEn": kind.4, + }) +} + +fn share_text(result: &Value, lang: &str) -> String { + let date = result.get("date").and_then(Value::as_str).unwrap_or(""); + let completed = result.get("completedCount").and_then(Value::as_u64).unwrap_or(0); + let rate = result.get("rate").and_then(Value::as_i64).unwrap_or(0); + let focus = format_focus(result.get("focusMinutes").and_then(Value::as_f64).unwrap_or(0.0)); + let must_total = result.get("mustTotal").and_then(Value::as_u64).unwrap_or(0); + let must_done = result.get("mustDone").and_then(Value::as_u64).unwrap_or(0); + let clear = result.get("mustAllClear").and_then(Value::as_bool).unwrap_or(false); + let streak = result.get("streak").and_then(Value::as_u64).unwrap_or(0); + let code = result.get("type").and_then(|kind| kind.get("code")).and_then(Value::as_str).unwrap_or(""); + let comment = if lang == "en" { + result.get("type").and_then(|kind| kind.get("commentEn")).and_then(Value::as_str).unwrap_or("") + } else { + result.get("type").and_then(|kind| kind.get("commentJa")).and_then(Value::as_str).unwrap_or("") + }; + let project = result + .get("topProject") + .and_then(|value| value.get("name")) + .and_then(Value::as_str); + let must_line = if must_total == 0 { + "MUST -".to_string() + } else if lang == "en" { + if clear { + "MUST: ALL CLEAR".to_string() + } else { + format!("MUST: {must_done}/{must_total}") + } + } else if clear { + "MUST 全クリア".to_string() + } else { + format!("MUST {must_done}/{must_total}") + }; + if lang == "en" { + let mut lines = vec![ + format!("FastTask RESULT {date}"), + format!("Completed: {completed} (Rate {rate}%)"), + format!("Focus: {focus}"), + must_line, + ]; + if let Some(name) = project { + lines.push(format!("Top project: {name}")); + } + if streak > 0 { + let unit = if streak > 1 { "s" } else { "" }; + lines.push(format!("Streak: {streak} day{unit}")); + } + lines.push(format!("Type: {code} - {comment}")); + lines.join("\n") + } else { + let mut lines = vec![ + format!("FastTask RESULT {date}"), + format!("完了 {completed}件(達成率 {rate}%)"), + format!("フォーカス {focus}"), + must_line, + ]; + if let Some(name) = project { + lines.push(format!("トッププロジェクト: {name}")); + } + if streak > 0 { + lines.push(format!("連続 {streak}日")); + } + lines.push(format!("今日のタイプ: {code}({comment})")); + lines.join("\n") + } +} + +pub fn format_focus(minutes: f64) -> String { + let total = minutes.round().max(0.0) as i64; + let hours = total / 60; + let mins = total % 60; + format!("{hours}:{mins:02}") +} + +fn shift_day(day: &str, delta: i64) -> String { + let Some(date) = NaiveDate::parse_from_str(day, "%Y-%m-%d").ok() else { + return day.to_string(); + }; + (date + Duration::days(delta)).format("%Y-%m-%d").to_string() +} + +fn query_bool(query: &[(String, String)], key: &str) -> Option { + let value = query.iter().find(|(name, _)| name == key)?.1.as_str(); + match value { + "true" | "1" => Some(true), + "false" | "0" => Some(false), + _ => None, + } +} diff --git a/src-tauri/core/tests/http.rs b/src-tauri/core/tests/http.rs new file mode 100644 index 0000000..ef18a5a --- /dev/null +++ b/src-tauri/core/tests/http.rs @@ -0,0 +1,480 @@ +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde_json::{json, Value}; + +static SEQ: AtomicU64 = AtomicU64::new(0); + +struct Dir(PathBuf); + +impl Dir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "fasttask-core-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } +} + +impl Drop for Dir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn call(dir: &Dir, method: &str, url: &str, body: &str) -> (u16, Value) { + let response = fasttask_core::handle( + &dir.0, + "test-token", + method, + url, + Some("Bearer test-token"), + body, + ); + (response.status, response.body) +} + +fn call_anon(dir: &Dir, method: &str, url: &str) -> u16 { + fasttask_core::handle(&dir.0, "test-token", method, url, None, "").status +} + +#[test] +fn rejects_missing_token_and_keeps_create_contract() { + let dir = Dir::new(); + assert_eq!(call_anon(&dir, "GET", "/tasks"), 401); + + let (status, created) = call(&dir, "POST", "/tasks", r#"{"title":" 資料 ","tags":["a"]}"#); + assert_eq!(status, 200); + assert_eq!(created["title"], "資料"); + assert_eq!(created["status"], "todo"); + assert_eq!(created["tags"], json!(["a"])); + assert_eq!(created["order"], 0); + let id = created["id"].as_str().unwrap().to_string(); + + let (status, second) = call(&dir, "POST", "/tasks", r#"{"title":"next"}"#); + assert_eq!(status, 200); + assert_eq!(second["order"], 1); + + let (status, listed) = call(&dir, "GET", "/tasks?status=todo", ""); + assert_eq!(status, 200); + assert_eq!(listed["tasks"].as_array().unwrap().len(), 2); + + let (status, _) = call(&dir, "GET", "/tasks?status=nope", ""); + assert_eq!(status, 400); + + let (status, updated) = call( + &dir, + "PATCH", + &format!("/tasks/{id}"), + r#"{"status":"done","dueDate":null}"#, + ); + assert_eq!(status, 200); + assert_eq!(updated["status"], "done"); + assert!(updated["completedAt"].as_str().is_some()); + assert!(updated["dueDate"].is_null()); + + let (status, _) = call(&dir, "PATCH", &format!("/tasks/{id}"), r#"{"tags":"x"}"#); + assert_eq!(status, 400); + + let (status, deleted) = call(&dir, "DELETE", &format!("/tasks/{id}"), ""); + assert_eq!(status, 200); + assert_eq!(deleted["deleted"], id); + let (status, _) = call(&dir, "DELETE", &format!("/tasks/{id}"), ""); + assert_eq!(status, 404); +} + +#[test] +fn parses_input_box_text_and_splits_on_commas() { + let dir = Dir::new(); + call( + &dir, + "POST", + "/tags", + r##"{"name":"開発","color":"#007AFF","keywords":["資料"]}"##, + ); + let (status, created) = call( + &dir, + "POST", + "/tasks/batch", + r#"{"text":":work 資料 tomorrow https://example.com/a, レビュー"}"#, + ); + assert_eq!(status, 200, "{created}"); + let tasks = created["tasks"].as_array().unwrap(); + assert_eq!(tasks.len(), 2); + assert_eq!(tasks[0]["projectName"], "work"); + assert_eq!(tasks[0]["url"], "https://example.com/a"); + assert!(tasks[0]["dueDate"].as_str().unwrap().len() == 10); + assert_eq!(tasks[0]["dueDate"], tasks[1]["dueDate"]); + assert_eq!(tasks[1]["projectName"], "work"); + assert_eq!(tasks[1]["title"], ":work レビュー"); + let tags = tasks[0]["tags"].as_array().unwrap(); + assert_eq!(tags.len(), 1); + + let (status, found) = call(&dir, "GET", "/tasks?q=レビュー&project=work&order=ui", ""); + assert_eq!(status, 200); + assert_eq!(found["count"], 1); +} + +#[test] +fn search_filters_tags_due_and_must() { + let dir = Dir::new(); + let today = fasttask_core::local_today(); + let (_, tag) = call(&dir, "POST", "/tags", r#"{"name":"家事","keywords":[]}"#); + let tag_id = tag["id"].as_str().unwrap(); + call( + &dir, + "POST", + "/tasks", + &format!(r#"{{"title":"洗濯","tags":["家事"],"dueDate":"{today}","isMinimum":true}}"#), + ); + call( + &dir, + "POST", + "/tasks", + r#"{"title":"別件","projectName":"other","isPending":true}"#, + ); + let (status, listed) = call(&dir, "GET", &format!("/tasks?tag={tag_id}&must=true"), ""); + assert_eq!(status, 200); + assert_eq!(listed["count"], 1); + assert_eq!(listed["tasks"][0]["title"], "洗濯"); + + let (status, pending) = call(&dir, "GET", "/tasks?pending=true&project=other", ""); + assert_eq!(status, 200); + assert_eq!(pending["count"], 1); + + let (status, due) = call(&dir, "GET", "/tasks?due=today", ""); + assert_eq!(status, 200); + assert_eq!(due["count"], 1); +} + +#[test] +fn nests_within_project_and_refuses_cycles_and_depth() { + let dir = Dir::new(); + let parent = call(&dir, "POST", "/tasks", r#"{"title":"親","projectName":"p"}"#).1; + let child = call(&dir, "POST", "/tasks", r#"{"title":"子","projectName":"p"}"#).1; + let other = call(&dir, "POST", "/tasks", r#"{"title":"他","projectName":"q"}"#).1; + let parent_id = parent["id"].as_str().unwrap(); + let child_id = child["id"].as_str().unwrap(); + let other_id = other["id"].as_str().unwrap(); + + let (status, _) = call( + &dir, + "PATCH", + &format!("/tasks/{other_id}"), + &format!(r#"{{"parentId":"{parent_id}"}}"#), + ); + assert_eq!(status, 400); + + let (status, nested) = call( + &dir, + "PATCH", + &format!("/tasks/{child_id}"), + &format!(r#"{{"parentId":"{parent_id}"}}"#), + ); + assert_eq!(status, 200, "{nested}"); + assert_eq!(nested["parentId"], parent_id); + assert!(nested["order"].as_i64().unwrap() > parent["order"].as_i64().unwrap_or(0)); + + let (status, _) = call( + &dir, + "PATCH", + &format!("/tasks/{parent_id}"), + &format!(r#"{{"parentId":"{child_id}"}}"#), + ); + assert_eq!(status, 400); + + let mut current = child_id.to_string(); + for index in 0..4 { + let (status, created) = call( + &dir, + "POST", + "/tasks", + &format!(r#"{{"title":"d{index}","projectName":"p","parentId":"{current}"}}"#), + ); + if index < 3 { + assert_eq!(status, 200, "{created}"); + current = created["id"].as_str().unwrap().to_string(); + } else { + assert_eq!(status, 400, "{created}"); + } + } +} + +#[test] +fn complete_blocks_on_open_children_unless_cascade_and_delete_is_recursive() { + let dir = Dir::new(); + let parent = call(&dir, "POST", "/tasks", r#"{"title":"親"}"#).1["id"].as_str().unwrap().to_string(); + let child = call( + &dir, + "POST", + "/tasks", + &format!(r#"{{"title":"子","parentId":"{parent}"}}"#), + ) + .1["id"] + .as_str() + .unwrap() + .to_string(); + let grand = call( + &dir, + "POST", + "/tasks", + &format!(r#"{{"title":"孫","parentId":"{child}"}}"#), + ) + .1["id"] + .as_str() + .unwrap() + .to_string(); + + let (status, blocked) = call( + &dir, + "POST", + "/tasks/status", + &format!(r#"{{"id":"{parent}","status":"done"}}"#), + ); + assert_eq!(status, 409, "{blocked}"); + assert_eq!(call(&dir, "GET", &format!("/tasks/{child}"), "").1["status"], "todo"); + + let (status, done) = call( + &dir, + "POST", + "/tasks/status", + &format!(r#"{{"id":"{parent}","status":"done","cascade":true}}"#), + ); + assert_eq!(status, 200, "{done}"); + assert_eq!(call(&dir, "GET", &format!("/tasks/{grand}"), "").1["status"], "done"); + + let (status, _) = call( + &dir, + "POST", + "/tasks/status", + &format!(r#"{{"id":"{parent}","status":"todo"}}"#), + ); + assert_eq!(status, 200); + assert_eq!(call(&dir, "GET", &format!("/tasks/{parent}"), "").1["status"], "todo"); + assert_eq!(call(&dir, "GET", &format!("/tasks/{child}"), "").1["status"], "done"); + + let (status, deleted) = call(&dir, "DELETE", &format!("/tasks/{parent}"), ""); + assert_eq!(status, 200, "{deleted}"); + let also = deleted["alsoDeleted"].as_array().unwrap(); + assert!(also.iter().any(|id| id.as_str() == Some(child.as_str()))); + assert!(also.iter().any(|id| id.as_str() == Some(grand.as_str()))); + assert_eq!(call(&dir, "GET", "/tasks", "").1["count"], 0); +} + +#[test] +fn reorder_detaches_a_child_moved_above_its_parent() { + let dir = Dir::new(); + let parent = call(&dir, "POST", "/tasks", r#"{"title":"親","projectName":"p"}"#).1["id"] + .as_str() + .unwrap() + .to_string(); + let child = call( + &dir, + "POST", + "/tasks", + &format!(r#"{{"title":"子","projectName":"p","parentId":"{parent}"}}"#), + ) + .1["id"] + .as_str() + .unwrap() + .to_string(); + let (status, _) = call( + &dir, + "POST", + "/tasks/reorder", + &format!(r#"{{"ids":["{child}","{parent}"]}}"#), + ); + assert_eq!(status, 200); + let child_task = call(&dir, "GET", &format!("/tasks/{child}"), "").1; + assert!(child_task["parentId"].is_null()); + assert_eq!(child_task["order"], 0); +} + +#[test] +fn project_reorder_and_flag_cascade() { + let dir = Dir::new(); + call(&dir, "POST", "/tasks", r#"{"title":"a","projectName":"A"}"#); + call(&dir, "POST", "/tasks", r#"{"title":"b","projectName":"B"}"#); + let parent = call(&dir, "POST", "/tasks", r#"{"title":"親","projectName":"A"}"#).1["id"] + .as_str() + .unwrap() + .to_string(); + let child = call( + &dir, + "POST", + "/tasks", + &format!(r#"{{"title":"子","projectName":"A","parentId":"{parent}"}}"#), + ) + .1["id"] + .as_str() + .unwrap() + .to_string(); + let (status, _) = call( + &dir, + "POST", + "/projects/reorder", + r#"{"projects":["B","A"]}"#, + ); + assert_eq!(status, 200); + let b = call(&dir, "GET", "/tasks?project=B", "").1["tasks"][0]["order"].as_i64().unwrap(); + let a = call(&dir, "GET", "/tasks?project=A&parent=none", "").1["tasks"][0]["order"] + .as_i64() + .unwrap(); + assert!(b < a); + + let (status, _) = call( + &dir, + "PATCH", + &format!("/tasks/{parent}"), + r#"{"isMinimum":true,"isPending":true}"#, + ); + assert_eq!(status, 200); + let child_task = call(&dir, "GET", &format!("/tasks/{child}"), "").1; + assert_eq!(child_task["isMinimum"], true); + assert_eq!(child_task["isPending"], true); +} + +#[test] +fn tags_apply_keywords_and_delete_strips_them() { + let dir = Dir::new(); + call(&dir, "POST", "/tasks", r#"{"title":"APIを直す"}"#); + let (status, tag) = call( + &dir, + "POST", + "/tags", + r#"{"name":"開発","keywords":["API"]}"#, + ); + assert_eq!(status, 200, "{tag}"); + let id = tag["id"].as_str().unwrap(); + let task = call(&dir, "GET", "/tasks?q=API", "").1["tasks"][0].clone(); + assert_eq!(task["tags"][0], id); + + let (status, _) = call( + &dir, + "PATCH", + &format!("/tags/{id}"), + r#"{"keywords":["API","直す"]}"#, + ); + assert_eq!(status, 200); + let (status, _) = call(&dir, "DELETE", &format!("/tags/{id}"), ""); + assert_eq!(status, 200); + let task = call(&dir, "GET", "/tasks", "").1["tasks"][0].clone(); + assert!(task["tags"].as_array().unwrap().is_empty()); +} + +#[test] +fn settings_focus_clear_and_google_do_not_leak_secrets() { + let dir = Dir::new(); + call(&dir, "POST", "/tasks", r#"{"title":"残す"}"#); + let (status, settings) = call( + &dir, + "PATCH", + "/settings", + r#"{"theme":"dark","language":"en","groupingEnabled":false,"lastFocusMinutes":40}"#, + ); + assert_eq!(status, 200, "{settings}"); + assert_eq!(settings["theme"], "dark"); + assert_eq!(settings["language"], "en"); + assert_eq!(call(&dir, "GET", "/settings", "").1["lastFocusMinutes"], 40); + assert_eq!(call(&dir, "PATCH", "/settings", r#"{"nope":true}"#).0, 400); + + let (status, focus) = call(&dir, "POST", "/focus", r#"{"minutes":25}"#); + assert_eq!(status, 200, "{focus}"); + assert_eq!(focus["focusMinutes"], 25.0); + + fs::write( + dir.0.join("google.json"), + r#"{"client_secret":"super-secret-value","refresh_token":"refresh-value","client_id":"id","email":"a@b.c","calendar_id":"primary","tasklist_id":"@default","auto_push":false,"push_calendar":true,"push_tasks":true}"#, + ) + .unwrap(); + let (status, google) = call(&dir, "GET", "/google", ""); + assert_eq!(status, 200); + let encoded = google.to_string(); + assert!(!encoded.contains("super-secret-value")); + assert!(!encoded.contains("refresh-value")); + assert!(!encoded.contains("client_secret")); + assert_eq!(google["connected"], true); + assert_eq!(google["hasSecret"], true); + assert_eq!( + call(&dir, "PATCH", "/google", r#"{"clientSecret":"nope"}"#).0, + 400 + ); + let (status, patched) = call(&dir, "PATCH", "/google", r#"{"autoPush":true}"#); + assert_eq!(status, 200, "{patched}"); + assert_eq!(patched["autoPush"], true); + let stored = fs::read_to_string(dir.0.join("google.json")).unwrap(); + assert!(stored.contains("super-secret-value")); + + assert_eq!(call(&dir, "POST", "/data/clear", r#"{"confirm":"no"}"#).0, 400); + let (status, cleared) = call(&dir, "POST", "/data/clear", r#"{"confirm":"delete"}"#); + assert_eq!(status, 200, "{cleared}"); + assert_eq!(call(&dir, "GET", "/tasks", "").1["count"], 0); + assert_eq!(call(&dir, "GET", "/settings", "").1["theme"], "dark"); +} + +#[test] +fn export_and_result_match_the_screen_definitions() { + let dir = Dir::new(); + let today = "2026-11-09"; + let data = json!({ + "version": "1", + "tags": [{"id":"tag1","name":"開発","color":"#000","keywords":[]}], + "settings": {"language":"ja","copyGroupingEnabled":false,"urlEnabled":true,"focusMinutesByDate":{"2026-11-09":30}}, + "tasks": [ + {"id":"a","title":"one","status":"done","tags":["tag1"],"createdAt":"2026-11-09T00:00:00.000Z","completedAt":"2026-11-09T10:00:00.000Z","order":0,"isMinimum":true,"projectName":"A"}, + {"id":"b","title":"two","status":"done","tags":[],"createdAt":"2026-11-09T00:00:00.000Z","completedAt":"2026-11-09T11:00:00.000Z","order":1,"isMinimum":true,"projectName":"A"}, + {"id":"c","title":"three","status":"done","tags":[],"createdAt":"2026-11-09T00:00:00.000Z","completedAt":"2026-11-09T12:00:00.000Z","order":2,"projectName":"B"}, + {"id":"d","title":"old","status":"done","tags":[],"createdAt":"2026-11-08T00:00:00.000Z","completedAt":"2026-11-08T12:00:00.000Z","order":3}, + {"id":"e","title":"left","status":"todo","tags":[],"createdAt":"2026-11-09T00:00:00.000Z","order":4,"isMinimum":true}, + {"id":"f","title":"plain","status":"todo","tags":[],"createdAt":"2026-11-09T00:00:00.000Z","order":5} + ] + }); + fs::write(dir.0.join("data.json"), serde_json::to_string_pretty(&data).unwrap()).unwrap(); + let (status, result) = call(&dir, "GET", &format!("/result?date={today}"), ""); + assert_eq!(status, 200, "{result}"); + assert_eq!(result["completedCount"], 3); + assert_eq!(result["mustDone"], 2); + assert_eq!(result["mustRemaining"], 1); + assert_eq!(result["rate"], 75); + assert_eq!(result["type"]["code"], "STEADY"); + assert_eq!(result["streak"], 2); + assert_eq!(result["topProject"]["name"], "A"); + assert_eq!(result["topTag"]["name"], "開発"); + assert!(result["shareText"].as_str().unwrap().contains("完了 3件")); + assert_eq!(result["focusMinutes"], 30.0); + + let (status, exported) = call( + &dir, + "GET", + &format!("/export?target=today&date={today}&grouping=false"), + "", + ); + assert_eq!(status, 200, "{exported}"); + let markdown = exported["markdown"].as_str().unwrap(); + assert!(markdown.contains("## 2026-11-09 完了タスク"), "{markdown}"); + assert!(markdown.contains("- one #開発"), "{markdown}"); + assert!(!markdown.contains("old")); +} + +#[test] +fn editing_a_title_parses_project_and_url_but_not_the_date() { + let dir = Dir::new(); + let (status, created) = call(&dir, "POST", "/tasks", r#"{"title":"memo"}"#); + assert_eq!(status, 200); + let id = created["id"].as_str().unwrap(); + let (status, updated) = call( + &dir, + "PATCH", + &format!("/tasks/{id}"), + r#"{"title":":work 資料 明日 https://example.com/a","parseTitle":true}"#, + ); + assert_eq!(status, 200, "{updated}"); + assert_eq!(updated["projectName"], "work"); + assert_eq!(updated["title"], ":work 資料 明日"); + assert_eq!(updated["url"], "https://example.com/a"); + assert!(updated.get("dueDate").is_none_or(|value| value.is_null())); +} From ebc6eccfef8cac4a9b5c1b74b11cd876be210d3c Mon Sep 17 00:00:00 2001 From: riiim Date: Thu, 12 Nov 2026 11:41:25 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E3=82=A2=E3=83=97=E3=83=AA?= =?UTF-8?q?=E3=81=AEHTTP=20API=E3=82=92=E5=85=B1=E9=80=9A=E5=AE=9F?= =?UTF-8?q?=E8=A3=85=E3=81=AB=E7=B9=8B=E3=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 画面は外部からのデータ変更で設定とテーマも読み直す。Googleの今すぐ同期はアプリプロセスだけが行う。 --- src-tauri/Cargo.lock | 169 ++++++++++++++++- src-tauri/Cargo.toml | 6 +- src-tauri/src/api.rs | 262 ++++++-------------------- src/App.tsx | 31 ++- src/components/GoogleSyncSettings.tsx | 6 + src/externalData.test.ts | 16 ++ src/externalData.ts | 14 ++ 7 files changed, 289 insertions(+), 215 deletions(-) create mode 100644 src/externalData.test.ts create mode 100644 src/externalData.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f74c339..8d179e9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,7 @@ version = "1.4.1" dependencies = [ "base64 0.22.1", "chrono", + "fasttask-core", "reqwest", "serde", "serde_json", @@ -19,6 +20,8 @@ dependencies = [ "tauri-plugin-global-shortcut", "tauri-plugin-notification", "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-updater", "tiny_http", "url", "uuid", @@ -69,6 +72,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arboard" version = "3.6.1" @@ -830,6 +842,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1116,6 +1139,18 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fasttask-core" +version = "1.4.1" +dependencies = [ + "chrono", + "regex", + "serde_json", + "tiny_http", + "url", + "uuid", +] + [[package]] name = "fax" version = "0.2.7" @@ -1141,6 +1176,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2326,6 +2371,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2620,6 +2671,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2727,6 +2790,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -3119,7 +3196,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3396,6 +3473,7 @@ checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3442,7 +3520,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3988,6 +4066,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4093,6 +4182,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -4323,6 +4423,49 @@ dependencies = [ "zbus 5.16.0", ] +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.11.2" @@ -6081,6 +6224,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "xdg-home" version = "1.3.0" @@ -6323,6 +6476,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2e492bf..2f4c21b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,8 +19,9 @@ tauri-plugin-clipboard-manager = "2" tauri-plugin-fs = "2" tauri-plugin-global-shortcut = "2" tauri-plugin-notification = "2" -tauri-plugin-process = "2" -tauri-plugin-updater = "2" +# 2.4 / 2.13 以降は Tauri 2.12(Rust 1.90)が要る。mise.toml の Rust は 1.88。 +tauri-plugin-process = "=2.3.1" +tauri-plugin-updater = "=2.12.0" serde = { version = "1", features = ["derive"] } serde_json = "1" tiny_http = "0.12" @@ -30,3 +31,4 @@ reqwest = { version = "0.13", default-features = false, features = ["json", "for url = "2" sha2 = "0.10" base64 = "0.22" +fasttask-core = { path = "core" } diff --git a/src-tauri/src/api.rs b/src-tauri/src/api.rs index 45513b8..9ad1ebd 100644 --- a/src-tauri/src/api.rs +++ b/src-tauri/src/api.rs @@ -1,14 +1,15 @@ // AIエージェント連携用のローカルHTTP API (127.0.0.1 のみ)。 +// データの意味は fasttask-core が持つ。ここは待受、認証情報、画面への通知だけ。 use std::fs; use std::io::Read; use std::path::PathBuf; use std::sync::Mutex; +use fasttask_core::Change; use serde_json::{json, Value}; use tauri::{Emitter, Manager}; use tiny_http::{Header, Request, Response, Server}; -const MAX_BODY: usize = 1 << 20; // 1MiB const PORT_MIN: u16 = 48730; const PORT_MAX: u16 = 48760; @@ -98,234 +99,87 @@ fn respond(req: Request, status: u16, body: Value) -> std::io::Result<()> { } fn handle(ctx: &Ctx, mut req: Request) -> std::io::Result<()> { - let authed = req - .headers() - .iter() - .any(|h| { - h.field.equiv("Authorization") - && h.value.as_str().strip_prefix("Bearer ") == Some(ctx.token.as_str()) - }); + let authed = req.headers().iter().any(|h| { + h.field.equiv("Authorization") + && h.value.as_str().strip_prefix("Bearer ") == Some(ctx.token.as_str()) + }); if !authed { return respond(req, 401, json!({ "error": "unauthorized" })); } - let (path, query) = { - let url = req.url().to_string(); - match url.split_once('?') { - Some((p, q)) => (p.to_string(), q.to_string()), - None => (url, String::new()), - } + let url = req.url().to_string(); + let (path, _) = match url.split_once('?') { + Some((path, query)) => (path.to_string(), query.to_string()), + None => (url.clone(), String::new()), }; let method = req.method().as_str().to_string(); - // ボディ読み込み (サイズ上限つき) + if method == "POST" && path == "/google/push" { + return google_call(req, ctx.app.clone(), true); + } + if method == "GET" && path == "/google/tasklists" { + return google_call(req, ctx.app.clone(), false); + } + let mut body = String::new(); if matches!(method.as_str(), "POST" | "PATCH") { let len = req.body_length().unwrap_or(0); - if len > MAX_BODY { + if len > fasttask_core::MAX_BODY { return respond(req, 413, json!({ "error": "body too large" })); } - if let Err(e) = req.as_reader().take(MAX_BODY as u64 + 1).read_to_string(&mut body) { + if let Err(e) = req + .as_reader() + .take(fasttask_core::MAX_BODY as u64 + 1) + .read_to_string(&mut body) + { return respond(req, 400, json!({ "error": format!("read error: {e}") })); } - if body.len() > MAX_BODY { + if body.len() > fasttask_core::MAX_BODY { return respond(req, 413, json!({ "error": "body too large" })); } } - let segs: Vec<&str> = path.trim_matches('/').split('/').collect(); - let result = match (method.as_str(), segs.as_slice()) { - ("GET", ["tasks"]) => list_tasks(ctx, &query), - ("POST", ["tasks"]) => create_task(ctx, &body), - ("PATCH", ["tasks", id]) => update_task(ctx, id, &body), - ("DELETE", ["tasks", id]) => delete_task(ctx, id), - _ => Err((404, "not found".to_string())), - }; - - match result { - Ok(v) => respond(req, 200, v), - Err((code, msg)) => respond(req, code, json!({ "error": msg })), - } -} - -type ApiResult = Result; - -fn data_path(ctx: &Ctx) -> PathBuf { - ctx.dir.join("data.json") -} - -fn read_data(ctx: &Ctx) -> Result { - let path = data_path(ctx); - if !path.exists() { - return Ok(json!({ "tasks": [], "tags": [], "version": "1" })); - } - let s = fs::read_to_string(&path).map_err(|e| (500, format!("read error: {e}")))?; - serde_json::from_str(&s).map_err(|e| (500, format!("parse error: {e}"))) -} - -fn write_data(ctx: &Ctx, data: &Value) -> Result<(), (u16, String)> { - let tmp = ctx.dir.join("data.json.tmp"); - let s = serde_json::to_string_pretty(data).map_err(|e| (500, format!("serialize: {e}")))?; - fs::write(&tmp, s).map_err(|e| (500, format!("write error: {e}")))?; - fs::rename(&tmp, data_path(ctx)).map_err(|e| (500, format!("rename error: {e}")))?; - let _ = ctx.app.emit("external-data-changed", ()); - // HTTP API 経由の変更も自動pushの対象にする(設定OFFなら何もしない) - if let Some(tasks) = data.get("tasks").and_then(Value::as_array) { - crate::google::auto_push(ctx.app.clone(), tasks.clone()); - } - Ok(()) -} - -fn tasks_of(data: &Value) -> Vec { - data.get("tasks") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default() -} - -fn list_tasks(ctx: &Ctx, query: &str) -> ApiResult { - let _g = DATA_LOCK.lock().unwrap(); - let data = read_data(ctx)?; - let status = query.split('&').find_map(|kv| kv.strip_prefix("status=")); - let mut tasks = tasks_of(&data); - if let Some(s) = status { - if s != "todo" && s != "done" { - return Err((400, "status must be todo or done".into())); - } - tasks.retain(|t| t.get("status").and_then(Value::as_str) == Some(s)); - } - Ok(json!({ "tasks": tasks })) -} - -fn parse_body(body: &str) -> Result { - let v: Value = serde_json::from_str(body).map_err(|e| (400, format!("invalid json: {e}")))?; - if !v.is_object() { - return Err((400, "body must be a JSON object".into())); - } - Ok(v) -} - -fn create_task(ctx: &Ctx, body: &str) -> ApiResult { - let b = parse_body(body)?; - let title = b - .get("title") - .and_then(Value::as_str) - .map(str::trim) - .filter(|s| !s.is_empty()) - .ok_or((400, "title is required".to_string()))?; - if title.chars().count() > 1000 { - return Err((400, "title too long".into())); - } - let tags = match b.get("tags") { - None | Some(Value::Null) => vec![], - Some(Value::Array(a)) if a.iter().all(Value::is_string) => a.clone(), - Some(_) => return Err((400, "tags must be an array of strings".into())), + let response = { + let _guard = DATA_LOCK.lock().unwrap(); + fasttask_core::handle( + &ctx.dir, + &ctx.token, + &method, + &url, + Some(&format!("Bearer {}", ctx.token)), + &body, + ) }; - - let _g = DATA_LOCK.lock().unwrap(); - let mut data = read_data(ctx)?; - let mut tasks = tasks_of(&data); - let next_order = tasks - .iter() - .filter(|t| t.get("status").and_then(Value::as_str) == Some("todo")) - .filter_map(|t| t.get("order").and_then(Value::as_i64)) - .max() - .map_or(0, |m| m + 1); - - let mut task = json!({ - "id": uuid::Uuid::new_v4().to_string(), - "title": title, - "status": "todo", - "tags": tags, - "createdAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), - "order": next_order, - }); - for key in ["dueDate", "url"] { - match b.get(key) { - None | Some(Value::Null) => {} - Some(Value::String(s)) => { - task[key] = json!(s); + if response.changed == Change::Data { + let _ = ctx.app.emit("external-data-changed", ()); + if let Ok(text) = fs::read_to_string(ctx.dir.join("data.json")) { + if let Ok(data) = serde_json::from_str::(&text) { + if let Some(tasks) = data.get("tasks").and_then(Value::as_array) { + crate::google::auto_push(ctx.app.clone(), tasks.clone()); + } } - Some(_) => return Err((400, format!("{key} must be a string"))), } + } else if response.changed == Change::Google { + let _ = ctx.app.emit("external-google-changed", ()); } - - tasks.push(task.clone()); - data["tasks"] = Value::Array(tasks); - write_data(ctx, &data)?; - Ok(task) + respond(req, response.status, response.body) } -fn update_task(ctx: &Ctx, id: &str, body: &str) -> ApiResult { - let b = parse_body(body)?; - let _g = DATA_LOCK.lock().unwrap(); - let mut data = read_data(ctx)?; - let mut tasks = tasks_of(&data); - let task = tasks - .iter_mut() - .find(|t| t.get("id").and_then(Value::as_str) == Some(id)) - .ok_or((404, "task not found".to_string()))?; - - if let Some(v) = b.get("title") { - let s = v - .as_str() - .map(str::trim) - .filter(|s| !s.is_empty() && s.chars().count() <= 1000) - .ok_or((400, "title must be a non-empty string".to_string()))?; - task["title"] = json!(s); - } - if let Some(v) = b.get("status") { - let s = v.as_str().unwrap_or(""); - if s != "todo" && s != "done" { - return Err((400, "status must be todo or done".into())); - } - task["status"] = json!(s); - if s == "done" { - task["completedAt"] = - json!(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)); +fn google_call(req: Request, app: tauri::AppHandle, push: bool) -> std::io::Result<()> { + let (tx, rx) = std::sync::mpsc::channel(); + tauri::async_runtime::spawn(async move { + let result = if push { + crate::google::google_push_now(app) + .await + .map(|value| serde_json::to_value(value).unwrap_or_else(|_| json!({}))) } else { - task["completedAt"] = Value::Null; - } - } - for key in ["dueDate", "url"] { - match b.get(key) { - None => {} - Some(Value::Null) => { - task[key] = Value::Null; - } - Some(Value::String(s)) => { - task[key] = json!(s); - } - Some(_) => return Err((400, format!("{key} must be a string or null"))), - } - } - if let Some(v) = b.get("tags") { - match v { - Value::Array(a) if a.iter().all(Value::is_string) => task["tags"] = v.clone(), - _ => return Err((400, "tags must be an array of strings".into())), - } - } - - let updated = task.clone(); - data["tasks"] = Value::Array(tasks); - write_data(ctx, &data)?; - Ok(updated) -} - -fn delete_task(ctx: &Ctx, id: &str) -> ApiResult { - let _g = DATA_LOCK.lock().unwrap(); - let mut data = read_data(ctx)?; - let mut tasks = tasks_of(&data); - let before = tasks.len(); - // 子タスクも一緒に消す (parentId でぶら下がる孤児を残さない) - tasks.retain(|t| { - t.get("id").and_then(Value::as_str) != Some(id) - && t.get("parentId").and_then(Value::as_str) != Some(id) + crate::google::google_list_tasklists(app).await + }; + let _ = tx.send(result); }); - if tasks.len() == before { - return Err((404, "task not found".into())); + match rx.recv() { + Ok(Ok(body)) => respond(req, 200, body), + Ok(Err(message)) => respond(req, 400, json!({ "error": message })), + Err(_) => respond(req, 500, json!({ "error": "google request failed" })), } - data["tasks"] = Value::Array(tasks); - write_data(ctx, &data)?; - Ok(json!({ "deleted": id })) } diff --git a/src/App.tsx b/src/App.tsx index 9f4b4a3..f3ec37d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,8 @@ import { ShortcutsModal } from "./components/ShortcutsModal"; import { FocusTimerSetup } from "./components/FocusTimerSetup"; import { FocusEndModal } from "./components/FocusEndModal"; import { UiProvider, useUiContext } from "./context/UiContext"; -import { useStore } from "./store"; +import { useStore, applyTheme } from "./store"; +import { mergeExternalData } from "./externalData"; import { osNotify, osNotifyWithAction, setupFocusTimerActions, setFocusTimerActionCallback } from "./utils/notify"; import { t } from "./i18n"; import { buildDfsOrder, getDepth, hasUndoneDescendants } from "./utils/taskTree"; @@ -239,12 +240,28 @@ function AppInner() { // ローカルAPI/MCP が data.json を直接書き換えたときの再読み込み let unlistenExternal: (() => void) | undefined; listen("external-data-changed", async () => { - const raw = await invoke<{ tasks?: unknown[]; tags?: unknown[] } | null>("load_data"); - if (raw) { - useStore.setState({ - tasks: (raw.tasks ?? []) as never, - tags: (raw.tags ?? []) as never, - }); + const raw = await invoke<{ + tasks?: never[]; + tags?: never[]; + settings?: Record; + } | null>("load_data"); + const prevShortcut = useStore.getState().settings.focusShortcut; + const next = mergeExternalData(raw as never); + if (!next) return; + useStore.setState({ + tasks: next.tasks, + tags: next.tags, + settings: next.settings, + undoStack: [], + }); + applyTheme(next.settings.theme); + document.documentElement.lang = next.settings.language; + if (next.settings.focusShortcut !== prevShortcut) { + if (next.settings.focusShortcut) { + invoke("register_focus_shortcut", { shortcutStr: next.settings.focusShortcut }).catch(() => {}); + } else { + invoke("unregister_focus_shortcut").catch(() => {}); + } } }).then((f) => { unlistenExternal = f; }); diff --git a/src/components/GoogleSyncSettings.tsx b/src/components/GoogleSyncSettings.tsx index 22bbd6f..7d33464 100644 --- a/src/components/GoogleSyncSettings.tsx +++ b/src/components/GoogleSyncSettings.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { Language } from "../types"; /** Rust 側 PublicConfig と対。secret / refresh_token は一切含まれない。 */ @@ -149,6 +150,11 @@ export function GoogleSyncSettings({ lang }: { lang: Language }) { useEffect(() => { invoke("google_get_config").then(apply).catch((e) => setErr(String(e))); + let unlisten: (() => void) | undefined; + listen("external-google-changed", () => { + invoke("google_get_config").then(apply).catch((e) => setErr(String(e))); + }).then((fn) => { unlisten = fn; }); + return () => { unlisten?.(); }; }, []); async function patch(p: Record) { diff --git a/src/externalData.test.ts b/src/externalData.test.ts new file mode 100644 index 0000000..51899af --- /dev/null +++ b/src/externalData.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mergeExternalData } from "./externalData.ts"; + +test("mergeExternalData: 無いデータは null、設定は既定値と合成する", () => { + assert.equal(mergeExternalData(null), null); + const merged = mergeExternalData({ + tasks: [], + tags: [], + settings: { theme: "dark", language: "en" }, + }); + assert.equal(merged?.settings.theme, "dark"); + assert.equal(merged?.settings.language, "en"); + assert.equal(merged?.settings.groupingEnabled, true); + assert.equal(merged?.settings.urlEnabled, true); +}); diff --git a/src/externalData.ts b/src/externalData.ts new file mode 100644 index 0000000..0fdbe1a --- /dev/null +++ b/src/externalData.ts @@ -0,0 +1,14 @@ +import type { Settings, Tag, Task } from "./types.ts"; +import { defaultSettings } from "./types.ts"; + +/** ローカル API が data.json を書き換えたあと、画面の状態へ戻す。 */ +export function mergeExternalData( + raw: { tasks?: Task[]; tags?: Tag[]; settings?: Partial } | null, +): { tasks: Task[]; tags: Tag[]; settings: Settings } | null { + if (!raw) return null; + return { + tasks: raw.tasks ?? [], + tags: raw.tags ?? [], + settings: { ...defaultSettings, ...(raw.settings ?? {}) }, + }; +} From b8402a2bd537585ae600c8844141d95053b69107 Mon Sep 17 00:00:00 2001 From: riiim Date: Thu, 12 Nov 2026 11:41:28 +0000 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20MCP=E3=82=92=E5=85=AC=E5=BC=8FSDK?= =?UTF-8?q?=E3=81=AE=E3=83=84=E3=83=BC=E3=83=AB=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit initialize、tools/list、tools/call を SDK 経由にし、画面の操作に対応するツール名で出す。 --- .github/workflows/ci.yml | 23 ++ mcp/README.md | 42 ++- mcp/api.js | 80 +++++ mcp/protocol.test.js | 220 ++++++++++++ mcp/server.js | 146 +------- mcp/tools.js | 441 +++++++++++++++++++++++ package.json | 3 + pnpm-lock.yaml | 732 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 1549 insertions(+), 138 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 mcp/api.js create mode 100644 mcp/protocol.test.js create mode 100644 mcp/tools.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2363986 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.34.9 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - uses: dtolnay/rust-toolchain@stable + - run: pnpm install --frozen-lockfile + - run: pnpm test + - run: pnpm build + - run: cargo test --manifest-path src-tauri/core/Cargo.toml diff --git a/mcp/README.md b/mcp/README.md index 26acaa3..ba2ae47 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -1,8 +1,9 @@ # FastTask MCP サーバ -FastTask が起動時に立てるローカル HTTP API (127.0.0.1 のみ) を叩く、依存ゼロの MCP stdio ブリッジ。 +FastTask の画面と同じ操作を、MCP の tools として出す stdio サーバ。 +公式 SDK (`@modelcontextprotocol/sdk`) を使い、initialize、tools/list、tools/call に対応する。 -必要条件: Node 18+ / FastTask アプリが起動していること。 +必要条件: Node 22+。操作先の FastTask アプリが起動していること。 ## 仕組み @@ -12,7 +13,7 @@ FastTask は起動時に `app_data_dir/api.json` へ実ポートと認証トー - Linux: `~/.local/share/com.fasttask.desktop/api.json` - Windows: `%APPDATA%\com.fasttask.desktop\api.json` -MCP サーバはこのファイルを毎リクエスト読み直すので、設定は不要。環境変数 +MCP サーバはこのファイルを毎リクエスト読み直す。環境変数 `FASTTASK_PORT` / `FASTTASK_TOKEN` / `FASTTASK_APP_DATA_DIR` で上書きできる。 ## 登録 @@ -36,18 +37,42 @@ Claude Desktop (`claude_desktop_config.json`) / 手動設定: } ``` +リポジトリの依存を入れてから起動する。 + +```sh +pnpm install +node mcp/server.js +``` + ## tools -| tool | 引数 | +| tool | 内容 | | --- | --- | -| `list_tasks` | `status?` (`todo` \| `done`) | -| `create_task` | `title` (必須), `dueDate?`, `url?`, `tags?` | -| `update_task` | `id` (必須), `title?`, `status?`, `dueDate?`, `url?`, `tags?` | -| `delete_task` | `id` (必須) — 子タスクも一緒に削除 | +| `search_tasks` | キーワード、状態、プロジェクト、タグ、期限、MUST、確認待ちで探す | +| `get_task` | 1件取得 | +| `add_tasks` | 入力欄と同じ文、またはフィールド指定で追加 | +| `edit_task` | タイトル、プロジェクト、期限、URL、タグ、MUST、確認待ち、親 | +| `set_task_status` | 完了 / 未完了。子が残っている親は cascade が必要 | +| `delete_tasks` | 削除(子孫も消える) | +| `reorder_tasks` | 未完了の並び替え | +| `reorder_projects` | プロジェクト見出しの並び替え | +| `list_tags` / `save_tag` / `delete_tag` | タグマスタ | +| `get_settings` / `update_settings` | 表示設定とフォーカスの分数 | +| `copy_list` | 画面のコピーと同じ Markdown | +| `daily_result` | リザルト集計 | +| `record_focus` | フォーカス時間を記録 | +| `clear_all_data` | タスクとタグを全削除(`confirm: "delete"`) | +| `google_sync` | 連携の状態、同期スイッチ、今すぐ同期、タスクリスト一覧 | + +クライアントシークレットとブラウザでの Google 接続はアプリの設定画面だけで行う。 ## HTTP API を直接叩く 全リクエストに `Authorization: Bearer ` が必須 (無いと 401)。 +既存の `GET/POST /tasks`、`PATCH/DELETE /tasks/:id` はそのまま使える。 +追加分は `POST /tasks/batch`、`POST /tasks/status`、`POST /tasks/delete`、 +`POST /tasks/reorder`、`POST /projects/reorder`、`/tags`、`/settings`、 +`GET /export`、`GET /result`、`POST /focus`、`POST /data/clear`、`/google`。 ```sh BASE=http://127.0.0.1:$(sed -n 's/.*"port": *\([0-9]*\).*/\1/p' ~/Library/Application\ Support/com.fasttask.desktop/api.json) @@ -68,3 +93,4 @@ sh mcp/smoke.sh ``` トークン無し → 401、作成 → 一覧に出る、を確認して後片付けする。 +プロトコルの確認は `pnpm test` の中の `mcp/protocol.test.js` が行う。 diff --git a/mcp/api.js b/mcp/api.js new file mode 100644 index 0000000..08044cf --- /dev/null +++ b/mcp/api.js @@ -0,0 +1,80 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export function appDataDir() { + if (process.env.FASTTASK_APP_DATA_DIR) return process.env.FASTTASK_APP_DATA_DIR; + if (process.platform === "darwin") { + return join(homedir(), "Library", "Application Support", "com.fasttask.desktop"); + } + if (process.platform === "win32") { + return join(process.env.APPDATA || "", "com.fasttask.desktop"); + } + return join(homedir(), ".local", "share", "com.fasttask.desktop"); +} + +export function config() { + let port = process.env.FASTTASK_PORT; + let token = process.env.FASTTASK_TOKEN; + if (!port || !token) { + const file = JSON.parse(readFileSync(join(appDataDir(), "api.json"), "utf8")); + port ||= String(file.port ?? ""); + token ||= file.token; + } + if (!port || !token) { + throw new Error("FastTask の接続先が分かりません。アプリを起動してください。"); + } + return { base: `http://127.0.0.1:${port}`, token }; +} + +export async function api(method, path, body) { + const { base, token } = config(); + let response; + try { + response = await fetch(base + path, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`FastTask に接続できません。アプリが起動しているか確認してください (${message})`); + } + const text = await response.text(); + let parsed = null; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + parsed = { error: text }; + } + } + if (!response.ok) { + const message = parsed && typeof parsed === "object" && parsed.error + ? parsed.error + : `HTTP ${response.status}`; + throw new Error(String(message)); + } + return parsed; +} + +export function textResult(data) { + const text = typeof data === "string" ? data : JSON.stringify(data, null, 2); + return { content: [{ type: "text", text }] }; +} + +export async function call(method, path, body) { + try { + return textResult(await api(method, path, body)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { isError: true, content: [{ type: "text", text: message }] }; + } +} + +export function defined(fields) { + return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)); +} diff --git a/mcp/protocol.test.js b/mcp/protocol.test.js new file mode 100644 index 0000000..e3650f2 --- /dev/null +++ b/mcp/protocol.test.js @@ -0,0 +1,220 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import net from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const bin = path.join(root, "src-tauri/core/target/debug/fasttask-api"); +const token = "test-token"; + +let dir; +let apiProc; +let client; + +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + server.close(() => resolve(port)); + }); + }); +} + +function waitFor(stream, needle) { + return new Promise((resolve, reject) => { + let buf = ""; + const timer = setTimeout(() => reject(new Error(`timed out waiting for ${needle}`)), 8000); + stream.on("data", (chunk) => { + buf += chunk.toString(); + if (buf.includes(needle)) { + clearTimeout(timer); + resolve(); + } + }); + }); +} + +function textOf(result) { + const block = result.content?.find((item) => item.type === "text"); + return block?.text ?? ""; +} + +async function tool(name, args = {}) { + return client.callTool({ name, arguments: args }); +} + +test.before(async () => { + execFileSync( + "cargo", + ["+stable", "build", "--manifest-path", "src-tauri/core/Cargo.toml", "--bin", "fasttask-api"], + { cwd: root, stdio: "inherit" }, + ); + dir = await mkdtemp(path.join(tmpdir(), "fasttask-mcp-")); + const port = await freePort(); + apiProc = spawn(bin, [], { + env: { + ...process.env, + FASTTASK_DIR: dir, + FASTTASK_PORT: String(port), + FASTTASK_TOKEN: token, + }, + stdio: ["ignore", "ignore", "pipe"], + }); + await waitFor(apiProc.stderr, "listening"); + client = new Client({ name: "fasttask-protocol-test", version: "0.0.0" }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [path.join(root, "mcp/server.js")], + env: { + ...process.env, + FASTTASK_PORT: String(port), + FASTTASK_TOKEN: token, + }, + stderr: "pipe", + }); + await client.connect(transport); +}); + +test.after(async () => { + await client?.close().catch(() => {}); + apiProc?.kill(); + if (dir) await rm(dir, { recursive: true, force: true }); +}); + +let gate = Promise.resolve(); +function testSerial(name, fn) { + test(name, async () => { + const turn = gate; + let release; + gate = new Promise((resolve) => { + release = resolve; + }); + await turn; + try { + await fn(); + } finally { + release(); + } + }); +} + +testSerial("initialize exposes tools and answers ping", async () => { + const capabilities = client.getServerCapabilities(); + assert.ok(capabilities?.tools); + assert.match(client.getInstructions() ?? "", /search_tasks/); + assert.equal(client.getServerVersion()?.name, "fasttask"); + const pong = await client.ping(); + assert.deepEqual(pong, {}); +}); + +testSerial("tools/list has schemas an agent can fill in", async () => { + const listed = await client.listTools(); + const names = listed.tools.map((tool) => tool.name); + for (const name of [ + "search_tasks", + "add_tasks", + "edit_task", + "set_task_status", + "delete_tasks", + "reorder_tasks", + "reorder_projects", + "save_tag", + "copy_list", + "daily_result", + "google_sync", + ]) { + assert.ok(names.includes(name), name); + } + for (const tool of listed.tools) { + assert.equal(typeof tool.description, "string"); + assert.ok(tool.description.length > 10); + assert.equal(tool.inputSchema.type, "object"); + } +}); + +testSerial("tools/call creates, edits, completes, and deletes like the app", async () => { + const added = await tool("add_tasks", { text: ":work 資料 tomorrow, レビュー" }); + assert.notEqual(added.isError, true, textOf(added)); + const created = JSON.parse(textOf(added)).tasks; + assert.equal(created.length, 2); + assert.equal(created[0].projectName, "work"); + assert.equal(created[1].projectName, "work"); + const parent = created[0].id; + const child = created[1].id; + + const found = JSON.parse(textOf(await tool("search_tasks", { query: "レビュー", project: "work", status: "todo" }))); + assert.equal(found.count, 1); + + const nested = await tool("edit_task", { id: child, parentId: parent }); + assert.notEqual(nested.isError, true, textOf(nested)); + assert.equal(JSON.parse(textOf(nested)).parentId, parent); + + const blocked = await tool("set_task_status", { id: parent, status: "done" }); + assert.equal(blocked.isError, true); + assert.match(textOf(blocked), /children pending/); + + const done = await tool("set_task_status", { id: parent, status: "done", cascade: true }); + assert.notEqual(done.isError, true, textOf(done)); + + const markdown = textOf(await tool("copy_list", { target: "todo" })); + assert.match(markdown, /未完了タスク|Todo Tasks/); + + const result = JSON.parse(textOf(await tool("daily_result"))); + assert.equal(typeof result.completedCount, "number"); + assert.ok(result.shareText); + + const removed = await tool("delete_tasks", { id: parent }); + assert.notEqual(removed.isError, true, textOf(removed)); + const also = JSON.parse(textOf(removed)).alsoDeleted; + assert.ok(also.includes(child)); +}); + +testSerial("settings, focus, tags, and google status stay free of secrets", async () => { + const settings = await tool("update_settings", { theme: "dark", language: "ja", lastFocusMinutes: 30 }); + assert.notEqual(settings.isError, true, textOf(settings)); + assert.equal(JSON.parse(textOf(await tool("get_settings"))).theme, "dark"); + + const focus = JSON.parse(textOf(await tool("record_focus", { minutes: 12 }))); + assert.ok(focus.focusMinutes >= 12); + + const tag = JSON.parse(textOf(await tool("save_tag", { name: "開発", keywords: ["資料"] }))); + assert.equal(tag.name, "開発"); + const tags = JSON.parse(textOf(await tool("list_tags"))); + assert.ok(tags.tags.some((item) => item.id === tag.id)); + + await writeFile( + path.join(dir, "google.json"), + JSON.stringify({ + client_secret: "super-secret-value", + refresh_token: "refresh-value", + client_id: "client", + email: "a@b.c", + auto_push: false, + }), + ); + const status = await tool("google_sync", { action: "status" }); + assert.notEqual(status.isError, true, textOf(status)); + const body = textOf(status); + assert.doesNotMatch(body, /super-secret-value/); + assert.doesNotMatch(body, /refresh-value/); + assert.equal(JSON.parse(body).connected, true); + const refused = await tool("google_sync", { action: "update", clientId: "client" }); + assert.notEqual(refused.isError, true, textOf(refused)); +}); + +testSerial("unknown tool and invalid arguments come back as tool errors", async () => { + const missing = await tool("not_a_tool", {}); + assert.equal(missing.isError, true); + const invalid = await tool("add_tasks", {}); + assert.equal(invalid.isError, true); + assert.match(textOf(invalid), /text|tasks/); +}); diff --git a/mcp/server.js b/mcp/server.js index 91e810c..861f130 100644 --- a/mcp/server.js +++ b/mcp/server.js @@ -1,135 +1,21 @@ #!/usr/bin/env node -// FastTask MCP stdio bridge -> ローカルHTTP API。依存なし (Node 18+ の fetch を使用)。 -import { readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { createInterface } from "node:readline"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { registerTools } from "./tools.js"; -const APP_DATA_DIR = - process.env.FASTTASK_APP_DATA_DIR || - (process.platform === "darwin" - ? join(homedir(), "Library", "Application Support", "com.fasttask.desktop") - : process.platform === "win32" - ? join(process.env.APPDATA || "", "com.fasttask.desktop") - : join(homedir(), ".local", "share", "com.fasttask.desktop")); +const instructions = [ + "FastTask はローカルのタスクアプリ。操作の前に search_tasks で現状を確認する。", + "プロジェクトはタイトル先頭の `:名前`。小タスクは同じプロジェクトの parentId。深さは5段まで。", + "親を完了するときは、未完了の子があると children pending になる。子から終えるか、cascade を true にする。", + "delete_tasks は子孫も消す。clear_all_data は confirm に delete が必要。", + "Google のシークレットとブラウザ接続はアプリの設定画面で行う。google_sync は状態の確認、同期スイッチ、今すぐ同期に使う。", +].join("\n"); -function config() { - let port = process.env.FASTTASK_PORT; - let token = process.env.FASTTASK_TOKEN; - if (!port || !token) { - const f = JSON.parse(readFileSync(join(APP_DATA_DIR, "api.json"), "utf8")); - port ||= f.port; - token ||= f.token; - } - return { base: `http://127.0.0.1:${port}`, token }; -} +const server = new McpServer( + { name: "fasttask", version: "1.4.1" }, + { instructions }, +); -async function api(method, path, body) { - const { base, token } = config(); - const res = await fetch(base + path, { - method, - headers: { - Authorization: `Bearer ${token}`, - ...(body ? { "Content-Type": "application/json" } : {}), - }, - body: body ? JSON.stringify(body) : undefined, - }); - const text = await res.text(); - if (!res.ok) throw new Error(`${res.status}: ${text}`); - return text; -} +registerTools(server); -const TOOLS = [ - { - name: "list_tasks", - description: "FastTask のタスク一覧を取得する", - inputSchema: { - type: "object", - properties: { status: { type: "string", enum: ["todo", "done"] } }, - }, - run: (a) => api("GET", `/tasks${a.status ? `?status=${encodeURIComponent(a.status)}` : ""}`), - }, - { - name: "create_task", - description: "FastTask にタスクを作成する", - inputSchema: { - type: "object", - properties: { - title: { type: "string" }, - dueDate: { type: "string", description: "ISO 日付 (YYYY-MM-DD)" }, - url: { type: "string" }, - tags: { type: "array", items: { type: "string" } }, - }, - required: ["title"], - }, - run: (a) => api("POST", "/tasks", a), - }, - { - name: "update_task", - description: "タスクを部分更新する (title / status / dueDate / url / tags)", - inputSchema: { - type: "object", - properties: { - id: { type: "string" }, - title: { type: "string" }, - status: { type: "string", enum: ["todo", "done"] }, - dueDate: { type: "string" }, - url: { type: "string" }, - tags: { type: "array", items: { type: "string" } }, - }, - required: ["id"], - }, - run: ({ id, ...patch }) => api("PATCH", `/tasks/${encodeURIComponent(id)}`, patch), - }, - { - name: "delete_task", - description: "タスクを削除する (子タスクも削除される)", - inputSchema: { - type: "object", - properties: { id: { type: "string" } }, - required: ["id"], - }, - run: ({ id }) => api("DELETE", `/tasks/${encodeURIComponent(id)}`), - }, -]; - -function send(msg) { - process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...msg }) + "\n"); -} - -async function handle(req) { - switch (req.method) { - case "initialize": - return { - protocolVersion: req.params?.protocolVersion || "2024-11-05", - capabilities: { tools: {} }, - serverInfo: { name: "fasttask", version: "0.1.0" }, - }; - case "tools/list": - return { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) }; - case "tools/call": { - const tool = TOOLS.find((t) => t.name === req.params?.name); - if (!tool) throw new Error(`unknown tool: ${req.params?.name}`); - const text = await tool.run(req.params.arguments || {}); - return { content: [{ type: "text", text }] }; - } - default: - throw new Error(`unknown method: ${req.method}`); - } -} - -createInterface({ input: process.stdin }).on("line", async (line) => { - if (!line.trim()) return; - let req; - try { - req = JSON.parse(line); - } catch { - return; - } - try { - const result = await handle(req); - if (req.id !== undefined) send({ id: req.id, result }); - } catch (e) { - if (req.id !== undefined) send({ id: req.id, error: { code: -32000, message: String(e.message || e) } }); - } -}); +await server.connect(new StdioServerTransport()); diff --git a/mcp/tools.js b/mcp/tools.js new file mode 100644 index 0000000..21886c6 --- /dev/null +++ b/mcp/tools.js @@ -0,0 +1,441 @@ +import { z } from "zod"; +import { api, call, defined } from "./api.js"; + +const id = z.string().min(1).describe("タスクID"); +const due = z.string().nullable().optional().describe("期限。YYYY-MM-DD。null で消す"); +const tags = z.array(z.string()).optional().describe("タグのIDまたは名前"); + +function query(params) { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === "") continue; + if (Array.isArray(value)) { + for (const item of value) search.append(key, String(item)); + } else { + search.set(key, String(value)); + } + } + const text = search.toString(); + return text ? `?${text}` : ""; +} + +export function registerTools(server) { + server.registerTool( + "search_tasks", + { + description: + "タスクを探す。画面の検索・タグ絞り込み・プロジェクト・期限・MUST/PENDING に相当する。未完了も完了も対象にできる。並びは画面と同じ親子順。", + inputSchema: { + query: z.string().optional().describe("空白区切りのAND検索。タイトル本文・プロジェクト名・タグ名"), + status: z.enum(["todo", "done"]).optional().describe("未完了 todo / 完了 done。省略時は両方"), + project: z.string().optional().describe("プロジェクト名。未分類は none"), + tags: tags.describe("いずれかに一致すればよい(OR)"), + due: z.enum(["today", "overdue", "upcoming", "none"]).or(z.string()).optional() + .describe("today / overdue / upcoming / none / YYYY-MM-DD"), + must: z.boolean().optional().describe("MUST(isMinimum、または今日が期限の未完了)"), + pending: z.boolean().optional().describe("確認待ち"), + parentId: z.string().optional().describe("親タスクID。直下だけ。ルートは none"), + }, + annotations: { readOnlyHint: true }, + }, + async (args) => call("GET", `/tasks${query({ + q: args.query, + status: args.status, + project: args.project, + tag: args.tags, + due: args.due, + must: args.must, + pending: args.pending, + parent: args.parentId, + order: "ui", + })}`), + ); + + server.registerTool( + "get_task", + { + description: "タスクを1件取得する。子タスクのIDは parentId で search_tasks する。", + inputSchema: { id }, + annotations: { readOnlyHint: true }, + }, + async (args) => call("GET", `/tasks/${encodeURIComponent(args.id)}`), + ); + + server.registerTool( + "add_tasks", + { + description: + "タスクを追加する。text は画面の入力欄と同じ(`:プロジェクト タイトル, もう一件 https://... 明日`)。カンマで複数になり、2件目以降は先頭のプロジェクトを引き継ぐ。tasks はフィールドを分けて渡すときに使う。", + inputSchema: { + text: z.string().optional().describe("入力欄と同じ1行。複数はカンマ区切り"), + tasks: z.array(z.object({ + title: z.string().describe("タイトル"), + project: z.string().optional().describe("プロジェクト名(空白なし)"), + dueDate: z.string().optional().describe("YYYY-MM-DD"), + url: z.string().optional(), + tags: tags, + parentId: z.string().optional().describe("同じプロジェクトの親。深さは5段まで"), + must: z.boolean().optional().describe("MUST(最低限)"), + pending: z.boolean().optional().describe("確認待ち"), + })).optional(), + dueDate: z.string().optional().describe("text モードの期限。省略時は文中の日付"), + url: z.string().optional(), + tags: tags, + project: z.string().optional(), + parentId: z.string().optional(), + must: z.boolean().optional(), + pending: z.boolean().optional(), + }, + }, + async (args) => { + if (args.text && args.tasks) { + return { isError: true, content: [{ type: "text", text: "text と tasks は同時に指定できません" }] }; + } + if (args.text) { + return call("POST", "/tasks/batch", defined({ + text: args.text, + dueDate: args.dueDate, + url: args.url, + tags: args.tags, + projectName: args.project, + parentId: args.parentId, + isMinimum: args.must, + isPending: args.pending, + })); + } + if (args.tasks?.length) { + return call("POST", "/tasks/batch", { + tasks: args.tasks.map((task) => defined({ + title: task.title, + projectName: task.project, + dueDate: task.dueDate, + url: task.url, + tags: task.tags, + parentId: task.parentId, + isMinimum: task.must, + isPending: task.pending, + })), + }); + } + return { isError: true, content: [{ type: "text", text: "text または tasks が必要です" }] }; + }, + ); + + server.registerTool( + "edit_task", + { + description: + "タスクのタイトル、プロジェクト、期限、URL、タグ、MUST、確認待ち、親を変える。タイトルは画面の編集と同じく `:プロジェクト`、URL、日付を解釈する。MUST と確認待ちは子タスクにも伝わる。parentId を null にするとインデントを外す。", + inputSchema: { + id, + title: z.string().optional(), + parseTitle: z.boolean().optional().describe("false のときタイトル文字列をそのまま保存する。既定は true"), + project: z.string().nullable().optional().describe("プロジェクト名。null で未分類"), + dueDate: due, + url: z.string().nullable().optional().describe("null でURLを消す"), + tags: tags.describe("タグをこの一覧で置き換える"), + addTags: tags.describe("既存タグに足す"), + removeTags: tags.describe("既存タグから外す"), + must: z.boolean().optional(), + pending: z.boolean().optional(), + parentId: z.string().nullable().optional().describe("同じプロジェクトの親。null で外す"), + }, + }, + async (args) => { + const patch = defined({ + title: args.title, + parseTitle: args.title === undefined ? undefined : args.parseTitle !== false, + projectName: args.project, + dueDate: args.dueDate, + url: args.url, + tags: args.tags, + isMinimum: args.must, + isPending: args.pending, + parentId: args.parentId, + }); + if (args.addTags || args.removeTags) { + try { + const [current, listed] = await Promise.all([ + api("GET", `/tasks/${encodeURIComponent(args.id)}`), + api("GET", "/tags"), + ]); + const known = listed.tags ?? []; + const resolve = (value) => { + const hit = known.find((tag) => tag.id === value || tag.name === value); + return hit ? hit.id : value; + }; + let next = (Array.isArray(args.tags) ? args.tags : (current.tags ?? [])).map(resolve); + for (const tag of args.addTags ?? []) { + const resolved = resolve(tag); + if (!next.includes(resolved)) next.push(resolved); + } + const removing = new Set((args.removeTags ?? []).map(resolve)); + next = next.filter((tag) => !removing.has(resolve(tag))); + patch.tags = next; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { isError: true, content: [{ type: "text", text: message }] }; + } + } + return call("PATCH", `/tasks/${encodeURIComponent(args.id)}`, patch); + }, + ); + + server.registerTool( + "set_task_status", + { + description: + "完了または未完了に戻す。画面のチェックと同じく、未完了の子がいる親は cascade なしでは完了できない(children pending)。cascade を true にすると子もまとめて完了する。未完了へ戻すのは指定したタスクだけで、子は戻さない。", + inputSchema: { + id: z.string().optional(), + ids: z.array(z.string()).optional().describe("複数まとめて変える"), + status: z.enum(["done", "todo", "toggle"]).describe("done 完了 / todo 未完了 / toggle 反転"), + cascade: z.boolean().optional().describe("完了時に子孫も完了する。既定は false"), + }, + }, + async (args) => call("POST", "/tasks/status", defined({ + id: args.id, + ids: args.ids, + status: args.status, + cascade: args.cascade, + })), + ); + + server.registerTool( + "delete_tasks", + { + description: "タスクを削除する。子タスクもまとめて消える。画面の削除と同じ。", + inputSchema: { + id: z.string().optional(), + ids: z.array(z.string()).optional(), + }, + annotations: { destructiveHint: true }, + }, + async (args) => call("POST", "/tasks/delete", defined({ id: args.id, ids: args.ids })), + ); + + server.registerTool( + "reorder_tasks", + { + description: + "未完了タスクの並びを変える。ids は新しい順のタスクID。親より前に動いた子は、画面のドラッグと同じく親子が外れる。", + inputSchema: { + ids: z.array(z.string()).min(1).describe("新しい順のタスクID"), + }, + }, + async (args) => call("POST", "/tasks/reorder", { ids: args.ids }), + ); + + server.registerTool( + "reorder_projects", + { + description: "プロジェクト見出しの並びを変える。未分類は null。一覧に無いプロジェクトは末尾に残る。", + inputSchema: { + projects: z.array(z.string().nullable()).describe("プロジェクト名の新しい順。未分類は null"), + }, + }, + async (args) => call("POST", "/projects/reorder", { projects: args.projects }), + ); + + server.registerTool( + "list_tags", + { + description: "タグマスタ(名前、色、自動付与キーワード)を取得する。", + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, + async () => call("GET", "/tags"), + ); + + server.registerTool( + "save_tag", + { + description: + "タグを作る、または名前・色・キーワードを更新する。id があればそのタグ、なければ同じ名前のタグを更新し、無ければ新規作成。キーワードに合う既存タスクへタグが付く。", + inputSchema: { + id: z.string().optional(), + name: z.string().optional().describe("新規のときは必須"), + color: z.string().optional().describe("#RRGGBB。省略時はグレー"), + keywords: z.array(z.string()).optional().describe("タイトルに含まれると自動で付く語"), + }, + }, + async (args) => { + const body = defined({ name: args.name, color: args.color, keywords: args.keywords }); + if (args.id) return call("PATCH", `/tags/${encodeURIComponent(args.id)}`, body); + try { + const listed = await api("GET", "/tags"); + const same = (listed.tags ?? []).filter((tag) => tag.name === args.name); + if (same.length === 1) { + return call("PATCH", `/tags/${encodeURIComponent(same[0].id)}`, body); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { isError: true, content: [{ type: "text", text: message }] }; + } + return call("POST", "/tags", body); + }, + ); + + server.registerTool( + "delete_tag", + { + description: "タグを削除し、全タスクからそのタグを外す。", + inputSchema: { + id: z.string().optional(), + name: z.string().optional(), + }, + annotations: { destructiveHint: true }, + }, + async (args) => { + let tagId = args.id; + if (!tagId && args.name) { + try { + const listed = await api("GET", "/tags"); + const same = (listed.tags ?? []).filter((tag) => tag.name === args.name); + if (same.length !== 1) { + return { isError: true, content: [{ type: "text", text: same.length === 0 ? "tag not found" : "tag name is ambiguous" }] }; + } + tagId = same[0].id; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { isError: true, content: [{ type: "text", text: message }] }; + } + } + if (!tagId) { + return { isError: true, content: [{ type: "text", text: "id or name is required" }] }; + } + return call("DELETE", `/tags/${encodeURIComponent(tagId)}`); + }, + ); + + server.registerTool( + "get_settings", + { + description: "表示・テーマ・言語・フォーカス記録などの設定を取得する。", + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, + async () => call("GET", "/settings"), + ); + + server.registerTool( + "update_settings", + { + description: + "設定を部分更新する。テーマ、言語、グループ表示、コピー時のグループ、期限表示、タグ、URL、効果音、フォーカスの分数、フォーカスのショートカット文字列。ショートカットのOS登録は起動中のアプリが受け取る。", + inputSchema: { + theme: z.enum(["light", "dark", "system"]).optional(), + language: z.enum(["ja", "en"]).optional(), + groupingEnabled: z.boolean().optional().describe("プロジェクト見出し"), + copyGroupingEnabled: z.boolean().optional().describe("コピー時にプロジェクト見出しを付ける"), + showDueDate: z.boolean().optional(), + tagsEnabled: z.boolean().optional(), + urlEnabled: z.boolean().optional(), + soundEnabled: z.boolean().optional(), + focusShortcut: z.string().optional().describe("例: CommandOrControl+Shift+F。空文字で解除"), + lastFocusMinutes: z.number().int().min(1).max(120).optional(), + }, + }, + async (args) => call("PATCH", "/settings", defined(args)), + ); + + server.registerTool( + "copy_list", + { + description: + "画面のコピーメニューと同じ Markdown を返す。today は今日完了、all は完了全部、todo は未完了(親子のインデント付き)。", + inputSchema: { + target: z.enum(["today", "all", "todo"]), + grouping: z.boolean().optional(), + includeUrl: z.boolean().optional(), + language: z.enum(["ja", "en"]).optional(), + }, + annotations: { readOnlyHint: true }, + }, + async (args) => { + const result = await call("GET", `/export${query({ + target: args.target, + grouping: args.grouping, + url: args.includeUrl, + lang: args.language, + })}`); + if (result.isError) return result; + try { + const parsed = JSON.parse(result.content[0].text); + return { content: [{ type: "text", text: parsed.markdown ?? result.content[0].text }] }; + } catch { + return result; + } + }, + ); + + server.registerTool( + "daily_result", + { + description: "画面のリザルトと同じ集計。完了数、達成率、MUST、フォーカス時間、連続日数、今日のタイプ、共有用テキスト。", + inputSchema: { + date: z.string().optional().describe("YYYY-MM-DD。省略時は今日"), + }, + annotations: { readOnlyHint: true }, + }, + async (args) => call("GET", `/result${query({ date: args.date })}`), + ); + + server.registerTool( + "record_focus", + { + description: "フォーカス時間をその日の記録に足す。画面でタイマーを終えたときに保存されるのと同じ項目。分単位。", + inputSchema: { + minutes: z.number().positive().max(1440), + date: z.string().optional().describe("YYYY-MM-DD。省略時は今日"), + }, + }, + async (args) => call("POST", "/focus", defined({ minutes: args.minutes, date: args.date })), + ); + + server.registerTool( + "clear_all_data", + { + description: "タスクとタグをすべて削除する。設定は残る。confirm に delete と渡したときだけ実行する。", + inputSchema: { + confirm: z.literal("delete").describe("実行するときは delete"), + }, + annotations: { destructiveHint: true }, + }, + async (args) => call("POST", "/data/clear", { confirm: args.confirm }), + ); + + server.registerTool( + "google_sync", + { + description: + "Google カレンダー / Google ToDo 連携。status は接続状態、update は同期スイッチとカレンダーID・タスクリストID、push は今すぐ同期、tasklists はタスクリスト一覧。クライアントシークレットと接続(ブラウザログイン)はアプリの設定画面だけで行う。", + inputSchema: { + action: z.enum(["status", "update", "push", "tasklists"]), + autoPush: z.boolean().optional(), + pushCalendar: z.boolean().optional(), + pushTasks: z.boolean().optional(), + calendarId: z.string().optional(), + tasklistId: z.string().optional(), + clientId: z.string().optional().describe("OAuth クライアントID。シークレットは受け取らない"), + }, + }, + async (args) => { + if (args.action === "status") return call("GET", "/google"); + if (args.action === "push") return call("POST", "/google/push"); + if (args.action === "tasklists") return call("GET", "/google/tasklists"); + const patch = defined({ + autoPush: args.autoPush, + pushCalendar: args.pushCalendar, + pushTasks: args.pushTasks, + calendarId: args.calendarId, + tasklistId: args.tasklistId, + clientId: args.clientId, + }); + if (Object.keys(patch).length === 0) { + return { isError: true, content: [{ type: "text", text: "update する項目がありません" }] }; + } + return call("PATCH", "/google", patch); + }, + ); +} diff --git a/package.json b/package.json index b2b664a..b1646fa 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", + "test": "node --test src/utils/*.test.ts src/windowClose.test.ts src/externalData.test.ts scripts/release-notes.test.cjs mcp/protocol.test.js", "tauri": "tauri", "release": "pnpm tauri build && bash scripts/patch-dmg.sh" }, @@ -15,6 +16,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@modelcontextprotocol/sdk": "^1.32.2", "@tauri-apps/api": "^2", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-fs": "^2.5.1", @@ -26,6 +28,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "uuid": "^14.0.0", + "zod": "^4.6.7", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d9f09b..5cd88d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.6) + '@modelcontextprotocol/sdk': + specifier: ^1.32.2 + version: 1.32.2(zod@4.6.7) '@tauri-apps/api': specifier: ^2 version: 2.11.0 @@ -50,6 +53,9 @@ importers: uuid: specifier: ^14.0.0 version: 14.0.0 + zod: + specifier: ^4.6.7 + version: 4.6.7 zustand: specifier: ^5.0.14 version: 5.0.14(@types/react@19.2.15)(react@19.2.6) @@ -352,6 +358,12 @@ packages: cpu: [x64] os: [win32] + '@hono/node-server@2.1.7': + resolution: {integrity: sha512-Wr0E6S+v2Bw9FkpGNP5BQ5QbJvclUa06UVehd65WFZe6XeJwbrKQQ5wPDevc0IC+QyzbO7v3Llh5uo45IZcSnA==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 || ^5.0.0-0 + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -368,6 +380,16 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@modelcontextprotocol/sdk@1.32.2': + resolution: {integrity: sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -661,6 +683,21 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -687,6 +724,10 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + body-parser@2.4.0: + resolution: {integrity: sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==} + engines: {node: '>=18'} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -696,6 +737,18 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + camelcase-css@2.0.1: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} @@ -711,9 +764,37 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -734,19 +815,42 @@ packages: supports-color: optional: true + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.364: resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} + encodeurl@2.0.2: + resolution: {integrity: sha512-ddmxiyS6ETq/v5/+mdA4fykK7JgEmqcShrGmQFTaKf5o43tL8XLuMzNLLsspuIC3/qGupS7Ci2TF+feJRkYY0w==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + es-errors@1.3.0: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -756,10 +860,41 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + express-rate-limit@8.7.4: + resolution: {integrity: sha512-LKBXzX700wz3zAGNNmVnjRI5mEbYkLv7UbMI4+9Xa+JPcg2UZh6AgY/2CZh9RrJjFM5ttC/N1lD+KzyFoZITCw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.3.0: + resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -776,9 +911,21 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -791,6 +938,14 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -799,10 +954,41 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hono@4.13.18: + resolution: {integrity: sha512-H9q/E1XizaWstJxUW881dmLxEj+t/TrGGnyxINkm4+sK0qUwsMtFdo2BWI9Vs3cIS19MU6b6iCijoUoEf4f3DA==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.7.6: + resolution: {integrity: sha512-00LYkM6byILa9eQIx6qoaXCM40xTLd56AZ4vyaPW3YTyb49VZYPo5xsiAq4tuQ54GH4R7gVdYScJ1B7B77PuTg==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -823,10 +1009,19 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jiti@1.21.7: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jose@6.2.17: + resolution: {integrity: sha512-wOuK9V1yujQFH5S0ZWmLcDZN7+IKh9bUCC2ZPCf2HDlol3WWHy+PPyNjEI75FXRLMp3vDKQcupP59xhVU95hcg==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -835,6 +1030,12 @@ packages: engines: {node: '>=6'} hasBin: true + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -850,6 +1051,18 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -858,6 +1071,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -869,6 +1090,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + node-releases@2.0.46: resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} engines: {node: '>=18'} @@ -885,9 +1110,31 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.2: + resolution: {integrity: sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -907,6 +1154,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -954,9 +1205,25 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + + qs@6.17.1: + resolution: {integrity: sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + react-dom@19.2.6: resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} peerDependencies: @@ -977,6 +1244,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} @@ -991,9 +1262,16 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1001,10 +1279,49 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + source-map-js@1.2.2: resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -1034,17 +1351,29 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript@5.8.3: resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} hasBin: true + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -1058,6 +1387,10 @@ packages: resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} hasBin: true + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite@7.3.7: resolution: {integrity: sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1098,9 +1431,25 @@ packages: yaml: optional: true + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + zustand@5.0.14: resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} engines: {node: '>=12.20.0'} @@ -1338,6 +1687,10 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true + '@hono/node-server@2.1.7(hono@4.13.18)': + dependencies: + hono: 4.13.18 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1357,6 +1710,28 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@modelcontextprotocol/sdk@1.32.2(zod@4.6.7)': + dependencies: + '@hono/node-server': 2.1.7(hono@4.13.18) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.3.0 + express-rate-limit: 8.7.4(express@5.3.0) + hono: 4.13.18 + jose: 6.2.17 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.6.7 + zod-to-json-schema: 3.25.2(zod@4.6.7) + transitivePeerDependencies: + - supports-color + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true @@ -1571,6 +1946,22 @@ snapshots: transitivePeerDependencies: - supports-color + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.9 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + any-promise@1.3.0: {} anymatch@3.1.3: @@ -1593,6 +1984,20 @@ snapshots: binary-extensions@2.3.0: {} + body-parser@2.4.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.2 + qs: 6.17.1 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -1605,6 +2010,18 @@ snapshots: node-releases: 2.0.46 update-browserslist-db: 1.2.3(browserslist@4.28.2) + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + camelcase-css@2.0.1: {} caniuse-lite@1.0.30001793: {} @@ -1623,8 +2040,29 @@ snapshots: commander@4.1.1: {} + content-disposition@2.0.1: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -1635,14 +2073,32 @@ snapshots: dependencies: ms: 2.1.3 + depd@2.0.0: {} + didyoumean@1.2.2: {} dlv@1.1.3: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + electron-to-chromium@1.5.364: {} + encodeurl@2.0.2: {} + + es-define-property@1.0.1: {} + es-errors@1.3.0: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -1674,6 +2130,59 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + + express-rate-limit@8.7.4(express@5.3.0): + dependencies: + debug: 4.4.3 + express: 5.3.0 + ip-address: 10.7.6 + transitivePeerDependencies: + - supports-color + + express@5.3.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.4.0 + content-disposition: 2.0.1 + content-type: 2.1.0 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.2 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.17.1 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1682,6 +2191,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-uri@3.1.9: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -1694,8 +2205,23 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.2 + escape-html: 1.0.3 + on-finished: 2.4.2 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + fraction.js@5.3.4: {} + fresh@2.0.0: {} + fsevents@2.3.3: optional: true @@ -1703,6 +2229,24 @@ snapshots: gensync@1.0.0-beta.2: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -1711,10 +2255,34 @@ snapshots: dependencies: is-glob: 4.0.3 + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + hasown@2.0.4: dependencies: function-bind: 1.1.2 + hono@4.13.18: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.7.6: {} + + ipaddr.js@1.9.1: {} + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -1731,12 +2299,22 @@ snapshots: is-number@7.0.0: {} + is-promise@4.0.0: {} + + isexe@2.0.0: {} + jiti@1.21.7: {} + jose@6.2.17: {} + js-tokens@4.0.0: {} jsesc@3.1.0: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json5@2.2.3: {} lilconfig@3.1.3: {} @@ -1747,6 +2325,12 @@ snapshots: dependencies: yallist: 3.1.1 + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -1754,6 +2338,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + ms@2.1.3: {} mz@2.7.0: @@ -1764,6 +2354,10 @@ snapshots: nanoid@3.3.18: {} + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + node-releases@2.0.46: {} normalize-path@3.0.0: {} @@ -1772,8 +2366,24 @@ snapshots: object-hash@3.0.0: {} + object-inspect@1.13.4: {} + + on-finished@2.4.2: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + path-parse@1.0.7: {} + path-to-regexp@8.4.2: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -1784,6 +2394,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + postcss-import@15.1.0(postcss@8.5.26): dependencies: postcss: 8.5.26 @@ -1821,8 +2433,27 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.2 + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.17.1: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + queue-microtask@1.2.3: {} + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + react-dom@19.2.6(react@19.2.6): dependencies: react: 19.2.6 @@ -1840,6 +2471,8 @@ snapshots: dependencies: picomatch: 2.3.2 + require-from-string@2.0.2: {} + resolve@1.22.12: dependencies: es-errors: 1.3.0 @@ -1881,16 +2514,91 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.4 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 + safer-buffer@2.1.2: {} + scheduler@0.27.0: {} semver@6.3.1: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.2 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.2 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + source-map-js@1.2.2: {} + statuses@2.0.2: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -1948,12 +2656,22 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + ts-interface-checker@0.1.13: {} tslib@2.8.1: {} + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + typescript@5.8.3: {} + unpipe@1.0.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -1964,6 +2682,8 @@ snapshots: uuid@14.0.0: {} + vary@1.1.2: {} + vite@7.3.7(jiti@1.21.7): dependencies: esbuild: 0.28.2 @@ -1976,8 +2696,20 @@ snapshots: fsevents: 2.3.3 jiti: 1.21.7 + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrappy@1.0.2: {} + yallist@3.1.1: {} + zod-to-json-schema@3.25.2(zod@4.6.7): + dependencies: + zod: 4.6.7 + + zod@4.6.7: {} + zustand@5.0.14(@types/react@19.2.15)(react@19.2.6): optionalDependencies: '@types/react': 19.2.15