Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c8b4e9e32 | ||
|
|
e2fb32f898 | ||
|
|
a2c947f870 | ||
|
|
abb6002078 | ||
|
|
f447a9a163 | ||
|
|
a26f31a150 | ||
|
|
c911258578 | ||
|
|
d52fa90b6b | ||
|
|
bfb926cec6 |
Generated
+73
-243
@@ -251,14 +251,13 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ammonia"
|
name = "ammonia"
|
||||||
version = "4.1.2"
|
version = "4.1.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
|
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cssparser 0.35.0",
|
"cssparser",
|
||||||
"html5ever 0.35.0",
|
"html5ever",
|
||||||
"maplit",
|
"maplit",
|
||||||
"tendril 0.4.3",
|
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -323,15 +322,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.104"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atom_syndication"
|
name = "atom_syndication"
|
||||||
version = "0.12.8"
|
version = "0.12.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "39d8b8ef99e33deb2a51504888222225797f48bace2f0cab01975746a39c05bb"
|
checksum = "d48d93a31c932d58b9fd68664a904ae5b74bea37f06c5f68d5bec92f0cdd106d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"derive_builder",
|
"derive_builder",
|
||||||
@@ -386,12 +385,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bcrypt"
|
name = "base64"
|
||||||
version = "0.19.1"
|
version = "0.23.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24ae5479c93d3720e4c1dbd6b945b97457c50cb672781104768190371df1a905"
|
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bcrypt"
|
||||||
|
version = "0.19.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.23.1",
|
||||||
"blowfish",
|
"blowfish",
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -506,9 +511,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chacha20"
|
name = "chacha20"
|
||||||
version = "0.10.0"
|
version = "0.10.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.3.0",
|
"cpufeatures 0.3.0",
|
||||||
@@ -668,42 +673,19 @@ dependencies = [
|
|||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cssparser"
|
|
||||||
version = "0.35.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
|
|
||||||
dependencies = [
|
|
||||||
"cssparser-macros 0.6.1",
|
|
||||||
"dtoa-short",
|
|
||||||
"itoa",
|
|
||||||
"phf 0.11.3",
|
|
||||||
"smallvec",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cssparser"
|
name = "cssparser"
|
||||||
version = "0.37.0"
|
version = "0.37.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
|
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cssparser-macros 0.7.0",
|
"cssparser-macros",
|
||||||
"dtoa-short",
|
"dtoa-short",
|
||||||
"itoa",
|
"itoa",
|
||||||
"phf 0.13.1",
|
"phf",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cssparser-macros"
|
|
||||||
version = "0.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
|
|
||||||
dependencies = [
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cssparser-macros"
|
name = "cssparser-macros"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -963,10 +945,10 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dotenv"
|
name = "dotenvy"
|
||||||
version = "0.15.0"
|
version = "0.15.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
|
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "downcast-rs"
|
name = "downcast-rs"
|
||||||
@@ -1124,16 +1106,6 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futf"
|
|
||||||
version = "0.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
|
|
||||||
dependencies = [
|
|
||||||
"mac",
|
|
||||||
"new_debug_unreachable",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -1281,11 +1253,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasip3",
|
"wasip3",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1332,9 +1306,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.14"
|
version = "0.4.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
|
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1396,17 +1370,6 @@ dependencies = [
|
|||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "html5ever"
|
|
||||||
version = "0.35.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
"markup5ever 0.35.0",
|
|
||||||
"match_token",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.39.0"
|
version = "0.39.0"
|
||||||
@@ -1414,7 +1377,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
|
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"markup5ever 0.39.0",
|
"markup5ever",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1492,7 +1455,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"h2 0.4.14",
|
"h2 0.4.19",
|
||||||
"http 1.4.1",
|
"http 1.4.1",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1899,29 +1862,12 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mac"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "maplit"
|
name = "maplit"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "markup5ever"
|
|
||||||
version = "0.35.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
"tendril 0.4.3",
|
|
||||||
"web_atoms 0.1.3",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.39.0"
|
version = "0.39.0"
|
||||||
@@ -1929,19 +1875,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
|
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"tendril 0.5.0",
|
"tendril",
|
||||||
"web_atoms 0.2.4",
|
"web_atoms",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "match_token"
|
|
||||||
version = "0.35.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2073,55 +2008,25 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "phf"
|
|
||||||
version = "0.11.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
|
||||||
dependencies = [
|
|
||||||
"phf_macros 0.11.3",
|
|
||||||
"phf_shared 0.11.3",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf"
|
name = "phf"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_macros 0.13.1",
|
"phf_macros",
|
||||||
"phf_shared 0.13.1",
|
"phf_shared",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "phf_codegen"
|
|
||||||
version = "0.11.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
|
||||||
dependencies = [
|
|
||||||
"phf_generator 0.11.3",
|
|
||||||
"phf_shared 0.11.3",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf_codegen"
|
name = "phf_codegen"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_generator 0.13.1",
|
"phf_generator",
|
||||||
"phf_shared 0.13.1",
|
"phf_shared",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "phf_generator"
|
|
||||||
version = "0.11.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
|
||||||
dependencies = [
|
|
||||||
"phf_shared 0.11.3",
|
|
||||||
"rand 0.8.6",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2131,20 +2036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"phf_shared 0.13.1",
|
"phf_shared",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "phf_macros"
|
|
||||||
version = "0.11.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
|
||||||
dependencies = [
|
|
||||||
"phf_generator 0.11.3",
|
|
||||||
"phf_shared 0.11.3",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2153,22 +2045,13 @@ version = "0.13.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_generator 0.13.1",
|
"phf_generator",
|
||||||
"phf_shared 0.13.1",
|
"phf_shared",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "phf_shared"
|
|
||||||
version = "0.11.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
|
||||||
dependencies = [
|
|
||||||
"siphasher",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf_shared"
|
name = "phf_shared"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@@ -2282,9 +2165,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.39.4"
|
version = "0.41.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
|
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -2312,15 +2195,16 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-proto"
|
name = "quinn-proto"
|
||||||
version = "0.11.14"
|
version = "0.11.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"bytes",
|
"bytes",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.2",
|
||||||
"lru-slab",
|
"lru-slab",
|
||||||
"rand 0.9.4",
|
"rand 0.10.1",
|
||||||
|
"rand_pcg",
|
||||||
"ring",
|
"ring",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -2367,15 +2251,6 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand"
|
|
||||||
version = "0.8.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
|
||||||
dependencies = [
|
|
||||||
"rand_core 0.6.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -2407,12 +2282,6 @@ dependencies = [
|
|||||||
"rand_core 0.9.5",
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_core"
|
|
||||||
version = "0.6.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
@@ -2428,6 +2297,15 @@ version = "0.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_pcg"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raw-cpuid"
|
name = "raw-cpuid"
|
||||||
version = "11.6.0"
|
version = "11.6.0"
|
||||||
@@ -2493,7 +2371,7 @@ dependencies = [
|
|||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2 0.4.14",
|
"h2 0.4.19",
|
||||||
"http 1.4.1",
|
"http 1.4.1",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -2539,9 +2417,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rss"
|
name = "rss"
|
||||||
version = "2.0.13"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0e38781082c53bdde56e6081698c06831f69a27eac2593ed6b6cb2511424ad5"
|
checksum = "dc13570823abc675c60d7837f1fe7daaf18dd181a3cacb3c6ba7a062eef581c0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atom_syndication",
|
"atom_syndication",
|
||||||
"derive_builder",
|
"derive_builder",
|
||||||
@@ -2564,7 +2442,7 @@ dependencies = [
|
|||||||
"dateparser",
|
"dateparser",
|
||||||
"diesel",
|
"diesel",
|
||||||
"diesel_migrations",
|
"diesel_migrations",
|
||||||
"dotenv",
|
"dotenvy",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"futures",
|
"futures",
|
||||||
"hmac",
|
"hmac",
|
||||||
@@ -2713,13 +2591,13 @@ version = "0.27.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
|
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cssparser 0.37.0",
|
"cssparser",
|
||||||
"ego-tree",
|
"ego-tree",
|
||||||
"getopts",
|
"getopts",
|
||||||
"html5ever 0.39.0",
|
"html5ever",
|
||||||
"precomputed-hash",
|
"precomputed-hash",
|
||||||
"selectors",
|
"selectors",
|
||||||
"tendril 0.5.0",
|
"tendril",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2752,12 +2630,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
|
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"cssparser 0.37.0",
|
"cssparser",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"log",
|
"log",
|
||||||
"new_debug_unreachable",
|
"new_debug_unreachable",
|
||||||
"phf 0.13.1",
|
"phf",
|
||||||
"phf_codegen 0.13.1",
|
"phf_codegen",
|
||||||
"precomputed-hash",
|
"precomputed-hash",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"servo_arc",
|
"servo_arc",
|
||||||
@@ -2956,19 +2834,6 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "string_cache"
|
|
||||||
version = "0.8.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
|
||||||
dependencies = [
|
|
||||||
"new_debug_unreachable",
|
|
||||||
"parking_lot",
|
|
||||||
"phf_shared 0.11.3",
|
|
||||||
"precomputed-hash",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "string_cache"
|
name = "string_cache"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2977,30 +2842,18 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"new_debug_unreachable",
|
"new_debug_unreachable",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"phf_shared 0.13.1",
|
"phf_shared",
|
||||||
"precomputed-hash",
|
"precomputed-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "string_cache_codegen"
|
|
||||||
version = "0.5.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
|
||||||
dependencies = [
|
|
||||||
"phf_generator 0.11.3",
|
|
||||||
"phf_shared 0.11.3",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "string_cache_codegen"
|
name = "string_cache_codegen"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_generator 0.13.1",
|
"phf_generator",
|
||||||
"phf_shared 0.13.1",
|
"phf_shared",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
]
|
]
|
||||||
@@ -3069,17 +2922,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tendril"
|
|
||||||
version = "0.4.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
|
|
||||||
dependencies = [
|
|
||||||
"futf",
|
|
||||||
"mac",
|
|
||||||
"utf-8",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tendril"
|
name = "tendril"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -3573,28 +3415,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "web_atoms"
|
|
||||||
version = "0.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
|
|
||||||
dependencies = [
|
|
||||||
"phf 0.11.3",
|
|
||||||
"phf_codegen 0.11.3",
|
|
||||||
"string_cache 0.8.9",
|
|
||||||
"string_cache_codegen 0.5.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web_atoms"
|
name = "web_atoms"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
|
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf 0.13.1",
|
"phf",
|
||||||
"phf_codegen 0.13.1",
|
"phf_codegen",
|
||||||
"string_cache 0.9.0",
|
"string_cache",
|
||||||
"string_cache_codegen 0.6.1",
|
"string_cache_codegen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ serde_derive = "1.0.228"
|
|||||||
actix-service = "2.0.3"
|
actix-service = "2.0.3"
|
||||||
diesel = { version = "2.3", features = ["postgres", "chrono"] }
|
diesel = { version = "2.3", features = ["postgres", "chrono"] }
|
||||||
diesel_migrations = "2.3"
|
diesel_migrations = "2.3"
|
||||||
dotenv = "0.15.0"
|
dotenvy = "0.15"
|
||||||
bcrypt = "0.19"
|
bcrypt = "0.19"
|
||||||
uuid = {version = "1.23", features=["serde", "v4"]}
|
uuid = {version = "1.23", features=["serde", "v4"]}
|
||||||
jwt = "0.16.0"
|
jwt = "0.16.0"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- This file should undo anything in `up.sql`
|
||||||
|
ALTER TABLE feed_item
|
||||||
|
DROP CONSTRAINT feed_item_feed_id_url_unique;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- Your SQL goes here
|
||||||
|
|
||||||
|
-- Concurrent syncs for the same feed could previously race past the
|
||||||
|
-- application-level "does this article already exist" check and both
|
||||||
|
-- insert, producing duplicate feed items. Before enforcing uniqueness,
|
||||||
|
-- collapse any duplicates that already exist, keyed on the article's link
|
||||||
|
-- (rather than its title) since that's the stable identity RSS items are
|
||||||
|
-- built around — some feeds edit a headline after publishing while keeping
|
||||||
|
-- the same link, which title-based matching would treat as a new article.
|
||||||
|
|
||||||
|
-- If any duplicate in a group was already read, propagate that onto the
|
||||||
|
-- surviving (lowest-id) row first, so this one-time cleanup can't make an
|
||||||
|
-- already-read article look unread again.
|
||||||
|
UPDATE feed_item AS keep
|
||||||
|
SET read = true
|
||||||
|
FROM feed_item AS dupe
|
||||||
|
WHERE keep.feed_id = dupe.feed_id
|
||||||
|
AND keep.url = dupe.url
|
||||||
|
AND keep.id <> dupe.id
|
||||||
|
AND keep.id = (
|
||||||
|
SELECT MIN(candidate.id)
|
||||||
|
FROM feed_item AS candidate
|
||||||
|
WHERE candidate.feed_id = dupe.feed_id
|
||||||
|
AND candidate.url = dupe.url
|
||||||
|
)
|
||||||
|
AND dupe.read = true;
|
||||||
|
|
||||||
|
-- Keep the oldest (lowest id) row of each duplicate set.
|
||||||
|
DELETE FROM feed_item a
|
||||||
|
USING feed_item b
|
||||||
|
WHERE a.feed_id = b.feed_id
|
||||||
|
AND a.url = b.url
|
||||||
|
AND a.id > b.id;
|
||||||
|
|
||||||
|
ALTER TABLE feed_item
|
||||||
|
ADD CONSTRAINT feed_item_feed_id_url_unique UNIQUE (feed_id, url);
|
||||||
+1
-1
@@ -6,7 +6,7 @@ use std::env;
|
|||||||
|
|
||||||
use actix_web::HttpRequest;
|
use actix_web::HttpRequest;
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use dotenv::dotenv;
|
use dotenvy::dotenv;
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
|
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
use diesel::pg::PgConnection;
|
use diesel::pg::PgConnection;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||||
use dotenv::dotenv;
|
use dotenvy::dotenv;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
||||||
|
|||||||
+1
-2
@@ -1,10 +1,9 @@
|
|||||||
extern crate diesel;
|
extern crate diesel;
|
||||||
extern crate dotenv;
|
|
||||||
|
|
||||||
use actix_cors::Cors;
|
use actix_cors::Cors;
|
||||||
use actix_service::Service;
|
use actix_service::Service;
|
||||||
use actix_web::{App, HttpMessage, HttpResponse, HttpServer};
|
use actix_web::{App, HttpMessage, HttpResponse, HttpServer};
|
||||||
use dotenv::dotenv;
|
use dotenvy::dotenv;
|
||||||
use futures::future::{ok, Either};
|
use futures::future::{ok, Either};
|
||||||
use std::env;
|
use std::env;
|
||||||
mod auth;
|
mod auth;
|
||||||
|
|||||||
+76
-19
@@ -4,8 +4,7 @@ use crate::error::AppError;
|
|||||||
use crate::json_serialization::user::JsonUser;
|
use crate::json_serialization::user::JsonUser;
|
||||||
use crate::models::feed::rss_feed::Feed;
|
use crate::models::feed::rss_feed::Feed;
|
||||||
use crate::models::feed_item::new_feed_item::NewFeedItem;
|
use crate::models::feed_item::new_feed_item::NewFeedItem;
|
||||||
use crate::models::feed_item::rss_feed_item::FeedItem;
|
use crate::schema::feed_item::{feed_id, url};
|
||||||
use crate::schema::feed_item::{feed_id, title};
|
|
||||||
use crate::{
|
use crate::{
|
||||||
database::establish_connection,
|
database::establish_connection,
|
||||||
schema::{
|
schema::{
|
||||||
@@ -143,24 +142,29 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> a
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let existing_item: Vec<FeedItem> = feed_item::table
|
let new_feed_item = NewFeedItem::new(
|
||||||
.filter(feed_id.eq(feed.id))
|
feed.id,
|
||||||
.filter(title.eq(&item_title))
|
content.clone(),
|
||||||
.load(connection)?;
|
item_title.clone(),
|
||||||
|
item.link.expect("checked above"),
|
||||||
|
Some(time),
|
||||||
|
);
|
||||||
|
|
||||||
if existing_item.is_empty() {
|
// `on_conflict` on the (feed_id, url) unique constraint makes this
|
||||||
let new_feed_item = NewFeedItem::new(
|
// insert idempotent, so two syncs for the same feed running concurrently
|
||||||
feed.id,
|
// (e.g. a sync button press racing a page-reload sync) can't both pass a
|
||||||
content.clone(),
|
// check-then-insert race and create duplicate items. Keying on the
|
||||||
item_title.clone(),
|
// article's link rather than its title also handles feeds that edit a
|
||||||
item.link.expect("checked above"),
|
// headline after publishing while keeping the same link — the link is
|
||||||
Some(time),
|
// the stable identity.
|
||||||
);
|
let inserted_rows = diesel::insert_into(feed_item::table)
|
||||||
let insert_result = diesel::insert_into(feed_item::table)
|
.values(&new_feed_item)
|
||||||
.values(&new_feed_item)
|
.on_conflict((feed_id, url))
|
||||||
.execute(connection);
|
.do_nothing()
|
||||||
|
.execute(connection)?;
|
||||||
|
|
||||||
log::info!("Insert Result: {:?}", insert_result);
|
if inserted_rows > 0 {
|
||||||
|
log::info!("Inserted item: {}", item_title);
|
||||||
} else {
|
} else {
|
||||||
log::info!("Item {} already exists.", item_title);
|
log::info!("Item {} already exists.", item_title);
|
||||||
}
|
}
|
||||||
@@ -210,10 +214,11 @@ pub async fn sync(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::models::feed::new_feed::NewFeed;
|
use crate::models::feed::new_feed::NewFeed;
|
||||||
|
use crate::models::feed_item::rss_feed_item::FeedItem;
|
||||||
use crate::models::user::new_user::NewUser;
|
use crate::models::user::new_user::NewUser;
|
||||||
use crate::models::user::rss_user::User;
|
use crate::models::user::rss_user::User;
|
||||||
use crate::schema::users;
|
use crate::schema::users;
|
||||||
use crate::test_helpers::unique_suffix;
|
use crate::test_helpers::{delete_feed, delete_user, insert_feed, insert_user, unique_suffix};
|
||||||
use chrono::Duration;
|
use chrono::Duration;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -440,6 +445,58 @@ mod tests {
|
|||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn create_feed_item_is_race_safe_under_concurrent_syncs() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let suffix = unique_suffix();
|
||||||
|
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
let feed = insert_feed(&mut connection, user.id);
|
||||||
|
|
||||||
|
let mut item = Item::default();
|
||||||
|
item.set_title(Some(format!("Race test article {suffix}")));
|
||||||
|
item.set_link(Some(format!("https://example.test/race/{suffix}")));
|
||||||
|
item.set_content(Some("<p>Hello world</p>".to_string()));
|
||||||
|
|
||||||
|
// Simulate two concurrent syncs for the same feed (e.g. a sync
|
||||||
|
// button press racing a page-reload sync) — each on its own
|
||||||
|
// connection/thread, released together so they genuinely overlap —
|
||||||
|
// both inserting the same item.
|
||||||
|
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||||
|
let handles: Vec<_> = (0..2)
|
||||||
|
.map(|_| {
|
||||||
|
let feed = feed.clone();
|
||||||
|
let item = item.clone();
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
barrier.wait();
|
||||||
|
create_feed_item(item, &feed, &mut connection)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for handle in handles {
|
||||||
|
handle.join().unwrap().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: Vec<FeedItem> = feed_item::table
|
||||||
|
.filter(feed_id.eq(feed.id))
|
||||||
|
.load(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
1,
|
||||||
|
items.len(),
|
||||||
|
"concurrent syncs must not create duplicate feed items"
|
||||||
|
);
|
||||||
|
|
||||||
|
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
|
||||||
|
.execute(&mut connection)
|
||||||
|
.ok();
|
||||||
|
delete_feed(&mut connection, feed.id);
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
#[actix_web::test]
|
#[actix_web::test]
|
||||||
async fn create_feed_item_strips_onerror_from_feed_image() {
|
async fn create_feed_item_strips_onerror_from_feed_image() {
|
||||||
let mut connection = establish_connection();
|
let mut connection = establish_connection();
|
||||||
|
|||||||
@@ -6,16 +6,37 @@ import Modal from './modal/AddUrl.vue'
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, feedUnreadCounts, setFeedFilter, unreadCount, lastProgrammaticScroll } = useFeeds()
|
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll } = useFeeds()
|
||||||
|
|
||||||
const headerRef = ref(null)
|
const headerRef = ref(null)
|
||||||
|
|
||||||
|
// Native <select>/<option> has no "is open" state — the closed box just shows
|
||||||
|
// the selected option's text verbatim, so per-option unread counts can only be
|
||||||
|
// toggled based on focus, since the popup is only ever shown while focused.
|
||||||
|
// @focus ~ opening (shows counts); @change (picking an option closes the
|
||||||
|
// popup but keeps focus, so no blur fires) and @blur (focus left without
|
||||||
|
// picking) ~ closed (counts hidden, so the closed box just shows the name).
|
||||||
|
const filterOpen = ref(false)
|
||||||
|
|
||||||
|
function onFilterFocus() {
|
||||||
|
filterOpen.value = true
|
||||||
|
refreshUnreadDisplay()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilterChange(event) {
|
||||||
|
setFeedFilter(event.target.value || null)
|
||||||
|
filterOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
// Scroll-driven show/hide: the header slides out of view on scroll-down and
|
// Scroll-driven show/hide: the header slides out of view on scroll-down and
|
||||||
// back in on scroll-up. This is show/hide via `transform` (not the old
|
// back in only on scroll-up — never merely by proximity to the top. This is
|
||||||
// resize behaviour) — the header is position:fixed, so translating it never
|
// show/hide via `transform` (not the old resize behaviour) — the header is
|
||||||
// reflows content, and the app's programmatic scrolls resolve to sensible
|
// position:fixed, so translating it never reflows content. The app's
|
||||||
// states: scrollTo(0, 0) lands near the top → shown; the list-view
|
// programmatic scrolls resolve to sensible states without any top-of-page
|
||||||
// read-correction scrollBy moves only a few px → stays under the threshold.
|
// special case: scrollTo(0, 0) (article paging, layout toggle) is itself a
|
||||||
|
// large upward jump, so it crosses REVEAL_THRESHOLD like a real scroll-up and
|
||||||
|
// reveals the header that way; the list-view read-correction scrollBy moves
|
||||||
|
// only a few px → stays under the threshold and leaves a hidden header hidden.
|
||||||
const hidden = ref(false)
|
const hidden = ref(false)
|
||||||
const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling
|
const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling
|
||||||
// When the feed list mutates itself (read-correction scrollBy + array-splice
|
// When the feed list mutates itself (read-correction scrollBy + array-splice
|
||||||
@@ -30,12 +51,10 @@ let accumulated = 0
|
|||||||
|
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
const y = Math.max(0, window.scrollY)
|
const y = Math.max(0, window.scrollY)
|
||||||
const headerH = headerRef.value?.offsetHeight ?? 0
|
|
||||||
|
|
||||||
// Always reveal near the very top, and keep it visible while the menu is
|
// Keep the header visible while the menu is open (the dropdown is anchored
|
||||||
// open (the dropdown is anchored to the header, so hiding it would slide the
|
// to the header, so hiding it would slide the open menu off-screen).
|
||||||
// open menu off-screen).
|
if (menuOpen.value) {
|
||||||
if (y <= headerH || menuOpen.value) {
|
|
||||||
hidden.value = false
|
hidden.value = false
|
||||||
accumulated = 0
|
accumulated = 0
|
||||||
lastY = y
|
lastY = y
|
||||||
@@ -78,6 +97,7 @@ onMounted(() => {
|
|||||||
lastY = Math.max(0, window.scrollY)
|
lastY = Math.max(0, window.scrollY)
|
||||||
window.addEventListener('scroll', onScrollRaf, { passive: true })
|
window.addEventListener('scroll', onScrollRaf, { passive: true })
|
||||||
document.addEventListener('click', onDocumentClick)
|
document.addEventListener('click', onDocumentClick)
|
||||||
|
refreshUnreadDisplay()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -152,10 +172,12 @@ function handleToggleLayout() {
|
|||||||
class="app-nav__filter"
|
class="app-nav__filter"
|
||||||
:value="feedFilter ?? ''"
|
:value="feedFilter ?? ''"
|
||||||
aria-label="Filter by feed"
|
aria-label="Filter by feed"
|
||||||
@change="setFeedFilter($event.target.value || null)"
|
@focus="onFilterFocus"
|
||||||
|
@change="onFilterChange"
|
||||||
|
@blur="filterOpen = false"
|
||||||
>
|
>
|
||||||
<option value="">All feeds</option>
|
<option value="">All feeds</option>
|
||||||
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }} ({{ feedUnreadCounts[title] ?? 0 }})</option>
|
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }}<template v-if="filterOpen"> ({{ displayedFeedUnreadCounts[title] ?? 0 }})</template></option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
class="app-nav__hamburger"
|
class="app-nav__hamburger"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
|
import { onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
|
||||||
import { useFeeds } from '@/composables/useFeeds';
|
import { useFeeds } from '@/composables/useFeeds';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -43,22 +43,6 @@ let touchStartY = null
|
|||||||
const SWIPE_THRESHOLD_X = 50 // min horizontal travel (px) to count as a swipe
|
const SWIPE_THRESHOLD_X = 50 // min horizontal travel (px) to count as a swipe
|
||||||
const SWIPE_RESTRAINT_Y = 75 // max vertical travel (px) before it's treated as a scroll, not a swipe
|
const SWIPE_RESTRAINT_Y = 75 // max vertical travel (px) before it's treated as a scroll, not a swipe
|
||||||
|
|
||||||
// Drives which slide-in/slide-out transition ('slide-next' / 'slide-prev') the
|
|
||||||
// article view uses — set right before the index actually changes, by both
|
|
||||||
// the nav buttons and the swipe handler below, so the two triggers animate
|
|
||||||
// identically.
|
|
||||||
const slideDirection = ref('next')
|
|
||||||
|
|
||||||
function navigateToNextArticle() {
|
|
||||||
slideDirection.value = 'next'
|
|
||||||
nextArticle()
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateToPrevArticle() {
|
|
||||||
slideDirection.value = 'prev'
|
|
||||||
prevArticle()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onArticleTouchStart(event) {
|
function onArticleTouchStart(event) {
|
||||||
// Ignore multi-touch (pinch-zoom) and swipes starting inside a
|
// Ignore multi-touch (pinch-zoom) and swipes starting inside a
|
||||||
// horizontally-scrollable code block (.article-feature__content :deep(pre))
|
// horizontally-scrollable code block (.article-feature__content :deep(pre))
|
||||||
@@ -83,9 +67,9 @@ function onArticleTouchEnd(event) {
|
|||||||
if (Math.abs(deltaX) < SWIPE_THRESHOLD_X || Math.abs(deltaY) > SWIPE_RESTRAINT_Y) return
|
if (Math.abs(deltaX) < SWIPE_THRESHOLD_X || Math.abs(deltaY) > SWIPE_RESTRAINT_Y) return
|
||||||
|
|
||||||
if (deltaX < 0) {
|
if (deltaX < 0) {
|
||||||
navigateToNextArticle()
|
nextArticle()
|
||||||
} else {
|
} else {
|
||||||
navigateToPrevArticle()
|
prevArticle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,20 +173,22 @@ onMounted(async () => {
|
|||||||
</svg>
|
</svg>
|
||||||
<p class="empty-state__label">All caught up</p>
|
<p class="empty-state__label">All caught up</p>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<!-- Deliberately no enter/leave transition here: Vue's <Transition>
|
||||||
<Transition :name="`slide-${slideDirection}`">
|
component forces a synchronous whole-document reflow
|
||||||
<article class="article-feature" :key="currentIndex">
|
(document.body.offsetHeight, in @vue/runtime-dom's forceReflow) on
|
||||||
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
|
every enter/leave, regardless of the app's own CSS — confirmed (via
|
||||||
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
|
a DevTools performance trace) to be what was corrupting the header's
|
||||||
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
|
native <select>/<button> rendering during article paging. -->
|
||||||
<p class="feed-original-link">
|
<article v-else class="article-feature" :key="currentIndex">
|
||||||
<a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article ↗</a>
|
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
|
||||||
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
|
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
|
||||||
</p>
|
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
|
||||||
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
|
<p class="feed-original-link">
|
||||||
</article>
|
<a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article ↗</a>
|
||||||
</Transition>
|
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
|
||||||
</template>
|
</p>
|
||||||
|
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
|
||||||
|
</article>
|
||||||
|
|
||||||
<div class="article-nav">
|
<div class="article-nav">
|
||||||
<button
|
<button
|
||||||
@@ -210,14 +196,14 @@ onMounted(async () => {
|
|||||||
class="article-nav__btn"
|
class="article-nav__btn"
|
||||||
:disabled="currentIndex === 0"
|
:disabled="currentIndex === 0"
|
||||||
aria-label="Previous article"
|
aria-label="Previous article"
|
||||||
@click="navigateToPrevArticle"
|
@click="prevArticle"
|
||||||
>↑</button>
|
>↑</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="article-nav__btn"
|
class="article-nav__btn"
|
||||||
:disabled="feeds.length === 0 || currentIndex === feeds.length - 1"
|
:disabled="feeds.length === 0 || currentIndex === feeds.length - 1"
|
||||||
aria-label="Next article"
|
aria-label="Next article"
|
||||||
@click="navigateToNextArticle"
|
@click="nextArticle"
|
||||||
>↓</button>
|
>↓</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -367,6 +353,13 @@ onMounted(async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding-top: 1em;
|
padding-top: 1em;
|
||||||
padding-bottom: 5rem;
|
padding-bottom: 5rem;
|
||||||
|
/* Fill the viewport below the fixed header even when the article content
|
||||||
|
is short, so swipe-to-navigate works anywhere on screen instead of only
|
||||||
|
over the (possibly small) content area. dvh is the accurate unit on
|
||||||
|
mobile, where this matters most; vh is the fallback for browsers
|
||||||
|
without dvh support. */
|
||||||
|
min-height: calc(100vh - var(--app-nav-height, 4.5rem));
|
||||||
|
min-height: calc(100dvh - var(--app-nav-height, 4.5rem));
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-feature {
|
.article-feature {
|
||||||
@@ -374,46 +367,6 @@ onMounted(async () => {
|
|||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Slide-in/out when paging between articles (swipe or nav buttons). The
|
|
||||||
outgoing article is pulled out of flex flow during its leave transition so
|
|
||||||
the incoming one can take its place immediately, instead of the two
|
|
||||||
stacking and doubling the page height for the transition's duration. */
|
|
||||||
.slide-next-enter-active,
|
|
||||||
.slide-next-leave-active,
|
|
||||||
.slide-prev-enter-active,
|
|
||||||
.slide-prev-leave-active {
|
|
||||||
transition: transform 0.28s ease, opacity 0.28s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-next-leave-active,
|
|
||||||
.slide-prev-leave-active {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-next-enter-from {
|
|
||||||
transform: translateX(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-next-leave-to {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-prev-enter-from {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-prev-leave-to {
|
|
||||||
transform: translateX(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.article-feature__source {
|
.article-feature__source {
|
||||||
margin: 0 0 0.5em;
|
margin: 0 0 0.5em;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
|
|||||||
@@ -239,13 +239,36 @@ describe('AppNav', () => {
|
|||||||
|
|
||||||
const wrapper = mountNav()
|
const wrapper = mountNav()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
const select = wrapper.find('.app-nav__filter')
|
||||||
|
|
||||||
const options = wrapper.find('.app-nav__filter').findAll('option').map(o => o.text())
|
// Counts only show while the dropdown is open (focused) — see AppNav.vue.
|
||||||
|
await select.trigger('focus')
|
||||||
|
|
||||||
|
const options = select.findAll('option').map(o => o.text())
|
||||||
// "All feeds" first, then distinct titles sorted alphabetically, each with
|
// "All feeds" first, then distinct titles sorted alphabetically, each with
|
||||||
// its unread count.
|
// its unread count.
|
||||||
expect(options).toEqual(['All feeds', 'Feed A (2)', 'Feed B (1)'])
|
expect(options).toEqual(['All feeds', 'Feed A (2)', 'Feed B (1)'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('hides the per-option unread count once the feed filter is closed', async () => {
|
||||||
|
const { allItems } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||||
|
{ id: 2, feedTitle: 'Feed A', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
const select = wrapper.find('.app-nav__filter')
|
||||||
|
|
||||||
|
await select.trigger('focus')
|
||||||
|
await select.setValue('Feed A') // fires change, which closes the popup but keeps focus
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const feedAOption = select.findAll('option').find(o => o.element.value === 'Feed A')
|
||||||
|
expect(feedAOption.text()).toBe('Feed A')
|
||||||
|
})
|
||||||
|
|
||||||
it('does not render the feed filter when there are no feeds', async () => {
|
it('does not render the feed filter when there are no feeds', async () => {
|
||||||
const wrapper = mountNav()
|
const wrapper = mountNav()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -306,9 +329,17 @@ describe('AppNav', () => {
|
|||||||
// Reset scroll position so each mount's lastY baseline starts at 0.
|
// Reset scroll position so each mount's lastY baseline starts at 0.
|
||||||
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true })
|
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true })
|
||||||
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
|
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
|
||||||
// offsetHeight is 0 in jsdom; give the header a real height so the
|
// getBoundingClientRect() is all-zero in jsdom; give the header a real
|
||||||
// "near the top" guard (scrollY <= headerH) has something to compare to.
|
// height so onMounted()'s one-time measurement (used for the
|
||||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(50)
|
// --app-nav-height CSS var — see AppNav.vue) has a realistic value.
|
||||||
|
// This is a prototype-wide spy (scoped to this describe block only, restored in
|
||||||
|
// afterEach), so it returns a full DOMRect-shaped object rather than a
|
||||||
|
// partial one — no test here happens to call getBoundingClientRect() on
|
||||||
|
// anything but the header, but a bare `{ height: 50 }` would silently
|
||||||
|
// hand back `undefined` for top/left/width/etc. to any code that did.
|
||||||
|
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
height: 50, width: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -341,19 +372,38 @@ describe('AppNav', () => {
|
|||||||
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('always shows the header near the top of the page', async () => {
|
it('reveals the header on a large upward jump that lands near the top', async () => {
|
||||||
const wrapper = mountNav()
|
const wrapper = mountNav()
|
||||||
scrollTo(400)
|
scrollTo(400)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
// Back within the header's own height of the top → always revealed.
|
// A jump this large (e.g. article paging's scrollTo(0, 0)) crosses
|
||||||
|
// REVEAL_THRESHOLD on its own — no top-of-page special case needed.
|
||||||
scrollTo(10)
|
scrollTo(10)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not reveal the header merely for landing near the top', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
// Hide it with a jump just past the header's own height (50px, mocked
|
||||||
|
// above) plus the reveal threshold.
|
||||||
|
scrollTo(62)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
// Move back to right at the header's own height — within the "near the
|
||||||
|
// top" zone the old code always revealed for — but the upward delta
|
||||||
|
// (12px) doesn't exceed REVEAL_THRESHOLD, so a hidden header stays
|
||||||
|
// hidden: proximity to the top is no longer sufficient on its own.
|
||||||
|
scrollTo(50)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
})
|
||||||
|
|
||||||
it('does not let a programmatic upward jump reveal the header mid-read', async () => {
|
it('does not let a programmatic upward jump reveal the header mid-read', async () => {
|
||||||
const { markProgrammaticScroll } = useFeeds()
|
const { markProgrammaticScroll } = useFeeds()
|
||||||
const nowSpy = vi.spyOn(performance, 'now').mockReturnValue(1000)
|
const nowSpy = vi.spyOn(performance, 'now').mockReturnValue(1000)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class FakeIntersectionObserver {
|
|||||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||||
|
|
||||||
describe('useFeeds', () => {
|
describe('useFeeds', () => {
|
||||||
const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, setupIntersectionObserver } = useFeeds()
|
const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, setupIntersectionObserver } = useFeeds()
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('user-token', 'test-token')
|
localStorage.setItem('user-token', 'test-token')
|
||||||
@@ -119,6 +119,32 @@ describe('useFeeds', () => {
|
|||||||
setInitialLoad(false)
|
setInitialLoad(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('refreshes the filter dropdown\'s displayed per-feed counts when list-view scrolling marks an article read', async () => {
|
||||||
|
// displayedFeedUnreadCounts is a frozen snapshot of feedUnreadCounts (see
|
||||||
|
// useFeeds.js) — AppNav's filter <select> renders it instead of the live
|
||||||
|
// computed so paging through article view doesn't force it to repaint.
|
||||||
|
// List-view scrolling is a different code path (handleIntersection) and
|
||||||
|
// should keep it live regardless.
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 301, feedTitle: 'Feed A', read: false },
|
||||||
|
{ id: 302, feedTitle: 'Feed A', read: false },
|
||||||
|
]
|
||||||
|
feeds.value = [...allItems.value]
|
||||||
|
refreshUnreadDisplay()
|
||||||
|
expect(displayedFeedUnreadCounts.value).toEqual({ 'Feed A': 2 })
|
||||||
|
|
||||||
|
setInitialLoad(true)
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
await handleIntersection([
|
||||||
|
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(displayedFeedUnreadCounts.value).toEqual({ 'Feed A': 1 })
|
||||||
|
|
||||||
|
setInitialLoad(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('ignores the initial observer snapshot on connect so a scrolled-down reconnect cannot mass-mark', async () => {
|
it('ignores the initial observer snapshot on connect so a scrolled-down reconnect cannot mass-mark', async () => {
|
||||||
feeds.value = [
|
feeds.value = [
|
||||||
{ id: 201, title: 'First' },
|
{ id: 201, title: 'First' },
|
||||||
|
|||||||
@@ -43,6 +43,21 @@ const unreadCount = computed(() => {
|
|||||||
: allItems.value
|
: allItems.value
|
||||||
return items.filter(i => !i.read).length
|
return items.filter(i => !i.read).length
|
||||||
})
|
})
|
||||||
|
// AppNav renders this snapshot (refreshed via refreshUnreadDisplay, below)
|
||||||
|
// for the filter <select>'s per-feed option counts, instead of binding
|
||||||
|
// feedUnreadCounts directly — by design, this stays frozen while paging
|
||||||
|
// through article view (markCurrentArticleRead marks one article read per
|
||||||
|
// page turn, which would otherwise recompute it live on every single swipe).
|
||||||
|
// The header's own unread badge stays bound to the live unreadCount (it
|
||||||
|
// should update in real time regardless of view). Everywhere else that marks
|
||||||
|
// articles read — list-view scrolling (handleIntersection), opening/changing
|
||||||
|
// the filter, syncing, mark-all-read, leaving article view — refreshes this
|
||||||
|
// snapshot immediately, so it only ever looks stale during article-view
|
||||||
|
// paging itself.
|
||||||
|
const displayedFeedUnreadCounts = ref({})
|
||||||
|
function refreshUnreadDisplay() {
|
||||||
|
displayedFeedUnreadCounts.value = feedUnreadCounts.value
|
||||||
|
}
|
||||||
const message = ref('')
|
const message = ref('')
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device
|
const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device
|
||||||
@@ -242,6 +257,7 @@ async function setFeedFilter(title) {
|
|||||||
feedFilter.value = title // null for "All feeds"
|
feedFilter.value = title // null for "All feeds"
|
||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
applyFilter()
|
applyFilter()
|
||||||
|
refreshUnreadDisplay()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
setupIntersectionObserver()
|
setupIntersectionObserver()
|
||||||
}
|
}
|
||||||
@@ -259,6 +275,7 @@ const fetchData = async () => {
|
|||||||
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||||
allItems.value = items;
|
allItems.value = items;
|
||||||
applyFilter();
|
applyFilter();
|
||||||
|
refreshUnreadDisplay();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
setupIntersectionObserver();
|
setupIntersectionObserver();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -346,6 +363,7 @@ function handleIntersection(entries, topbarHeight = 0) {
|
|||||||
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
|
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
|
||||||
// Mirror into the master so cleared filters don't resurrect read articles.
|
// Mirror into the master so cleared filters don't resurrect read articles.
|
||||||
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
|
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
|
||||||
|
refreshUnreadDisplay()
|
||||||
|
|
||||||
for (const feed of readFeeds) {
|
for (const feed of readFeeds) {
|
||||||
markRead(feed.id)
|
markRead(feed.id)
|
||||||
@@ -392,6 +410,7 @@ async function markAllRead() {
|
|||||||
// is active) — drop exactly those from the master too.
|
// is active) — drop exactly those from the master too.
|
||||||
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
|
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
|
||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
|
refreshUnreadDisplay()
|
||||||
// markRead swallows its own errors, so Promise.all can't reject here.
|
// markRead swallows its own errors, so Promise.all can't reject here.
|
||||||
await Promise.all(ids.map(id => markRead(id)))
|
await Promise.all(ids.map(id => markRead(id)))
|
||||||
showMessageForXSeconds('All articles marked as read.', 5)
|
showMessageForXSeconds('All articles marked as read.', 5)
|
||||||
@@ -418,6 +437,7 @@ async function leaveArticleView() {
|
|||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
viewMode.value = 'list'
|
viewMode.value = 'list'
|
||||||
localStorage.setItem('viewMode', viewMode.value)
|
localStorage.setItem('viewMode', viewMode.value)
|
||||||
|
refreshUnreadDisplay()
|
||||||
// The v-if on the list container tears down and recreates all .observe DOM
|
// The v-if on the list container tears down and recreates all .observe DOM
|
||||||
// nodes when switching views, so the intersection observer must be
|
// nodes when switching views, so the intersection observer must be
|
||||||
// re-pointed at the new elements after Vue has finished rendering.
|
// re-pointed at the new elements after Vue has finished rendering.
|
||||||
@@ -479,6 +499,8 @@ export function useFeeds() {
|
|||||||
feedTitles,
|
feedTitles,
|
||||||
feedUnreadCounts,
|
feedUnreadCounts,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
|
displayedFeedUnreadCounts,
|
||||||
|
refreshUnreadDisplay,
|
||||||
setFeedFilter,
|
setFeedFilter,
|
||||||
showMessage,
|
showMessage,
|
||||||
message,
|
message,
|
||||||
|
|||||||
Reference in New Issue
Block a user