diff --git a/Cargo.lock b/Cargo.lock index 4991e33f5..361b4d36f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -231,19 +231,20 @@ dependencies = [ [[package]] name = "arboard" -version = "3.4.0" +version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb4009533e8ff8f1450a5bcbc30f4242a1d34442221f72314bea1f5dc9c7f89" +checksum = "ac57f2b058a76363e357c056e4f74f1945bf734d37b8b3ef49066c4787dde0fc" dependencies = [ "clipboard-win", - "core-graphics 0.23.1", - "image 0.25.2", + "core-graphics", + "image 0.24.7", "log", - "objc2", - "objc2-app-kit", - "objc2-foundation", + "objc", + "objc-foundation", + "objc_id", "parking_lot 0.12.1", - "windows-sys 0.48.0", + "thiserror", + "winapi", "wl-clipboard-rs", "x11rb", ] @@ -867,15 +868,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block2" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" -dependencies = [ - "objc2", -] - [[package]] name = "blocking" version = "1.6.1" @@ -1245,11 +1237,13 @@ dependencies = [ [[package]] name = "clipboard-win" -version = "5.4.0" +version = "4.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" +checksum = "7191c27c2357d9b7ef96baac1773290d4ca63b24205b82a3fd8a0637afcf0362" dependencies = [ "error-code", + "str-buf", + "winapi", ] [[package]] @@ -1262,8 +1256,8 @@ dependencies = [ "block", "cocoa-foundation", "core-foundation", - "core-graphics 0.22.3", - "foreign-types 0.3.2", + "core-graphics", + "foreign-types", "libc", "objc", ] @@ -1278,7 +1272,7 @@ dependencies = [ "block", "core-foundation", "core-graphics-types", - "foreign-types 0.3.2", + "foreign-types", "libc", "objc", ] @@ -1458,20 +1452,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation", "core-graphics-types", - "foreign-types 0.3.2", - "libc", -] - -[[package]] -name = "core-graphics" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "970a29baf4110c26fedbc7f82107d42c23f7e88e404c4577ed73fe99ff85a212" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "core-graphics-types", - "foreign-types 0.5.0", + "foreign-types", "libc", ] @@ -1483,7 +1464,7 @@ checksum = "3a68b68b3446082644c91ac778bf50cd4104bfb002b5a6a7c44cca5a2c70788b" dependencies = [ "bitflags 1.3.2", "core-foundation", - "foreign-types 0.3.2", + "foreign-types", "libc", ] @@ -2071,15 +2052,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" -[[package]] -name = "dlib" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" -dependencies = [ - "libloading 0.8.5", -] - [[package]] name = "dml" version = "0.1.0" @@ -2199,9 +2171,9 @@ checksum = "e2153bd83ebc09db15bcbdc3e2194d901804952e3dc96967e1cd3b0c5c32d112" [[package]] name = "embed-resource" -version = "2.4.3" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4edcacde9351c33139a41e3c97eb2334351a81a2791bebb0b243df837128f602" +checksum = "f4e24052d7be71f0efb50c201557f6fe7d237cfd5a64fd5bcd7fd8fe32dbbffa" dependencies = [ "cc", "memchr", @@ -2304,9 +2276,13 @@ dependencies = [ [[package]] name = "error-code" -version = "3.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0474425d51df81997e2f90a21591180b38eccf27292d755f3e30750225c175b" +checksum = "64f18991e7bf11e7ffee451b5318b5c1a73c52d0d0ada6e5a3017c8c1ced6a21" +dependencies = [ + "libc", + "str-buf", +] [[package]] name = "event-listener" @@ -2514,28 +2490,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared 0.1.1", -] - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared 0.3.1", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.79", + "foreign-types-shared", ] [[package]] @@ -2544,12 +2499,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - [[package]] name = "form_urlencoded" version = "1.2.1" @@ -2818,12 +2767,12 @@ dependencies = [ [[package]] name = "gethostname" -version = "0.4.3" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +checksum = "c1ebd34e35c46e00bb73e81363248d627782724609fe1b6396f553f68fe3862e" dependencies = [ "libc", - "windows-targets 0.48.5", + "winapi", ] [[package]] @@ -3502,6 +3451,8 @@ dependencies = [ "color_quant", "num-rational", "num-traits", + "png", + "tiff", ] [[package]] @@ -4662,6 +4613,18 @@ dependencies = [ "smallvec", ] +[[package]] +name = "nix" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", +] + [[package]] name = "nix" version = "0.26.4" @@ -4672,7 +4635,6 @@ dependencies = [ "cfg-if", "libc", "memoffset 0.7.1", - "pin-utils", ] [[package]] @@ -4898,61 +4860,6 @@ dependencies = [ "objc_id", ] -[[package]] -name = "objc-sys" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" - -[[package]] -name = "objc2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" -dependencies = [ - "objc-sys", - "objc2-encode", -] - -[[package]] -name = "objc2-app-kit" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb79768a710a9a1798848179edb186d1af7e8a8679f369e4b8d201dd2a034047" -dependencies = [ - "block2", - "objc2", - "objc2-core-data", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-data" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e092bc42eaf30a08844e6a076938c60751225ec81431ab89f5d1ccd9f958d6c" -dependencies = [ - "block2", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-encode" -version = "4.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7891e71393cd1f227313c9379a26a584ff3d7e6e7159e988851f0934c993f0f8" - -[[package]] -name = "objc2-foundation" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfaefe14254871ea16c7d88968c0ff14ba554712a20d76421eec52f0a7fb8904" -dependencies = [ - "block2", - "objc2", -] - [[package]] name = "objc_exception" version = "0.1.2" @@ -5016,7 +4923,7 @@ checksum = "6b8419dc8cc6d866deb801274bba2e6f8f6108c1bb7fcc10ee5ab864931dbb45" dependencies = [ "bitflags 2.4.0", "cfg-if", - "foreign-types 0.3.2", + "foreign-types", "libc", "once_cell", "openssl-macros", @@ -5126,12 +5033,12 @@ dependencies = [ [[package]] name = "os_pipe" -version = "1.2.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ffd2b0a5634335b135d5728d84c5e0fd726954b87111f7506a61c502280d982" +checksum = "c6a252f1f8c11e84b3ab59d7a488e48e4478a93937e027076638c49536204639" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.42.0", ] [[package]] @@ -6120,15 +6027,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "quick-xml" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96a05e2e8efddfa51a84ca47cec303fac86c8541b686d37cac5efc0e094417bc" -dependencies = [ - "memchr", -] - [[package]] name = "quinn" version = "0.11.3" @@ -7681,6 +7579,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "str-buf" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e08d8363704e6c71fc928674353e6b7c23dcea9d82d7012c8faf2a3a025f8d0" + [[package]] name = "string_cache" version = "0.8.4" @@ -7998,7 +7902,7 @@ dependencies = [ "cc", "cocoa", "core-foundation", - "core-graphics 0.22.3", + "core-graphics", "crossbeam-channel", "dispatch", "gdk", @@ -8064,9 +7968,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e33e3ba00a3b05eb6c57ef135781717d33728b48acf914bb05629e74d897d29" +checksum = "570a20223602ad990a30a048f2fdb957ae3e38de3ca9582e04cc09d01e8ccfad" dependencies = [ "anyhow", "bytes", @@ -8085,7 +7989,8 @@ dependencies = [ "http 0.2.9", "ignore", "indexmap 1.9.2", - "nix", + "log", + "nix 0.26.4", "notify-rust", "objc", "once_cell", @@ -8093,6 +7998,7 @@ dependencies = [ "os_info", "os_pipe", "percent-encoding", + "plist", "rand 0.8.5", "raw-window-handle", "regex", @@ -8123,9 +8029,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "1.5.4" +version = "1.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fb5a90a64241ddb7217d3210d844149070a911e87e8a107a707a1d4973f164" +checksum = "586f3e677f940c8bb4f70c52eda05dc59b79e61543f1182de83516810bb8e35d" dependencies = [ "anyhow", "cargo_toml", @@ -8183,7 +8089,7 @@ dependencies = [ [[package]] name = "tauri-plugin-store" version = "0.0.0" -source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#5c249a1a1076ac78cfc0ed4c2857cf7e2540b1cc" +source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#0b59bc7096dfc7ff1b141c31457a2a0a4a5f9ad1" dependencies = [ "log", "serde", @@ -8280,7 +8186,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c58de036c4d2e20717024de2a3c4bf56c301f07b21bc8ef9b57189fce06f1f3b" dependencies = [ - "quick-xml 0.23.1", + "quick-xml", "strum", "windows 0.39.0", ] @@ -9359,75 +9265,61 @@ dependencies = [ ] [[package]] -name = "wayland-backend" -version = "0.3.7" +name = "wayland-client" +version = "0.29.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "056535ced7a150d45159d3a8dc30f91a2e2d588ca0b23f70e56033622b8016f6" +checksum = "3f3b068c05a039c9f755f881dc50f01732214f5685e379829759088967c46715" dependencies = [ - "cc", + "bitflags 1.3.2", "downcast-rs", - "rustix 0.38.31", - "scoped-tls", - "smallvec", + "libc", + "nix 0.24.3", + "wayland-commons", + "wayland-scanner", "wayland-sys", ] [[package]] -name = "wayland-client" -version = "0.31.6" +name = "wayland-commons" +version = "0.29.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3f45d1222915ef1fd2057220c1d9d9624b7654443ea35c3877f7a52bd0a5a2d" +checksum = "8691f134d584a33a6606d9d717b95c4fa20065605f798a3f350d78dced02a902" dependencies = [ - "bitflags 2.4.0", - "rustix 0.38.31", - "wayland-backend", - "wayland-scanner", + "nix 0.24.3", + "once_cell", + "smallvec", + "wayland-sys", ] [[package]] name = "wayland-protocols" -version = "0.31.2" +version = "0.29.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" +checksum = "b950621f9354b322ee817a23474e479b34be96c2e909c14f7bc0100e9a970bc6" dependencies = [ - "bitflags 2.4.0", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-wlr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad1f61b76b6c2d8742e10f9ba5c3737f6530b4c243132c2a2ccc8aa96fe25cd6" -dependencies = [ - "bitflags 2.4.0", - "wayland-backend", + "bitflags 1.3.2", "wayland-client", - "wayland-protocols", + "wayland-commons", "wayland-scanner", ] [[package]] name = "wayland-scanner" -version = "0.31.5" +version = "0.29.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597f2001b2e5fc1121e3d5b9791d3e78f05ba6bfa4641053846248e3a13661c3" +checksum = "8f4303d8fa22ab852f789e75a967f0a2cdc430a607751c0499bada3e451cbd53" dependencies = [ "proc-macro2", - "quick-xml 0.36.1", "quote", + "xml-rs", ] [[package]] name = "wayland-sys" -version = "0.31.5" +version = "0.29.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efa8ac0d8e8ed3e3b5c9fc92c7881406a268e11555abe36493efabe649a29e09" +checksum = "be12ce1a3c39ec7dba25594b97b42cb3195d54953ddb9d3d95a7c3902bc6e9d4" dependencies = [ - "dlib", - "log", "pkg-config", ] @@ -9593,6 +9485,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "winapi-wsapoll" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eafc5f679c576995526e81635d0cf9695841736712b4e892f87abbe6fed3f28" +dependencies = [ + "winapi", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -10040,15 +9941,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "winnow" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae8970b36c66498d8ff1d66685dc86b91b29db0c7739899012f63a63814b4b28" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "0.5.40" @@ -10089,22 +9981,20 @@ dependencies = [ [[package]] name = "wl-clipboard-rs" -version = "0.8.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57af79e973eadf08627115c73847392e6b766856ab8e3844a59245354b23d2fa" +checksum = "981a303dfbb75d659f6612d05a14b2e363c103d24f676a2d44a00d18507a1ad9" dependencies = [ "derive-new", "libc", "log", - "nix", + "nix 0.24.3", "os_pipe", "tempfile", "thiserror", "tree_magic_mini", - "wayland-backend", "wayland-client", "wayland-protocols", - "wayland-protocols-wlr", ] [[package]] @@ -10116,7 +10006,7 @@ dependencies = [ "base64 0.13.1", "block", "cocoa", - "core-graphics 0.22.3", + "core-graphics", "crossbeam-channel", "dunce", "gdk", @@ -10168,20 +10058,25 @@ dependencies = [ [[package]] name = "x11rb" -version = "0.13.1" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" +checksum = "592b4883219f345e712b3209c62654ebda0bb50887f330cbd018d0f654bfd507" dependencies = [ "gethostname", - "rustix 0.38.31", + "nix 0.24.3", + "winapi", + "winapi-wsapoll", "x11rb-protocol", ] [[package]] name = "x11rb-protocol" -version = "0.13.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" +checksum = "56b245751c0ac9db0e006dc812031482784e434630205a93c73cfefcaabeac67" +dependencies = [ + "nix 0.24.3", +] [[package]] name = "xattr" @@ -10216,9 +10111,9 @@ checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" [[package]] name = "zbus" -version = "3.13.1" +version = "3.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c3d77c9966c28321f1907f0b6c5a5561189d1f7311eea6d94180c6be9daab29" +checksum = "675d170b632a6ad49804c8cf2105d7c31eddd3312555cffd4b740e08e97c25e6" dependencies = [ "async-broadcast", "async-executor", @@ -10229,6 +10124,7 @@ dependencies = [ "async-recursion", "async-task", "async-trait", + "blocking", "byteorder", "derivative", "enumflags2", @@ -10237,7 +10133,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.26.4", "once_cell", "ordered-stream", "rand 0.8.5", @@ -10256,16 +10152,15 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "3.13.1" +version = "3.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6e341d12edaff644e539ccbbf7f161601294c9a84ed3d7e015da33155b435af" +checksum = "7131497b0f887e8061b430c530240063d33bf9455fa34438f388a245da69e0a5" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", "regex", "syn 1.0.107", - "winnow 0.4.1", "zvariant_utils", ] diff --git a/README.md b/README.md index be26eb485..9fb3f6f03 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,11 @@ Stump is a free and open source comics, manga and digital book server with OPDS - [License 📝](#license-) -> **🚧 Disclaimer 🚧**: Stump is under active development and is an ongoing **WIP**. Anyone is welcome to try it out, but **DO NOT** expect a fully featured or bug-free experience. If you'd like to contribute and help expedite Stump's first release, please review the [developer guide](#developer-guide-). +> **🚧 Disclaimer 🚧**: Stump is under active development and is an ongoing **WIP**. Anyone is welcome to try it out, but **DO NOT** expect a fully featured or bug-free experience. If you'd like to contribute and help expedite feature development, please review the [developer guide](#developer-guide-). ## Roadmap 🗺 -The following items are the major targets for Stump's first release: +The following items are the major targets for Stump's first stable release: - 📃 Full OPDS + OPDS Page Streaming support - 📕 EPUB, PDF, and CBZ/CBR support @@ -59,7 +59,7 @@ The following items are the major targets for Stump's first release: - 🌏 Language support _(look [here](https://github.com/stumpapp/stump/issues/106))_ - 🌈 And more! -Things you can expect to see after the first release: +Things you can expect to see afterwards: - 🖥️ Cross-platform desktop app _(Windows, Mac, Linux)_ - 📖 [Tachiyomi](https://github.com/stumpapp/tachiyomi-extensions) support diff --git a/apps/expo/package.json b/apps/expo/package.json index dea5ea476..49e16511b 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -28,7 +28,7 @@ "nativewind": "^2.0.11", "react": "18.2.0", "react-dom": "18.2.0", - "react-hook-form": "^7.50.1", + "react-hook-form": "^7.53.0", "react-native": "0.73.4", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "~2.14.0", diff --git a/apps/server/src/routers/api/v1/smart_list.rs b/apps/server/src/routers/api/v1/smart_list.rs index 8104a84bc..73afb9957 100644 --- a/apps/server/src/routers/api/v1/smart_list.rs +++ b/apps/server/src/routers/api/v1/smart_list.rs @@ -113,6 +113,8 @@ pub struct GetSmartListsParams { #[serde(default)] all: Option, #[serde(default)] + mine: Option, + #[serde(default)] search: Option, } @@ -141,11 +143,21 @@ async fn get_smart_lists( )); } + let mine = params.mine.unwrap_or(false); + if query_all && mine { + return Err(APIError::BadRequest( + "Cannot query all and mine at the same time".to_string(), + )); + } + let where_params = chain_optional_iter( [], [ - (!query_all) + // If not querying all, and not querying mine, then we need to filter by access + (!query_all && !mine) .then(|| smart_list_access_for_user(&user, AccessRole::Reader.value())), + // If querying mine, then we need to filter by the user + mine.then(|| smart_list::creator_id::equals(user.id.clone())), params.search.map(|search| { or![ smart_list::name::contains(search.clone()), @@ -174,6 +186,8 @@ pub struct CreateOrUpdateSmartList { pub joiner: Option, #[serde(default)] pub default_grouping: Option, + #[serde(default)] + pub visibility: Option, } #[utoipa::path( @@ -217,6 +231,9 @@ async fn create_smart_list( input.default_grouping.map(|grouping| { smart_list::default_grouping::set(grouping.to_string()) }), + input.visibility.map(|visibility| { + smart_list::visibility::set(visibility.to_string()) + }), ], ), ) @@ -323,6 +340,9 @@ async fn update_smart_list_by_id( input.default_grouping.map(|grouping| { smart_list::default_grouping::set(grouping.to_string()) }), + input.visibility.map(|visibility| { + smart_list::visibility::set(visibility.to_string()) + }), ], ), ) diff --git a/core/src/db/filter/smart_filter.rs b/core/src/db/filter/smart_filter.rs index a743e6082..d239bed51 100644 --- a/core/src/db/filter/smart_filter.rs +++ b/core/src/db/filter/smart_filter.rs @@ -1,6 +1,10 @@ use std::{fmt::Display, str::FromStr}; -use prisma_client_rust::{and, not, or}; +use prisma_client_rust::{ + and, + chrono::{DateTime, FixedOffset}, + not, or, +}; use serde::{Deserialize, Serialize}; use specta::Type; use utoipa::ToSchema; @@ -15,16 +19,13 @@ use smart_filter_gen::generate_smart_filter; // 1. Performance implications. This is mostly because the assumption for each `into_prisma` call is a single param, // which means for relation filters we will have an `is` call each time. I don't yet know how this actually affects // performance in real-world scenarios, but it's something to keep in mind. -// 2. Repetition of logic. There is a lot of repetition in the `into_prisma` definitions, and I think there is a way to (maybe) -// consolidate them into a single macro. I'm not sure if this is possible, but it's worth looking into. This will get exponentially -// worse as things like sorting and sorting on relations are added... :weary: #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema, Type)] #[serde(untagged)] /// A filter for a single value, e.g. `name = "test"` pub enum Filter { /// A simple equals filter, e.g. `name = "test"` - Equals(T), + Equals { equals: T }, /// A simple not filter, e.g. `name != "test"` Not { not: T }, /// A filter for a string that contains a substring, e.g. `name contains "test"`. This should @@ -71,7 +72,7 @@ impl Filter { WhereParam: From>, { match self { - Filter::Equals(value) => equals_fn(value), + Filter::Equals { equals } => equals_fn(equals), Filter::Not { not } => not![equals_fn(not)], Filter::Contains { contains } => contains_fn(contains), Filter::Excludes { excludes } => not![contains_fn(excludes)], @@ -93,7 +94,7 @@ impl Filter { WhereParam: From>, { match self { - Filter::Equals(value) => equals_fn(Some(value)), + Filter::Equals { equals } => equals_fn(Some(equals)), Filter::Not { not } => not![equals_fn(Some(not))], Filter::Contains { contains } => contains_fn(contains), Filter::Excludes { excludes } => not![contains_fn(excludes)], @@ -102,22 +103,20 @@ impl Filter { _ => unreachable!("Numeric filters should be handled elsewhere"), } } -} -impl Filter { pub fn into_numeric_params( self, - equals_fn: fn(i32) -> WhereParam, - gt_fn: fn(i32) -> WhereParam, - gte_fn: fn(i32) -> WhereParam, - lt_fn: fn(i32) -> WhereParam, - lte_fn: fn(i32) -> WhereParam, + equals_fn: fn(T) -> WhereParam, + gt_fn: fn(T) -> WhereParam, + gte_fn: fn(T) -> WhereParam, + lt_fn: fn(T) -> WhereParam, + lte_fn: fn(T) -> WhereParam, ) -> WhereParam where WhereParam: From>, { match self { - Filter::Equals(value) => equals_fn(value), + Filter::Equals { equals } => equals_fn(equals), Filter::Not { not } => not![equals_fn(not)], Filter::NumericFilter(numeric_filter) => match numeric_filter { NumericFilter::Gt { gt } => gt_fn(gt), @@ -138,17 +137,17 @@ impl Filter { pub fn into_optional_numeric_params( self, - equals_fn: fn(Option) -> WhereParam, - gt_fn: fn(i32) -> WhereParam, - gte_fn: fn(i32) -> WhereParam, - lt_fn: fn(i32) -> WhereParam, - lte_fn: fn(i32) -> WhereParam, + equals_fn: fn(Option) -> WhereParam, + gt_fn: fn(T) -> WhereParam, + gte_fn: fn(T) -> WhereParam, + lt_fn: fn(T) -> WhereParam, + lte_fn: fn(T) -> WhereParam, ) -> WhereParam where WhereParam: From>, { match self { - Filter::Equals(value) => equals_fn(Some(value)), + Filter::Equals { equals } => equals_fn(Some(equals)), Filter::Not { not } => not![equals_fn(Some(not))], Filter::NumericFilter(numeric_filter) => match numeric_filter { NumericFilter::Gt { gt } => gt_fn(gt), @@ -217,11 +216,6 @@ impl From<&str> for FilterJoin { } } -// pub struct SmartFilterOrder { -// pub direction: Direction, -// pub order_by: -// } - #[derive(Debug, Clone, Serialize, Deserialize, Type, ToSchema)] #[aliases(SmartFilterSchema = SmartFilter)] pub struct SmartFilter { @@ -230,9 +224,6 @@ pub struct SmartFilter { pub joiner: FilterJoin, } -// TODO: figure out if perhaps macros can come in with the save here. Continuing down this path -// will be INCREDIBLY verbose.. - #[generate_smart_filter] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type, ToSchema)] #[serde(untagged)] @@ -335,7 +326,6 @@ pub enum MediaMetadataSmartFilter { Inkers { inkers: String }, #[is_optional] Editors { editors: String }, - // FIXME: Current implementationm makes it awkward to support numeric filters #[is_optional] AgeRating { age_rating: i32 }, #[is_optional] @@ -352,8 +342,13 @@ pub enum MediaMetadataSmartFilter { #[prisma_table("media")] pub enum MediaSmartFilter { Name { name: String }, + Size { size: i64 }, Extension { extension: String }, + CreatedAt { created_at: DateTime }, + UpdatedAt { updated_at: DateTime }, + Status { status: String }, Path { path: String }, + Pages { pages: i32 }, Metadata { metadata: MediaMetadataSmartFilter }, Series { series: SeriesSmartFilter }, } @@ -437,6 +432,83 @@ mod tests { ); } + #[test] + fn it_serializes_number_correctly() { + let filter: FilterGroup = FilterGroup::And { + and: vec![MediaSmartFilter::Size { + size: Filter::NumericFilter(NumericFilter::Gte { gte: 3000 }), + }], + }; + + let json = serde_json::to_string(&filter).unwrap(); + + assert_eq!(json, r#"{"and":[{"size":{"gte":3000}}]}"#); + } + + #[test] + fn it_deserializes_number_correctly() { + let json = r#"{"or":[{"size":{"gte":3000}}]}"#; + + let filter: FilterGroup = serde_json::from_str(json).unwrap(); + + assert_eq!( + filter, + FilterGroup::Or { + or: vec![MediaSmartFilter::Size { + size: Filter::NumericFilter(NumericFilter::Gte { gte: 3000 }), + }], + } + ); + } + + #[test] + fn it_serializes_range_correctly() { + let filter: FilterGroup = FilterGroup::And { + and: vec![MediaSmartFilter::Metadata { + metadata: MediaMetadataSmartFilter::AgeRating { + age_rating: Filter::NumericFilter(NumericFilter::Range( + NumericRange { + from: 10, + to: 20, + inclusive: true, + }, + )), + }, + }], + }; + + let json = serde_json::to_string(&filter).unwrap(); + + assert_eq!( + json, + r#"{"and":[{"metadata":{"age_rating":{"from":10,"to":20,"inclusive":true}}}]}"# + ); + } + + #[test] + fn it_deserializes_range_correctly() { + let json = r#"{"and":[{"metadata":{"age_rating":{"from":10,"to":20,"inclusive":true}}}]}"#; + + let filter: FilterGroup = serde_json::from_str(json).unwrap(); + + assert_eq!( + filter, + FilterGroup::And { + and: vec![MediaSmartFilter::Metadata { + metadata: MediaMetadataSmartFilter::AgeRating { + age_rating: Filter::NumericFilter(NumericFilter::Range( + NumericRange { + from: 10, + to: 20, + inclusive: true, + }, + )), + }, + }], + } + ); + } + fn default_book(name: &str) -> media::Data { media::Data { id: "test-id".to_string(), diff --git a/docs/pages/faq.md b/docs/pages/faq.md index 528d089fc..bc3e84a51 100644 --- a/docs/pages/faq.md +++ b/docs/pages/faq.md @@ -33,10 +33,6 @@ The hardware requirements vary and should serve **only as a guide**. Generally s **This section will be revisited once the first beta release is out.** -## When will Stump be released? - -Stump is currently under development. The primary developer is working on it in their free time, mostly on the weekends. There aren't many active contributors, so it's hard to say when it will be release ready. Ideally the first release candidate will be ready by the end of 2024. Please consider contributing to the project if you're interested in expediting the development. You can find more information on ways to contribute in the [contributing](/contributing) guide. - ## Can I try it out? I am working on setting up a public demo instance. If it is available, you will find it at [demo.stumpapp.dev](https://demo.stumpapp.dev). The login credentials are: diff --git a/docs/pages/guides/opds.md b/docs/pages/guides/opds.md index be9e583c4..94ab1c05c 100644 --- a/docs/pages/guides/opds.md +++ b/docs/pages/guides/opds.md @@ -20,17 +20,16 @@ The general structure of the URL to connect to your Stump server is: The following clients have been tested with Stump: -| OS | Application | Page Streaming | Issues/Notes | -| ------- | :------------------------------------------------------------------------------------: | -------------: | -------------------------: | -| iOS | [Panels](https://panels.app/) | ✅ | ✨ | +| OS | Application | Page Streaming | Issues/Notes | +| ------- | :------------------------------------------------------------------------------------: | -------------: | -------------------------------------------------------------------------------: | +| iOS | [Panels](https://panels.app/) | ✅ | ✨ | | iOS | [Chunky Reader](https://apps.apple.com/us/app/chunky-comic-reader/id663567628) | ✅ | | -| Android | [Moon+ Reader](https://play.google.com/store/apps/details?id=com.flyersoft.moonreader) | ❌ | Users report OK experience | -| Android | [KyBook 3](http://kybook-reader.com/) | ❌ | No testing at this time | -| Android | [Librera](https://play.google.com/store/apps/details?id=com.foobnix.pdf.reader) | ❌ | Supports covers, categories, and downloads, but shows file names not book titles | -| Android | [Cantook by Aldiko](https://play.google.com/store/apps/details?id=com.aldiko.android) | ❌ | No auth support, does not work. Use 2.0 | -| Linux | [Foliate](https://johnfactotum.github.io/foliate/) | ❌ | Loads cover previews, file names and categories | -| Windows | [Thorium 3](https://thorium.edrlab.org/en/) | ❌ | Loads cover previews, file names and categories | - +| Android | [Moon+ Reader](https://play.google.com/store/apps/details?id=com.flyersoft.moonreader) | ❌ | Users report OK experience | +| Android | [KyBook 3](http://kybook-reader.com/) | ❌ | No testing at this time | +| Android | [Librera](https://play.google.com/store/apps/details?id=com.foobnix.pdf.reader) | ❌ | Supports covers, categories, and downloads, but shows file names not book titles | +| Android | [Cantook by Aldiko](https://play.google.com/store/apps/details?id=com.aldiko.android) | ❌ | No auth support, does not work. Use 2.0 | +| Linux | [Foliate](https://johnfactotum.github.io/foliate/) | ❌ | Loads cover previews, file names and categories | +| Windows | [Thorium 3](https://thorium.edrlab.org/en/) | ❌ | Loads cover previews, file names and categories | If you have any experiences, good or bad, using any of these clients or another client not listed here, please consider updating this page with your findings. @@ -44,10 +43,10 @@ The general structure of the URL to connect to your Stump server is: The following clients have been tested with Stump: -| OS | Application | Page Streaming | Issues/Notes | -| --- | :-----------------------------------------------------------------------------------: | -------------: | -----------------------------------------------------------------------------------------------------------------------------------: | -| iOS | [Cantook by Aldiko](https://apps.apple.com/us/app/cantook-by-aldiko/id1476410111) | ❓ | Supports catalog traversal and media downloads. Issues with rendering media covers, however this appears to be an issue on their end | -| Android | [Cantook by Aldiko](https://play.google.com/store/apps/details?id=com.aldiko.android) | ❓ | Supports catalog traversal and media downloads. Issues with rendering media covers, however this appears to be an issue on their end | -| Android | [Librera](https://play.google.com/store/apps/details?id=com.foobnix.pdf.reader) | ❓ | Does not work. Likely lacking OPDS 2.0 support | -| Linux | [Foliate](https://johnfactotum.github.io/foliate/) | ❌ | Loads cover previews, book names, publisher and categories | -| Windows | [Thorium 3](https://thorium.edrlab.org/en/) | ❌ | Loads covers and their previews, book names, publisher and categories | \ No newline at end of file +| OS | Application | Page Streaming | Issues/Notes | +| ------- | :-----------------------------------------------------------------------------------: | -------------: | -----------------------------------------------------------------------------------------------------------------------------------: | +| iOS | [Cantook by Aldiko](https://apps.apple.com/us/app/cantook-by-aldiko/id1476410111) | ❓ | Supports catalog traversal and media downloads. Issues with rendering media covers, however this appears to be an issue on their end | +| Android | [Cantook by Aldiko](https://play.google.com/store/apps/details?id=com.aldiko.android) | ❓ | Supports catalog traversal and media downloads. Issues with rendering media covers, however this appears to be an issue on their end | +| Android | [Librera](https://play.google.com/store/apps/details?id=com.foobnix.pdf.reader) | ❓ | Does not work. Likely lacking OPDS 2.0 support | +| Linux | [Foliate](https://johnfactotum.github.io/foliate/) | ❌ | Loads cover previews, book names, publisher and categories | +| Windows | [Thorium 3](https://thorium.edrlab.org/en/) | ❌ | Loads covers and their previews, book names, publisher and categories | diff --git a/docs/pages/guides/smart-list.mdx b/docs/pages/guides/smart-list.mdx index 50ac0263e..c9838c104 100644 --- a/docs/pages/guides/smart-list.mdx +++ b/docs/pages/guides/smart-list.mdx @@ -2,29 +2,27 @@ import { Callout } from 'nextra-theme-docs' # Smart lists - - At the time of writing, smart lists are extremely experimental. There is no UI for creating or - editing them, and the internal structures are subject to change - +A smart list is a dynamic list of books generated from a set of configured filters. They are powerful in that they allow you to quickly curate and organize a collection of books based on a set of criteria without having to manually manage the list. -Smart lists are stored combinations of filters that can be applied to your database, generating pseudo-lists on the fly. They are a powerful tool for organizing your books in a way that makes sense to you. +## Filters -## Components +The core of a smart list, used for determining which books are to be included in the resulting list. -There are three main components to a smart list: +A smart lists filter configuration is made up of two parts: -- **Name**: The name of the smart list, which is used to identify it in the UI and must be unique _per_ user -- **Description**: An optional description of the smart list -- **Filters**: The magic sauce that makes smart lists work +- **Groups**: Sub-lists of filters that are combined together using a top-level joiner (see below) +- **Top-level joiner**: The method used to join groups together, i.e. `AND` or `OR` (all groups must match vs at least one group must match) -### Filters +A group itself has two parts: -Filters are the core of a smart list. They are used to determine which books are to be included in the generated smart list. +- **Joiner**: The method used to join filters together within the group, i.e. `AND`, `OR`, `NOT` (all filters must match, at least one filter must match, or no filters must match) +- **Conditions**: A list of filters to apply to the books -Filters are made up of two parts: +A condition in a filter group has the following structure: -- **Filter groups**: Groups of filters that are combined together using a joiner -- **Joiner**: The combination method used to join groups together, i.e. `AND` or `OR` +- **Attribute**: The attribute of the book to filter on. This also allows selecting attributes of related entities, such as a book's metadata, series, or library. I won't enumerate them all, but you can filter on most attributes. Any missing attributes can be requested! +- **Operator**: The operator to use when comparing the attribute to the value. I won't enumerate them all, but you have options for string and list matching, number comparison, and range operators +- **Value**: The actual value to compare the attribute to. The UI presents this dynamically, based on the attribute and operator selected ### Grouping @@ -34,13 +32,13 @@ Not to be confused with the filter groups, grouping is a way of grouping matched 2. `BY_LIBRARY`: Groups books together by their library 3. `BY_BOOKS`: Does not group books together. The name is perhaps confusing, but you can think of it as "no grouping" -## Access +In the future, more grouping options may be considered -The smart list feature, itself, is gated behind the `smartlist:read` user permission. This means that only users with this permission will be able to interact with smart lists. +## Feature Access -### Sharing +The smart list feature, itself, is gated behind the `smartlist:read` user permission. This means that only users with this permission will be able to interact with smart lists. -Access sharing is not yet fully implemented +## List-level Access Smart lists can be shared with other users, and has 3 visibility options: @@ -48,21 +46,16 @@ Smart lists can be shared with other users, and has 3 visibility options: - **Public**: Anyone can see the smart list - **Shared**: Only users that the smart list has been shared with can see it -## Views - -While interacting with a smart list on the UI, you can manipulate the view to your liking. This includes: - -- Sorting states for the table -- Column visibility, i.e. which columns are visible or hidden -- Column order, i.e. the order in which columns are displayed - -By default, any changes you make won't be persisted. However, the UI will detect and allow you to save any adjustments as a view. This view will then be available to you in the future, along with any other views you create. If you have a view selected, the changes will provide you with the additional option of updating the view. + + The `shared` visibility option is not yet fully implemented. It functions as `private` for now, + but will be updated in the future + ## Creating a Smart List -Currently, the only way to create a smart list is to use the Stump API directly. You can use the swagger UI to do this, or use a tool like Postman. +There is a query builder directly in the UI which allows you to declaratively create a smart list. This is the recommended way to create a smart list, as it is the most user-friendly. The UI is aware of the type-constraints per attribute and will guide you through the process. -An example `POST` request might look like this: +You can also use the API directly, an example `POST` request might look like this: ```json { @@ -105,3 +98,13 @@ An example `POST` request might look like this: "default_grouping": "BY_SERIES" } ``` + +## Views + +While interacting with a smart list on the UI, you can manipulate the view to your liking. This includes: + +- Sorting states for the table +- Column visibility, i.e. which columns are visible or hidden +- Column order, i.e. the order in which columns are displayed + +By default, any changes you make won't be persisted. However, the UI will detect and allow you to save any adjustments as a view. This view will then be available to you in the future, along with any other views you create. If you have a view selected, the changes will provide you with the additional option of updating the view. diff --git a/packages/browser/package.json b/packages/browser/package.json index 5dbd554e3..3a2946373 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -18,16 +18,16 @@ "dependencies": { "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", - "@hookform/resolvers": "^3.3.4", - "@stump/sdk": "*", + "@hookform/resolvers": "^3.9.0", "@stump/client": "*", "@stump/components": "*", "@stump/i18n": "*", + "@stump/sdk": "*", "@stump/types": "*", "@tanstack/react-query": "^4.36.1", "@tanstack/react-query-devtools": "^4.36.1", "@tanstack/react-table": "^8.16.0", - "@tanstack/react-virtual": "3.0.0-beta.18", + "@tanstack/react-virtual": "3.10.8", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "dayjs": "^1.11.10", @@ -36,6 +36,7 @@ "i18next": "^23.11.2", "immer": "^10.0.4", "is-valid-glob": "^1.0.0", + "lodash": "^4.17.21", "lodash.groupby": "^4.6.0", "lodash.isequal": "^4.5.0", "lodash.sortby": "^4.7.0", @@ -52,7 +53,7 @@ "react-dropzone": "^14.2.3", "react-error-boundary": "^4.0.13", "react-helmet": "^6.1.0", - "react-hook-form": "=7.47.0", + "react-hook-form": "^7.53.0", "react-hot-toast": "^2.4.1", "react-hotkeys-hook": "^4.5.0", "react-i18next": "^14.1.0", @@ -68,7 +69,7 @@ "rooks": "^7.14.1", "ts-pattern": "^5.1.1", "use-count-up": "^3.0.1", - "zod": "^3.22.4", + "zod": "^3.23.8", "zustand": "^4.5.2" }, "devDependencies": { diff --git a/packages/browser/src/components/library/createOrUpdate/index.ts b/packages/browser/src/components/library/createOrUpdate/index.ts index 047e3a852..f9ce6e529 100644 --- a/packages/browser/src/components/library/createOrUpdate/index.ts +++ b/packages/browser/src/components/library/createOrUpdate/index.ts @@ -1,3 +1,2 @@ -export { default as CreateLibraryForm } from '../../../scenes/createLibrary/CreateLibraryForm' export * from './schema' export * from './sections' diff --git a/packages/browser/src/components/navigation/sidebar/sections/smartList/SmartListSideBarSection.tsx b/packages/browser/src/components/navigation/sidebar/sections/smartList/SmartListSideBarSection.tsx index cf7eb1da4..5083bae5c 100644 --- a/packages/browser/src/components/navigation/sidebar/sections/smartList/SmartListSideBarSection.tsx +++ b/packages/browser/src/components/navigation/sidebar/sections/smartList/SmartListSideBarSection.tsx @@ -1,4 +1,4 @@ -import { useSmartListsQuery } from '@stump/client' +import { usePrefetchSmartList, useSmartListsQuery } from '@stump/client' import { Accordion, Text } from '@stump/components' import { useLocaleContext } from '@stump/i18n' import { useLocation } from 'react-router' @@ -18,6 +18,7 @@ export default function SmartListSideBarSection({ const { t } = useLocaleContext() const { lists } = useSmartListsQuery() + const { prefetch } = usePrefetchSmartList() const isCurrentList = (id: string) => location.pathname.startsWith(paths.smartList(id)) @@ -37,6 +38,7 @@ export default function SmartListSideBarSection({ to={paths.smartList(id)} isActive={isCurrentList(id)} className="pl-2 pr-0" + onMouseEnter={() => prefetch({ id })} > {name} diff --git a/packages/browser/src/components/smartList/createOrUpdate/__tests__/schema.test.ts b/packages/browser/src/components/smartList/createOrUpdate/__tests__/schema.test.ts new file mode 100644 index 000000000..9f76db4d4 --- /dev/null +++ b/packages/browser/src/components/smartList/createOrUpdate/__tests__/schema.test.ts @@ -0,0 +1,677 @@ +import { Filter, MediaSmartFilter, NumericFilter } from '@stump/types' + +import { + intoAPI, + intoAPIFilter, + intoAPIGroup, + intoForm, + intoFormFilter, + intoFormGroup, +} from '../schema' + +const stringFilters = [ + { + any: ['foo', 'shmoo'], + }, + { + not: 'bar', + }, + { + contains: 'f', + }, + { + excludes: 'z', + }, + { + none: ['baz', 'qux'], + }, +] as Filter[] +const numericFilters = [ + { + eq: 42, + }, + { + gt: 42, + }, + { + gte: 42, + }, + { + lt: 42, + }, + { + lte: 42, + }, + { + from: 42, + inclusive: true, + to: 69, + }, +] as NumericFilter[] + +describe('schema', () => { + describe('intoFormFilter', () => { + it('should convert basic smart filter into form filter', () => { + for (const filter of stringFilters) { + expect( + intoFormFilter({ + name: filter, + } satisfies MediaSmartFilter), + ).toEqual({ + field: 'name', + operation: Object.keys(filter)[0], + source: 'book', + value: Object.values(filter)[0], + }) + } + + for (const filter of numericFilters) { + const operation = 'from' in filter ? 'range' : Object.keys(filter)[0] + const value = 'from' in filter ? filter : Object.values(filter)[0] + expect(intoFormFilter({ created_at: filter } as unknown as MediaSmartFilter)).toEqual({ + field: 'created_at', + operation, + source: 'book', + value, + }) + } + }) + + it('should convert smart filter with metadata into form filter', () => { + for (const filter of stringFilters) { + expect(intoFormFilter({ metadata: { genre: filter } } satisfies MediaSmartFilter)).toEqual({ + field: 'genre', + operation: Object.keys(filter)[0], + source: 'book_meta', + value: Object.values(filter)[0], + }) + } + + for (const filter of numericFilters) { + const operation = 'from' in filter ? 'range' : Object.keys(filter)[0] + const value = 'from' in filter ? filter : Object.values(filter)[0] + expect( + intoFormFilter({ metadata: { age_rating: filter } } satisfies MediaSmartFilter), + ).toEqual({ + field: 'age_rating', + operation, + source: 'book_meta', + value, + }) + } + }) + + it('should convert smart filter with series into form filter', () => { + for (const filter of stringFilters) { + expect( + intoFormFilter({ + series: { + name: filter, + }, + } satisfies MediaSmartFilter), + ).toEqual({ + field: 'name', + operation: Object.keys(filter)[0], + source: 'series', + value: Object.values(filter)[0], + }) + } + + // TODO: support series metadata + // TODO: add numeric filters for series? + }) + + it('should convert smart filter with library into form filter', () => { + for (const filter of stringFilters) { + expect( + intoFormFilter({ + series: { + library: { + name: filter, + }, + }, + } satisfies MediaSmartFilter), + ).toEqual({ + field: 'name', + operation: Object.keys(filter)[0], + source: 'library', + value: Object.values(filter)[0], + }) + } + }) + }) + + describe('intoAPIFilter', () => { + it('should convert basic smart filter form into API filter', () => { + // String filter + expect( + intoAPIFilter({ + field: 'name', + operation: 'any', + source: 'book', + value: ['foo', 'shmoo'], + }), + ).toEqual({ + name: { + any: ['foo', 'shmoo'], + }, + }) + + // Numeric filter (basic) + expect( + intoAPIFilter({ + field: 'created_at', + operation: 'gte', + source: 'book', + value: 42, + }), + ).toEqual({ + created_at: { + gte: 42, + }, + }) + + // Numeric filter (complex) + expect( + intoAPIFilter({ + field: 'created_at', + operation: 'range', + source: 'book', + value: { + from: 42, + inclusive: true, + to: 69, + }, + }), + ).toEqual({ + created_at: { + from: 42, + inclusive: true, + to: 69, + }, + }) + }) + + it('should convert smart filter form with metadata into API filter', () => { + // String filter + expect( + intoAPIFilter({ + field: 'genre', + operation: 'any', + source: 'book_meta', + value: ['foo', 'shmoo'], + }), + ).toEqual({ + metadata: { + genre: { + any: ['foo', 'shmoo'], + }, + }, + }) + + // Numeric filter (basic) + expect( + intoAPIFilter({ + field: 'age_rating', + operation: 'gte', + source: 'book_meta', + value: 42, + }), + ).toEqual({ + metadata: { + age_rating: { + gte: 42, + }, + }, + }) + + // Numeric filter (complex) + expect( + intoAPIFilter({ + field: 'age_rating', + operation: 'range', + source: 'book_meta', + value: { + from: 42, + inclusive: true, + to: 69, + }, + }), + ).toEqual({ + metadata: { + age_rating: { + from: 42, + inclusive: true, + to: 69, + }, + }, + }) + }) + + it('should convert smart filter form with series into API filter', () => { + // String filter + expect( + intoAPIFilter({ + field: 'name', + operation: 'any', + source: 'series', + value: ['foo', 'shmoo'], + }), + ).toEqual({ + series: { + name: { + any: ['foo', 'shmoo'], + }, + }, + }) + + // Numeric filter (basic) + expect( + intoAPIFilter({ + field: 'created_at', + operation: 'gte', + source: 'series', + value: 42, + }), + ).toEqual({ + series: { + created_at: { + gte: 42, + }, + }, + }) + + // Numeric filter (complex) + expect( + intoAPIFilter({ + field: 'created_at', + operation: 'range', + source: 'series', + value: { + from: 42, + inclusive: true, + to: 69, + }, + }), + ).toEqual({ + series: { + created_at: { + from: 42, + inclusive: true, + to: 69, + }, + }, + }) + }) + + it('should convert smart filter form with library into API filter', () => { + // String filter + expect( + intoAPIFilter({ + field: 'name', + operation: 'any', + source: 'library', + value: ['foo', 'shmoo'], + }), + ).toEqual({ + series: { + library: { + name: { + any: ['foo', 'shmoo'], + }, + }, + }, + }) + + // Numeric filter (basic) + expect( + intoAPIFilter({ + field: 'created_at', + operation: 'gte', + source: 'library', + value: 42, + }), + ).toEqual({ + series: { + library: { + created_at: { + gte: 42, + }, + }, + }, + }) + + // Numeric filter (complex) + expect( + intoAPIFilter({ + field: 'created_at', + operation: 'range', + source: 'library', + value: { + from: 42, + inclusive: true, + to: 69, + }, + }), + ).toEqual({ + series: { + library: { + created_at: { + from: 42, + inclusive: true, + to: 69, + }, + }, + }, + }) + }) + }) + + describe('intoFormGroup', () => { + it('should convert basic smart filter into form group', () => { + // String filter + expect( + intoFormGroup({ + and: [ + { + name: { + any: ['foo', 'shmoo'], + }, + } satisfies MediaSmartFilter, + { + name: { + none: ['bar', 'baz'], + }, + } satisfies MediaSmartFilter, + ], + }), + ).toEqual({ + filters: [ + { + field: 'name', + operation: 'any', + source: 'book', + value: ['foo', 'shmoo'], + }, + { + field: 'name', + operation: 'none', + source: 'book', + value: ['bar', 'baz'], + }, + ], + joiner: 'and', + }) + + // Numeric filter + expect( + intoFormGroup({ + or: [ + { + metadata: { + age_rating: { + from: 42, + inclusive: true, + to: 69, + }, + }, + } satisfies MediaSmartFilter, + { + created_at: { + lt: new Date('2021-01-01').toISOString(), + }, + } satisfies MediaSmartFilter, + ], + }), + ).toEqual({ + filters: [ + { + field: 'age_rating', + operation: 'range', + source: 'book_meta', + value: { + from: 42, + inclusive: true, + to: 69, + }, + }, + { + field: 'created_at', + operation: 'lt', + source: 'book', + value: new Date('2021-01-01').toISOString(), + }, + ], + joiner: 'or', + }) + }) + }) + + describe('intoAPIGroup', () => { + it('should convert basic smart filter form group into API group', () => { + // String filter + expect( + intoAPIGroup({ + filters: [ + { + field: 'name', + operation: 'any', + source: 'book', + value: ['foo', 'shmoo'], + }, + { + field: 'name', + operation: 'none', + source: 'book', + value: ['bar', 'baz'], + }, + ], + joiner: 'and', + }), + ).toEqual({ + and: [ + { + name: { + any: ['foo', 'shmoo'], + }, + }, + { + name: { + none: ['bar', 'baz'], + }, + }, + ], + }) + + // Numeric filter + expect( + intoAPIGroup({ + filters: [ + { + field: 'age_rating', + operation: 'range', + source: 'book_meta', + value: { + from: 42, + inclusive: true, + to: 69, + }, + }, + { + field: 'created_at', + operation: 'lt', + source: 'book', + value: 42, + }, + ], + joiner: 'or', + }), + ).toEqual({ + or: [ + { + metadata: { + age_rating: { + from: 42, + inclusive: true, + to: 69, + }, + }, + }, + { + created_at: { + lt: 42, + }, + }, + ], + }) + }) + }) + + describe('intoForm', () => { + it('should convert a smart filter into a form', () => { + expect( + intoForm({ + default_grouping: 'BY_SERIES', + description: 'baz', + filters: { + groups: [ + { + and: [ + { + name: { + any: ['foo', 'shmoo'], + }, + }, + { + name: { + none: ['bar', 'baz'], + }, + }, + ], + }, + { + or: [{ created_at: { lt: new Date('2021-01-01').toISOString() } }], + }, + ], + joiner: 'OR', + }, + id: 'foo', + joiner: 'AND', + name: 'bar', + visibility: 'PUBLIC', + }), + ).toEqual({ + description: 'baz', + filters: { + groups: [ + { + filters: [ + { + field: 'name', + operation: 'any', + source: 'book', + value: ['foo', 'shmoo'], + }, + { + field: 'name', + operation: 'none', + source: 'book', + value: ['bar', 'baz'], + }, + ], + joiner: 'and', + }, + { + filters: [ + { + field: 'created_at', + operation: 'lt', + source: 'book', + value: new Date('2021-01-01').toISOString(), + }, + ], + joiner: 'or', + }, + ], + joiner: 'and', + }, + grouping: 'BY_SERIES', + name: 'bar', + visibility: 'PUBLIC', + }) + }) + }) + + describe('intoAPI', () => { + it('should convert a form representation into an API representation', () => { + expect( + intoAPI({ + description: 'baz', + filters: { + groups: [ + { + filters: [ + { + field: 'name', + operation: 'any', + source: 'book', + value: ['foo', 'shmoo'], + }, + { + field: 'name', + operation: 'none', + source: 'book', + value: ['bar', 'baz'], + }, + ], + joiner: 'and', + }, + { + filters: [ + { + field: 'created_at', + operation: 'lt', + source: 'book', + value: 42, + }, + ], + joiner: 'or', + }, + ], + joiner: 'and', + }, + grouping: 'BY_SERIES', + name: 'bar', + visibility: 'PUBLIC', + }), + ).toEqual({ + default_grouping: 'BY_SERIES', + description: 'baz', + filters: { + groups: [ + { + and: [ + { + name: { + any: ['foo', 'shmoo'], + }, + }, + { + name: { + none: ['bar', 'baz'], + }, + }, + ], + }, + { + or: [{ created_at: { lt: 42 } }], + }, + ], + joiner: 'AND', + }, + name: 'bar', + visibility: 'PUBLIC', + }) + }) + }) +}) diff --git a/packages/browser/src/components/smartList/createOrUpdate/index.ts b/packages/browser/src/components/smartList/createOrUpdate/index.ts new file mode 100644 index 000000000..f9ce6e529 --- /dev/null +++ b/packages/browser/src/components/smartList/createOrUpdate/index.ts @@ -0,0 +1,2 @@ +export * from './schema' +export * from './sections' diff --git a/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/GroupBy.tsx b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/GroupBy.tsx new file mode 100644 index 000000000..89485b8ae --- /dev/null +++ b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/GroupBy.tsx @@ -0,0 +1,63 @@ +import { Label, NativeSelect, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' +import React, { useCallback } from 'react' +import { useFormContext } from 'react-hook-form' + +import { isGrouping, SmartListFormSchema, SmartListGroupBy } from '../schema' + +type Props = { + disabled?: boolean +} + +export default function GroupBy({ disabled }: Props) { + const form = useFormContext() + + const grouping = form.watch('grouping') + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + if (isGrouping(e.target.value)) { + form.setValue('grouping', e.target.value) + } + }, + [form], + ) + + const { t } = useLocaleContext() + + return ( +
+ +
+ +
+ + {t(getKey('description'))} + +
+ ) +} + +const LOCALE_KEY = 'createOrUpdateSmartListForm.fields.queryBuilder.grouping' +const getKey = (key: string) => `${LOCALE_KEY}.${key}` +const getOptionKey = (grouping: SmartListGroupBy, key: string) => + `${LOCALE_KEY}.options.${grouping.toLowerCase()}.${key}` diff --git a/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/SmartListQueryBuilder.tsx b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/SmartListQueryBuilder.tsx new file mode 100644 index 000000000..8470a2b30 --- /dev/null +++ b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/SmartListQueryBuilder.tsx @@ -0,0 +1,101 @@ +import { Alert, Button, cn, cx, Tabs, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' +import { useFormContext, useWatch } from 'react-hook-form' + +import { SmartListFormSchema } from '../schema' +import { FilterGroup } from './filterGroup' +import GroupBy from './GroupBy' + +// TODO: error states throughout form elems + +type Props = { + disabled?: boolean +} + +export default function SmartListQueryBuilder({ disabled }: Props) { + const form = useFormContext() + + const [joiner] = form.watch(['filters.joiner']) + const { + filters: { groups }, + } = useWatch({ control: form.control }) as SmartListFormSchema + const { t } = useLocaleContext() + + return ( + <> +
+ + {t(getKey('uiPerformance'))} + + + + +
+ + + form.setValue('filters.joiner', 'and')} + > + {t(getKey('rootJoiner.and.label'))} + + + form.setValue('filters.joiner', 'or')} + > + + {t(getKey('rootJoiner.or.label'))} + + + + + + + {t(getKey(`rootJoiner.${joiner.toLowerCase()}.description`))} + +
+ +
+ {groups.length === 0 && ( +
+ {t(getKey('filters.emptyState'))} +
+ )} + {groups.map((group, index) => ( + + ))} +
+ +
+ +
+
+ + ) +} + +const LOCALE_KEY = 'createOrUpdateSmartListForm.fields.queryBuilder' +const getKey = (key: string) => `${LOCALE_KEY}.${key}` diff --git a/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/FieldSelector.tsx b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/FieldSelector.tsx new file mode 100644 index 000000000..7dee603a0 --- /dev/null +++ b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/FieldSelector.tsx @@ -0,0 +1,200 @@ +import { Button, cn, Command, Popover } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' +import { ArrowLeft, ArrowRight, ChevronsUpDown } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useFieldArray, useFormContext } from 'react-hook-form' + +import { FilterSource, SmartListFormSchema } from '../../schema' +import { useFilterGroupContext } from './context' + +type Props = { + idx: number +} + +type FieldDef = SmartListFormSchema['filters']['groups'][number]['filters'][number] + +export function FieldSelector({ idx }: Props) { + const [open, setOpen] = useState(false) + + const [source, setSource] = useState(null) + + const { t } = useLocaleContext() + const { groupIdx } = useFilterGroupContext() + + const form = useFormContext() + const { fields, update } = useFieldArray({ + control: form.control, + name: `filters.groups.${groupIdx}.filters`, + }) + + const fieldDef = useMemo(() => fields?.[idx], [fields, idx]) + + useEffect(() => { + if (fieldDef) { + setSource(fieldDef.source) + } + }, [fieldDef, open]) + + const updateField = useCallback( + (params: Partial) => { + const newField = { ...fieldDef, ...params } + update(idx, newField as FieldDef) + }, + [update, fieldDef, idx], + ) + + const renderSource = () => { + if (!source) { + return ( + <> + setSource('book')} + className="flex items-center justify-between" + > + {t(getSourceKey('book', 'label'))} + + + setSource('book_meta')} + className="flex items-center justify-between" + > + {t(getSourceKey('book_meta', 'label'))} + + + setSource('series')} + className="flex items-center justify-between" + > + {t(getSourceKey('series', 'label'))} + + + setSource('library')} + className="flex items-center justify-between" + > + {t(getSourceKey('library', 'label'))} + + + + ) + } else { + const options = sourceOptions[source] || [] + return ( +
+ {options.map((option) => { + return ( + { + updateField({ field: option.value, source }) + setOpen(false) + }} + className={cn('transition-all duration-75', { 'text-brand': false })} + value={option.value} + > + {t(getAttributeKey(source, option.value))} + + ) + })} +
+ ) + } + } + + const renderGroupHeader = () => { + if (source) { + return ( + + ) + } else { + return {t(getKey('source.label'))} + } + } + + const renderSelected = () => { + if (fieldDef?.field) { + // return fieldDef.field + return source + ? // ? `${t(getSourceKey(source, 'label'))}.${t(getAttributeKey(source, fieldDef.field))}` + `${source}.${fieldDef.field}` + : fieldDef.field + } else { + return {t(getKey('placeholder'))} + } + } + + return ( + + + + + + + + {renderSource()} + + + + + ) +} + +const sourceOptions: Record = { + book: [ + { value: 'name' }, + { value: 'size' }, + { value: 'extension' }, + { value: 'created_at' }, + { value: 'updated_at' }, + { value: 'status' }, + { value: 'path' }, + { value: 'pages' }, + { value: 'tags' }, + ], + book_meta: [ + { value: 'title' }, + { value: 'summary' }, + { value: 'notes' }, + { value: 'genre' }, + { value: 'writers' }, + { value: 'pencillers' }, + { value: 'inkers' }, + { value: 'colorists' }, + { value: 'letterers' }, + { value: 'editors' }, + { value: 'publisher' }, + { value: 'cover_artists' }, + { value: 'links' }, + { value: 'characters' }, + { value: 'teams' }, + ], + library: [{ value: 'name' }, { value: 'path' }], + series: [{ value: 'name' }, { value: 'path' }], +} + +// TODO: series_meta: [meta_type, publisher, status, age_rating, volume] + +const LOCALE_KEY = 'createOrUpdateSmartListForm.fields.queryBuilder.filters.fieldSelect' +const getKey = (key: string) => `${LOCALE_KEY}.${key}` +const getSourceKey = (source: FilterSource, key: string) => `${LOCALE_KEY}.source.${source}.${key}` +const getAttributeKey = (source: FilterSource, key: string) => + getSourceKey(source, `attributes.${key}`) diff --git a/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/FilterGroup.tsx b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/FilterGroup.tsx new file mode 100644 index 000000000..a0fdfc66c --- /dev/null +++ b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/FilterGroup.tsx @@ -0,0 +1,107 @@ +import { Button, Card, IconButton, ToolTip } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' +import { ArrowRight, MinusCircle } from 'lucide-react' +import { useFieldArray } from 'react-hook-form' + +import { FilterGroupSchema, FilterSchema, SmartListFormSchema } from '../../schema' +import { FilterGroupContext } from './context' +import { FieldSelector } from './FieldSelector' +import { FilterValue } from './filterValue' +import GroupJoiner from './GroupJoiner' +import OperatorSelect from './OperatorSelect' + +type Props = { + idx: number + group: FilterGroupSchema +} +export default function FilterGroup({ idx, group }: Props) { + const { t } = useLocaleContext() + + const { remove: removeGroup } = useFieldArray({ + name: 'filters.groups', + }) + const { append, remove } = useFieldArray({ + name: `filters.groups.${idx}.filters`, + }) + + return ( + + +
+ {!group.filters.length && ( +
+ +
+ )} + + {group.filters.map((filter, filterIndex) => { + return ( +
+
+ + {filter.field && ( + <> + + + + )} + {filter.operation && ( + <> + + + + )} +
+ +
+ + remove(filterIndex)} + disabled={group.filters.length === 1} + > + + + +
+
+ ) + })} +
+ +
+ + +
+ + + + +
+ + + ) +} + +const LOCALE_KEY = 'createOrUpdateSmartListForm.fields.queryBuilder.filters' +const getKey = (key: string) => `${LOCALE_KEY}.${key}` diff --git a/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/GroupJoiner.tsx b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/GroupJoiner.tsx new file mode 100644 index 000000000..a9cba9448 --- /dev/null +++ b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/GroupJoiner.tsx @@ -0,0 +1,63 @@ +import { cn, Tabs, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' +import React from 'react' +import { useFormContext } from 'react-hook-form' + +import { FilterGroupJoiner, SmartListFormSchema } from '../../schema' +import { useFilterGroupContext } from './context' + +export default function GroupJoiner() { + const form = useFormContext() + + const { t } = useLocaleContext() + const { groupIdx } = useFilterGroupContext() + + const joiner = form.watch(`filters.groups.${groupIdx}.joiner`) + + return ( +
+ + + form.setValue(`filters.groups.${groupIdx}.joiner`, 'and')} + > + {t(getJoinerKey('and', 'label'))} + + + form.setValue(`filters.groups.${groupIdx}.joiner`, 'or')} + > + + {t(getJoinerKey('or', 'label'))} + + + + form.setValue(`filters.groups.${groupIdx}.joiner`, 'not')} + > + + {t(getJoinerKey('not', 'label'))} + + + + + + {t(getJoinerKey(joiner, 'description'))} + +
+ ) +} + +const LOCALE_KEY = 'createOrUpdateSmartListForm.fields.queryBuilder.groupJoiner' +const getKey = (key: string) => `${LOCALE_KEY}.${key}` +const getJoinerKey = (joiner: FilterGroupJoiner, key: string) => + getKey(`${joiner?.toLowerCase()}.${key}`) diff --git a/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/OperatorSelect.tsx b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/OperatorSelect.tsx new file mode 100644 index 000000000..eb5f55ae3 --- /dev/null +++ b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/OperatorSelect.tsx @@ -0,0 +1,157 @@ +import { Button, cn, Command, Popover } from '@stump/components' +import { ChevronsUpDown } from 'lucide-react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { useFieldArray, useFormContext } from 'react-hook-form' +import { match } from 'ts-pattern' + +import { + isDateField, + isNumberField, + isStringField, + ListOperation, + NumberOperation, + Operation, + SmartListFormSchema, + StringOperation, +} from '../../schema' +import { useFilterGroupContext } from './context' + +type Props = { + idx: number +} + +type FieldDef = SmartListFormSchema['filters']['groups'][number]['filters'][number] + +export default function OperatorSelect({ idx }: Props) { + const { groupIdx } = useFilterGroupContext() + + const form = useFormContext() + + const [isOpen, setIsOpen] = useState(false) + + const { update } = useFieldArray({ + control: form.control, + name: `filters.groups.${groupIdx}.filters`, + }) + + const fieldDef = useMemo( + () => form.watch(`filters.groups.${groupIdx}.filters.${idx}`) || ({} as FieldDef), + [form, groupIdx, idx], + ) + + const updateField = useCallback( + (params: Partial, close = true) => { + const newField = { ...fieldDef, ...params } + update(idx, newField) + setIsOpen(!close) + }, + [update, fieldDef, idx], + ) + + const operators = useMemo( + () => + match(fieldDef.field) + .when( + (field) => isStringField(field), + () => ['contains', 'excludes', 'not', 'equals'] as StringOperation[], + ) + .when( + (field) => isNumberField(field) || isDateField(field), + () => ['gt', 'gte', 'lt', 'lte', 'not', 'equals', 'range'] as NumberOperation[], + ) + .otherwise(() => [] as Operation[]), + [fieldDef], + ) + + const selectGroups = useMemo(() => { + const arrayGroup = operatorGroups.list + + return [ + { + label: 'Equality', + operators: operators, + }, + ...(!isDateField(fieldDef.field) + ? [ + { + label: 'List', + operators: arrayGroup, + }, + ] + : []), + ] + }, [operators, fieldDef]) + + useEffect(() => { + const allOperators = [...operators, ...operatorGroups.list] + const shouldReset = !allOperators.includes(fieldDef.operation) + if (shouldReset && fieldDef.operation) { + updateField({ operation: undefined }, false) + } + }, [fieldDef, operators, updateField]) + + if (!fieldDef) return null + + return ( + + + + + + + + {selectGroups.map(({ label, operators }) => ( + {label}} + > + {operators.map((operator) => ( + updateField({ operation: operator })} + className={cn('transition-all duration-75', { + 'text-brand': operator === fieldDef.operation, + })} + value={operator} + > + {operatorMap[operator]} + + ))} + + ))} + + + + ) +} + +const operatorGroups = { + list: ['any', 'none'] satisfies ListOperation[], + number: ['gt', 'gte', 'lt', 'lte', 'not', 'equals', 'range'] satisfies NumberOperation[], + string: ['contains', 'excludes', 'not', 'equals'] satisfies StringOperation[], +} + +const operatorMap: Record = { + any: 'any in list', + contains: 'contains string', + equals: 'equal to', + excludes: 'excludes string', + gt: 'greater than', + gte: 'greater than or equal to', + lt: 'less than', + lte: 'less than or equal to', + none: 'none in list', + not: 'not equal to', + range: 'in range', +} diff --git a/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/context.ts b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/context.ts new file mode 100644 index 000000000..6fb63e942 --- /dev/null +++ b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/context.ts @@ -0,0 +1,15 @@ +import { createContext, useContext } from 'react' + +export type IFilterGroupContext = { + groupIdx: number +} + +export const FilterGroupContext = createContext(null) + +export const useFilterGroupContext = () => { + const context = useContext(FilterGroupContext) + if (!context) { + throw new Error('FilterGroupContext is not provided') + } + return context +} diff --git a/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/filterValue/FilterValue.tsx b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/filterValue/FilterValue.tsx new file mode 100644 index 000000000..ee1206083 --- /dev/null +++ b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/filterValue/FilterValue.tsx @@ -0,0 +1,87 @@ +import { cn, DatePicker, Input } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' +import dayjs from 'dayjs' +import React, { useMemo } from 'react' +import { useFormContext } from 'react-hook-form' +import { match } from 'ts-pattern' + +import { + isDateField, + isListOperator, + isNumberField, + isNumberOperator, + SmartListFormSchema, +} from '@/components/smartList/createOrUpdate' + +import { useFilterGroupContext } from '../context' +import ListValue from './ListValue' +import RangeValue, { RangeFilterDef } from './RangeValue' + +type Props = { + idx: number +} + +export type FieldDef = SmartListFormSchema['filters']['groups'][number]['filters'][number] + +export default function FilterValue({ idx }: Props) { + const { t } = useLocaleContext() + const { groupIdx } = useFilterGroupContext() + + const form = useFormContext() + + const fieldDef = useMemo( + () => form.watch(`filters.groups.${groupIdx}.filters.${idx}`) || ({} as FieldDef), + [form, groupIdx, idx], + ) + + const variant = match(fieldDef.operation) + .when( + (value) => isListOperator(value), + () => 'list', + ) + .when( + (value) => isNumberOperator(value), + (value) => (value === 'range' ? 'range' : 'number'), + ) + .otherwise(() => 'string') + + if (variant === 'list') { + return + } else if (variant === 'range') { + return + } + + if (isDateField(fieldDef.field)) { + return ( + { + if (value) { + const adjustedValue = dayjs(value).endOf('day').toDate() + form.setValue(`filters.groups.${groupIdx}.filters.${idx}.value`, adjustedValue) + } else { + form.resetField(`filters.groups.${groupIdx}.filters.${idx}.value`) + } + }} + className="md:w-52" + /> + ) + } + + const isNumber = isNumberField(fieldDef.field) + + return ( + + ) +} + +const LOCALE_KEY = 'createOrUpdateSmartListForm.fields.queryBuilder.filters.basicValue' +const getKey = (key: string) => `${LOCALE_KEY}.${key}` diff --git a/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/filterValue/ListValue.tsx b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/filterValue/ListValue.tsx new file mode 100644 index 000000000..30cc4f2b0 --- /dev/null +++ b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/filterValue/ListValue.tsx @@ -0,0 +1,91 @@ +import { ComboBox } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' +import { useCallback, useMemo } from 'react' +import { useFormContext } from 'react-hook-form' + +import { isNumberField, SmartListFormSchema } from '@/components/smartList/createOrUpdate' + +import { useFilterGroupContext } from '../context' +import { FieldDef } from './FilterValue' + +type Props = { + idx: number +} + +export default function ListValue({ idx }: Props) { + const { t } = useLocaleContext() + const { groupIdx } = useFilterGroupContext() + + const form = useFormContext() + const fieldDef = useMemo( + () => form.watch(`filters.groups.${groupIdx}.filters.${idx}`) || ({} as FieldDef), + [form, groupIdx, idx], + ) + const values = useMemo( + () => + (form.watch(`filters.groups.${groupIdx}.filters.${idx}.value`) || []) as (string | number)[], + [form, groupIdx, idx], + ) + + const isNumber = isNumberField(fieldDef.field) + + const parseValue = useCallback( + (value: string | number) => { + if (isNumber) { + return (value as number) * 1 + } + return value + }, + [isNumber], + ) + + const addValue = useCallback( + (value: string | number) => { + const parsedValue = parseValue(value) + if (typeof parsedValue === 'number' && isNaN(parsedValue)) { + form.resetField(`filters.groups.${groupIdx}.filters.${idx}.value`) + return + } + + if (Array.isArray(values) && !values.includes(parsedValue)) { + // @ts-expect-error: fix this irritating type error re: string | number array + form.setValue(`filters.groups.${groupIdx}.filters.${idx}.value`, [...values, parsedValue]) + } + }, + [form, groupIdx, idx, values, parseValue], + ) + + const handleChange = useCallback( + (values?: string[]) => { + if (!values) { + form.resetField(`filters.groups.${groupIdx}.filters.${idx}.value`) + return + } + + const parsedValues = values.map(parseValue) as string[] | number[] + if (isNumber && parsedValues.some((value) => isNaN(value as number))) { + form.resetField(`filters.groups.${groupIdx}.filters.${idx}.value`) + return + } + + form.setValue(`filters.groups.${groupIdx}.filters.${idx}.value`, parsedValues) + }, + [form, groupIdx, idx, isNumber, parseValue], + ) + + return ( + ({ label: String(value), value: String(value) }))} + value={values.map(String)} + placeholder={t(getKey('placeholder'))} + filterEmptyMessage={t(getKey('emptyState'))} + onChange={handleChange} + onAddOption={({ value }) => addValue(value)} + isMultiSelect + filterable + /> + ) +} + +const LOCALE_KEY = 'createOrUpdateSmartListForm.fields.queryBuilder.filters.listValue' +const getKey = (key: string) => `${LOCALE_KEY}.${key}` diff --git a/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/filterValue/RangeValue.tsx b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/filterValue/RangeValue.tsx new file mode 100644 index 000000000..0a08fcf8d --- /dev/null +++ b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/filterValue/RangeValue.tsx @@ -0,0 +1,121 @@ +import { CheckBox, DatePicker, Input } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' +import dayjs from 'dayjs' +import { useMemo } from 'react' +import { useFormContext, useFormState } from 'react-hook-form' +import { useMediaMatch } from 'rooks' +import { match } from 'ts-pattern' + +import { + FilterSchema, + FromOperation, + isDateField, + isNumberField, + SmartListFormSchema, +} from '@/components/smartList/createOrUpdate' + +import { useFilterGroupContext } from '../context' + +export type RangeFilterDef = FilterSchema & { value: FromOperation } + +type Props = { + def: RangeFilterDef + idx: number +} + +export default function RangeValue({ def: { field, value }, idx }: Props) { + const form = useFormContext() + const isAtLeastMedium = useMediaMatch('(min-width: 768px)') + + const { t } = useLocaleContext() + const { groupIdx } = useFilterGroupContext() + const { errors } = useFormState({ control: form.control }) + + const formError = useMemo( + () => errors.filters?.groups?.[groupIdx]?.filters?.[idx], + [errors, groupIdx, idx], + ) + + const changeHandler = (key: 'from' | 'to') => (value?: Date | number) => { + if (value === undefined) { + form.resetField(`filters.groups.${groupIdx}.filters.${idx}.value.${key}`) + } else { + const adjustedValue = typeof value === 'number' ? value : dayjs(value).endOf('day').toDate() + form.setValue(`filters.groups.${groupIdx}.filters.${idx}.value.${key}`, adjustedValue) + } + } + + const renderValue = () => { + return ( + match(field) + .when(isDateField, () => ( + <> + + + + )) + // TODO(ux): show the error somewhere. The input in error state with message will disalign the container, which is just + // a bit annoying. Ideally we can render the error message somewhere else without disrupting the layout. + .when(isNumberField, () => ( + <> + + + + )) + .otherwise(() => null) + ) + } + + return ( + <> + {renderValue()} + + form.setValue( + `filters.groups.${groupIdx}.filters.${idx}.value.inclusive`, + !value?.inclusive, + ) + } + /> + + ) +} + +const LOCALE_KEY = 'createOrUpdateSmartListForm.fields.queryBuilder.filters.rangeValue' +const getKey = (key: string) => `${LOCALE_KEY}.${key}` diff --git a/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/filterValue/index.ts b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/filterValue/index.ts new file mode 100644 index 000000000..9710a718b --- /dev/null +++ b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/filterValue/index.ts @@ -0,0 +1 @@ +export { default as FilterValue } from './FilterValue' diff --git a/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/index.ts b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/index.ts new file mode 100644 index 000000000..aa41ab5b7 --- /dev/null +++ b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/index.ts @@ -0,0 +1 @@ +export { default as FilterGroup } from './FilterGroup' diff --git a/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/index.ts b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/index.ts new file mode 100644 index 000000000..c5a933fbf --- /dev/null +++ b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/index.ts @@ -0,0 +1 @@ +export { default as SmartListQueryBuilder } from './SmartListQueryBuilder' diff --git a/packages/browser/src/components/smartList/createOrUpdate/schema.ts b/packages/browser/src/components/smartList/createOrUpdate/schema.ts new file mode 100644 index 000000000..5fa468962 --- /dev/null +++ b/packages/browser/src/components/smartList/createOrUpdate/schema.ts @@ -0,0 +1,368 @@ +import { + CreateOrUpdateSmartList, + FilterGroup, + LibrarySmartFilter, + MediaMetadataSmartFilter, + MediaSmartFilter, + SeriesSmartFilter, + SmartFilter, + SmartList, +} from '@stump/types' +import getProperty from 'lodash/get' +import { match, P } from 'ts-pattern' +import { z } from 'zod' + +export const stringOperation = z.enum(['contains', 'excludes', 'not', 'equals']) +export type StringOperation = z.infer + +export const listOperation = z.enum(['any', 'none']) +export type ListOperation = z.infer +export const isListOperator = (value: string): value is ListOperation => + listOperation.safeParse(value).success + +export const numberOperation = z.enum(['gt', 'gte', 'lt', 'lte', 'not', 'equals', 'range']) +export type NumberOperation = z.infer +export const isNumberOperator = (value: string): value is NumberOperation => + numberOperation.safeParse(value).success + +export const operation = z.union([stringOperation, listOperation, numberOperation]) +export type Operation = z.infer + +export const fromOperation = z.object({ + from: z.union([z.date(), z.number()]), + inclusive: z.boolean().optional(), + to: z.union([z.date(), z.number()]), +}) +export type FromOperation = z.infer + +export const stringField = z.enum([ + 'name', + 'title', + 'path', + 'description', + 'summary', + 'notes', + 'genre', + 'writers', + 'pencillers', + 'inkers', + 'colorists', + 'letterers', + 'editors', + 'publisher', + 'colorists', + 'letterers', + 'cover_artists', + 'links', + 'characters', + 'teams', +]) +export type StringField = z.infer +export const isStringField = (field: string): field is StringField => + stringField.safeParse(field).success + +export const numberField = z.enum([ + 'age_rating', + 'year', + 'day', + 'month', + 'pages', + 'page_count', + 'size', +]) +export type NumberField = z.infer +export const isNumberField = (field: string): field is NumberField => + numberField.safeParse(field).success + +export const dateField = z.enum(['created_at', 'updated_at', 'completed_at']) +export type DateField = z.infer +export const isDateField = (field: string): field is DateField => dateField.safeParse(field).success + +export const filter = z + .object({ + field: z.string(), + operation, + source: z.enum(['book', 'book_meta', 'series', 'library']), + value: z.union([ + z.string(), + z.string().array(), + z.number(), + z.number().array(), + z.date(), + fromOperation, + ]), + }) + // strings may not use gt, gte, lt, lte, from + .refine( + (input) => + !( + stringField.safeParse(input.field).success && + ['gt', 'gte', 'lt', 'lte', 'from'].includes(input.operation) + ), + { + message: 'String fields may not use gt, gte, lt, lte, from', + }, + ) +export type FilterSchema = z.infer +export type FilterSource = FilterSchema['source'] + +export const intoAPIFilter = (input: z.infer): MediaSmartFilter => { + const fieldValue = match(input.operation) + .with('range', () => input.value) + .otherwise(() => ({ [input.operation]: input.value })) + + const converted = match(input.source) + .with( + 'book', + () => + ({ + [input.field]: fieldValue, + }) as MediaSmartFilter, + ) + .with('book_meta', () => ({ + metadata: { + [input.field]: fieldValue, + } as MediaMetadataSmartFilter, + })) + .with('series', () => ({ + series: { + [input.field]: fieldValue, + } as SeriesSmartFilter, + })) + .with('library', () => ({ + series: { + library: { + [input.field]: fieldValue, + }, + } as SeriesSmartFilter, + })) + .exhaustive() + + return converted +} + +// FIXME: this is SUPER unsafe wrt the types... +export const intoFormFilter = (input: MediaSmartFilter): z.infer => { + const source = match(input) + .when( + (x) => 'metadata' in x, + () => 'book_meta' as const, + ) + .when( + (x) => 'series' in x && 'library' in x.series, + () => 'library' as const, + ) + .when( + (x) => 'series' in x, + () => 'series' as const, + ) + .otherwise(() => 'book' as const) + + const field = match(source) + .with('book', () => Object.keys(input)[0]) + .with( + 'book_meta', + () => Object.keys((input as { metadata: MediaMetadataSmartFilter }).metadata)[0], + ) + .with('series', () => Object.keys((input as { series: SeriesSmartFilter }).series)[0]) + .with( + 'library', + () => Object.keys((input as { series: { library: LibrarySmartFilter } }).series.library)[0], + ) + .exhaustive() + + const conversion = match(source) + .with('book', () => { + const castedInput = input as MediaSmartFilter + const filterValue = getProperty(castedInput, field || '') // { [operation]: value } + const operation = 'from' in filterValue ? 'range' : Object.keys(filterValue || {})[0] + const value = match(operation) + .with('range', () => filterValue) + .otherwise(() => getProperty(filterValue, operation || '')) + + return { + field, + operation, + source, + value, + } + }) + .with('book_meta', () => { + const castedInput = input as { metadata: MediaMetadataSmartFilter } // { metadata: { [field]: { [operation]: value } } } + const filterValue = getProperty(castedInput.metadata, field || '') // { [operation]: value } + const operation = 'from' in filterValue ? 'range' : Object.keys(filterValue || {})[0] + const value = match(operation) + .with('range', () => filterValue) + .otherwise(() => getProperty(filterValue, operation || '')) + + return { + field, + operation, + source, + value, + } + }) + .with('series', () => { + const castedInput = input as { series: SeriesSmartFilter } // { series: { [field]: { [operation]: value } } } + const filterValue = getProperty(castedInput.series, field || '') + const operation = 'from' in filterValue ? 'range' : Object.keys(filterValue || {})[0] + const value = match(operation) + .with('range', () => filterValue) + .otherwise(() => getProperty(filterValue, operation || '')) + + return { + field, + operation, + source, + value, + } + }) + .with('library', () => { + const castedInput = input as { series: { library: LibrarySmartFilter } } // { series: { library: { [field]: { [operation]: value } } } } + const filterValue = getProperty(castedInput.series.library, field || '') // { [operation]: value } + const operation = 'from' in filterValue ? 'range' : Object.keys(filterValue || {})[0] + const value = operation === 'range' ? filterValue : getProperty(filterValue, operation || '') + return { + field, + operation, + source, + value, + } + }) + .exhaustive() + + return conversion as unknown as z.infer +} + +export const filterGroup = z.object({ + filters: z.array(filter), + joiner: z.enum(['and', 'or', 'not']), +}) +export type FilterGroupSchema = z.infer +export type FilterGroupJoiner = FilterGroupSchema['joiner'] + +export const intoFormGroup = ( + input: FilterGroup, +): z.infer => { + const converted = match(input) + .with( + { and: P.array() }, + ({ and }) => + ({ + filters: and.map(intoFormFilter), + joiner: 'and', + }) satisfies z.infer, + ) + .with( + { or: P.array() }, + ({ or }) => + ({ filters: or.map(intoFormFilter), joiner: 'or' }) satisfies z.infer, + ) + .with( + { not: P.array() }, + ({ not }) => + ({ filters: not.map(intoFormFilter), joiner: 'not' }) satisfies z.infer, + ) + .otherwise( + () => + ({ + filters: [], + joiner: 'and', + }) satisfies z.infer, + ) + + return converted +} + +export const intoAPIGroup = (input: z.infer): FilterGroup => { + const converted = match(input) + .with( + { filters: P.array(), joiner: 'and' }, + ({ filters }) => ({ and: filters.map(intoAPIFilter) }) as FilterGroup, + ) + .with( + { filters: P.array(), joiner: 'or' }, + ({ filters }) => ({ or: filters.map(intoAPIFilter) }) as FilterGroup, + ) + .with( + { filters: P.array(), joiner: 'not' }, + ({ filters }) => ({ not: filters.map(intoAPIFilter) }) as FilterGroup, + ) + .otherwise(() => ({ and: [] }) as FilterGroup) + + return converted +} + +export const filterConfig = z.object({ + groups: z.array(filterGroup), + joiner: z.enum(['and', 'or', 'not']), +}) + +export const grouping = z.enum(['BY_BOOKS', 'BY_SERIES', 'BY_LIBRARY']) +export type SmartListGroupBy = z.infer +export const isGrouping = (value: string): value is SmartListGroupBy => + grouping.safeParse(value).success + +export const createSchema = ( + existingNames: string[], + t: (key: string) => string, + updatingList?: SmartList, +) => { + const forbiddenNames = existingNames.filter((name) => name !== updatingList?.name) + return z.object({ + description: z.string().optional(), + filters: filterConfig, + grouping: grouping.optional(), + name: z + .string() + .min(1, t(validationKey('nameTooShort'))) + .refine((name) => !forbiddenNames.includes(name), { message: t(validationKey('nameTaken')) }), + visibility: z.enum(['PRIVATE', 'PUBLIC', 'SHARED']), + }) +} +const validationKey = (key: string) => `createOrUpdateSmartListForm.validation.${key}` + +export type SmartListFormSchema = z.infer> + +export const intoForm = ({ + name, + description, + visibility, + filters, + joiner, + default_grouping, +}: Omit): SmartListFormSchema => ({ + description: description || undefined, + filters: { + groups: filters.groups.map(intoFormGroup), + joiner: joiner.toLowerCase() as 'and' | 'or' | 'not', + }, + grouping: default_grouping || undefined, + name, + visibility, +}) + +export const intoAPI = ({ + name, + description, + visibility, + filters, + grouping, +}: SmartListFormSchema): CreateOrUpdateSmartList => ({ + default_grouping: grouping || null, + description: description || null, + filters: { + groups: filters.groups.map(intoAPIGroup), + joiner: filters.joiner.toUpperCase() as 'AND' | 'OR', + }, + name, + visibility, +}) + +export const intoAPIFilters = ({ + groups, + joiner, +}: Pick['filters']): SmartFilter => ({ + groups: groups.map(intoAPIGroup), + joiner: joiner.toUpperCase() as 'AND' | 'OR', +}) diff --git a/packages/browser/src/components/smartList/createOrUpdate/sections/AccessSettings.tsx b/packages/browser/src/components/smartList/createOrUpdate/sections/AccessSettings.tsx new file mode 100644 index 000000000..580455fea --- /dev/null +++ b/packages/browser/src/components/smartList/createOrUpdate/sections/AccessSettings.tsx @@ -0,0 +1,50 @@ +import { Alert, Label, NativeSelect, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' +import { EntityVisibility } from '@stump/types' +import React from 'react' +import { useFormContext } from 'react-hook-form' + +import { SmartListFormSchema } from '../schema' + +type SubSchema = Pick + +type Props = { + isCreating?: boolean +} + +export default function AccessSettings({ isCreating }: Props) { + const form = useFormContext() + const visibility = form.watch('visibility') + + const { t } = useLocaleContext() + + return ( + <> +
+ + + + {t(getOptionKey(visibility, 'description'))} + +
+ + {isCreating && visibility === 'SHARED' && ( + + {t(getOptionKey(visibility, 'createDisclaimer'))} + + )} + + ) +} + +const LOCALE_KEY = 'createOrUpdateSmartListForm.fields.visibility' +const getKey = (key: string) => `${LOCALE_KEY}.${key}` +const getOptionKey = (option: EntityVisibility, key: string) => + getKey(`options.${option.toLowerCase()}.${key}`) diff --git a/packages/browser/src/components/smartList/createOrUpdate/sections/BasicDetails.tsx b/packages/browser/src/components/smartList/createOrUpdate/sections/BasicDetails.tsx new file mode 100644 index 000000000..d1304ae25 --- /dev/null +++ b/packages/browser/src/components/smartList/createOrUpdate/sections/BasicDetails.tsx @@ -0,0 +1,40 @@ +import { Input, TextArea } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' +import React from 'react' +import { useFormContext } from 'react-hook-form' + +import { SmartListFormSchema } from '../schema' + +type SubSchema = Pick + +export default function BasicDetails() { + const form = useFormContext() + + const { t } = useLocaleContext() + + return ( +
+ + +