Compare commits

..

19 Commits

Author SHA1 Message Date
Lennart K
968a5e931c Make CalendarObject id an extrinsic property 2025-12-28 14:14:04 +01:00
Lennart K
2e89b63cd2 Outsource lots of stuff to ical library 2025-12-28 13:24:10 +01:00
Lennart K
1cfc8e7c23 frontend: Update dependencies 2025-12-27 15:07:06 +01:00
Lennart
b0fdca1b64 clippy appeasement 2025-12-27 14:30:36 +01:00
Lennart
b65cca9d17 version 0.11.6 2025-12-27 14:23:56 +01:00
Lennart
55ecbdcd41 carddav: Implement addressbook-query 2025-12-27 14:22:23 +01:00
Lennart
89d3d3b7a4 caldav: Outsource text-match to rustical_dav 2025-12-27 13:45:26 +01:00
Lennart K
a74b74369c version 0.11.5 2025-12-21 16:16:48 +01:00
Lennart K
85c49a0bdf update ical-rs 2025-12-20 14:30:26 +01:00
Lennart K
f2e4e2c1a7 add a database benchmark 2025-12-20 11:48:30 +01:00
Lennart K
28c925301e calendar store: Add method for bulk insert 2025-12-20 11:48:05 +01:00
Lennart K
b50ea478db Content-Type: Add charset=utf-8 2025-12-18 21:43:27 +01:00
Lennart K
2c7748255c update test snapshot 2025-12-18 21:40:39 +01:00
Lennart K
f40a23a1f1 update Cargo.toml and fix calendar export ical version 2025-12-18 21:39:36 +01:00
Lennart K
2a4ba33e45 refactor recurrence expansion 2025-12-18 21:27:40 +01:00
Lennart K
6bc4bd3fa3 Update ical-rs dependency 2025-12-18 14:14:26 +01:00
Lennart
7b32d478b8 version 0.11.4 2025-12-17 17:35:11 +01:00
Lennart
fb0bd67176 Update ical-rs version to fix bug with unicode characters in params
addresses #157
2025-12-17 17:33:46 +01:00
Lennart
ecad0d4490 frontend: update vite 2025-12-15 20:44:50 +01:00
50 changed files with 1150 additions and 1291 deletions

387
Cargo.lock generated
View File

@@ -26,6 +26,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "alloca"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
dependencies = [
"cc",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -41,6 +50,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstream"
version = "0.6.21"
@@ -156,9 +171,9 @@ dependencies = [
[[package]]
name = "askama_web"
version = "0.14.6"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50dcd7d2caaff31b91ef5d112ed10416344e23a33db9e7eea7ba695d2a97a88a"
checksum = "e1acadd534892f9ef8c3809b47997e3cd857fad735edceff77a88be1c8236920"
dependencies = [
"askama",
"askama_web_derive",
@@ -260,9 +275,9 @@ dependencies = [
[[package]]
name = "async-lock"
version = "3.4.1"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener 5.4.1",
"event-listener-strategy",
@@ -345,9 +360,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "axum"
version = "0.8.7"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"bytes",
@@ -397,9 +412,9 @@ dependencies = [
[[package]]
name = "axum-extra"
version = "0.12.2"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbfe9f610fe4e99cf0cfcd03ccf8c63c28c616fe714d80475ef731f3b13dd21b"
checksum = "6dfbd6109d91702d55fc56df06aae7ed85c465a7a451db6c0e54a4b9ca5983d1"
dependencies = [
"axum",
"axum-core",
@@ -518,9 +533,9 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.19.0"
version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]]
name = "bytemuck"
@@ -541,10 +556,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "cc"
version = "1.2.49"
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -595,6 +616,33 @@ dependencies = [
"phf",
]
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "clap"
version = "4.5.53"
@@ -718,6 +766,61 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "criterion"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d883447757bb0ee46f233e9dc22eb84d93a9508c9b868687b274fc431d886bf"
dependencies = [
"alloca",
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"itertools 0.13.0",
"num-traits",
"oorandom",
"page_size",
"plotters",
"rayon",
"regex",
"serde",
"serde_json",
"tinytemplate",
"tokio",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed943f81ea2faa8dcecbbfa50164acf95d555afec96a27871663b300e387b2e4"
dependencies = [
"cast",
"itertools 0.13.0",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
@@ -733,6 +836,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
@@ -874,18 +983,18 @@ dependencies = [
[[package]]
name = "derive_more"
version = "2.1.0"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.0"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case",
"proc-macro2",
@@ -1382,6 +1491,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1641,14 +1761,14 @@ dependencies = [
[[package]]
name = "ical"
version = "0.11.0"
source = "git+https://github.com/lennart-k/ical-rs#474caf58acbc8ebefd90e6b848741d7ed5136d65"
dependencies = [
"chrono",
"chrono-tz",
"derive_more",
"itertools 0.14.0",
"lazy_static",
"regex",
"serde",
"rrule",
"thiserror 2.0.17",
]
@@ -1791,14 +1911,15 @@ checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
[[package]]
name = "insta"
version = "1.44.3"
version = "1.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698"
checksum = "b76866be74d68b1595eb8060cb9191dca9c021db2316558e52ddc5d55d41b66c"
dependencies = [
"console",
"once_cell",
"regex",
"similar",
"tempfile",
]
[[package]]
@@ -1832,6 +1953,15 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -1843,9 +1973,9 @@ dependencies = [
[[package]]
name = "itoa"
version = "1.0.15"
version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010"
[[package]]
name = "js-sys"
@@ -1889,13 +2019,13 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
[[package]]
name = "libredox"
version = "0.1.10"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50"
dependencies = [
"bitflags",
"libc",
"redox_syscall",
"redox_syscall 0.6.0",
]
[[package]]
@@ -2133,6 +2263,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "openidconnect"
version = "4.0.1"
@@ -2327,6 +2463,16 @@ dependencies = [
"sha2",
]
[[package]]
name = "page_size"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -2351,7 +2497,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"redox_syscall 0.5.18",
"smallvec",
"windows-link",
]
@@ -2538,6 +2684,34 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "polling"
version = "3.11.0"
@@ -2591,7 +2765,7 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
dependencies = [
"toml_edit 0.23.9",
"toml_edit 0.23.10+spec-1.0.0",
]
[[package]]
@@ -2777,6 +2951,26 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rayon"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -2786,6 +2980,15 @@ dependencies = [
"bitflags",
]
[[package]]
name = "redox_syscall"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5"
dependencies = [
"bitflags",
]
[[package]]
name = "ref-cast"
version = "1.0.25"
@@ -2843,9 +3046,9 @@ checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
[[package]]
name = "reqwest"
version = "0.12.25"
version = "0.12.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a"
checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -3059,7 +3262,7 @@ dependencies = [
[[package]]
name = "rustical"
version = "0.11.3"
version = "0.11.6"
dependencies = [
"anyhow",
"argon2",
@@ -3092,7 +3295,7 @@ dependencies = [
"serde",
"sqlx",
"tokio",
"toml 0.9.8",
"toml 0.9.10+spec-1.1.0",
"tower",
"tower-http",
"tower-sessions",
@@ -3104,7 +3307,7 @@ dependencies = [
[[package]]
name = "rustical_caldav"
version = "0.11.3"
version = "0.11.6"
dependencies = [
"async-std",
"async-trait",
@@ -3146,7 +3349,7 @@ dependencies = [
[[package]]
name = "rustical_carddav"
version = "0.11.3"
version = "0.11.6"
dependencies = [
"async-trait",
"axum",
@@ -3179,7 +3382,7 @@ dependencies = [
[[package]]
name = "rustical_dav"
version = "0.11.3"
version = "0.11.6"
dependencies = [
"async-trait",
"axum",
@@ -3188,6 +3391,7 @@ dependencies = [
"futures-util",
"headers",
"http",
"ical",
"itertools 0.14.0",
"log",
"matchit 0.9.0",
@@ -3204,7 +3408,7 @@ dependencies = [
[[package]]
name = "rustical_dav_push"
version = "0.11.3"
version = "0.11.6"
dependencies = [
"async-trait",
"axum",
@@ -3229,7 +3433,7 @@ dependencies = [
[[package]]
name = "rustical_frontend"
version = "0.11.3"
version = "0.11.6"
dependencies = [
"askama",
"askama_web",
@@ -3265,7 +3469,7 @@ dependencies = [
[[package]]
name = "rustical_ical"
version = "0.11.3"
version = "0.11.6"
dependencies = [
"axum",
"chrono",
@@ -3282,7 +3486,7 @@ dependencies = [
[[package]]
name = "rustical_oidc"
version = "0.11.3"
version = "0.11.6"
dependencies = [
"async-trait",
"axum",
@@ -3298,7 +3502,7 @@ dependencies = [
[[package]]
name = "rustical_store"
version = "0.11.3"
version = "0.11.6"
dependencies = [
"anyhow",
"async-trait",
@@ -3331,11 +3535,13 @@ dependencies = [
[[package]]
name = "rustical_store_sqlite"
version = "0.11.3"
version = "0.11.6"
dependencies = [
"async-trait",
"chrono",
"criterion",
"derive_more",
"ical",
"password-auth",
"password-hash",
"pbkdf2",
@@ -3353,7 +3559,7 @@ dependencies = [
[[package]]
name = "rustical_xml"
version = "0.11.3"
version = "0.11.6"
dependencies = [
"quick-xml",
"thiserror 2.0.17",
@@ -3389,9 +3595,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.13.1"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c"
checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
dependencies = [
"web-time",
"zeroize",
@@ -3416,9 +3622,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.20"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea"
[[package]]
name = "same-file"
@@ -3521,9 +3727,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.145"
version = "1.0.146"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
checksum = "217ca874ae0207aac254aa02c957ded05585a90892cc8d87f9e5fa49669dadd8"
dependencies = [
"itoa",
"memchr",
@@ -3563,9 +3769,9 @@ dependencies = [
[[package]]
name = "serde_spanned"
version = "1.0.3"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392"
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
dependencies = [
"serde_core",
]
@@ -4024,6 +4230,19 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "tempfile"
version = "3.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -4114,6 +4333,16 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "tinyvec"
version = "1.10.0"
@@ -4206,14 +4435,14 @@ dependencies = [
[[package]]
name = "toml"
version = "0.9.8"
version = "0.9.10+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8"
checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48"
dependencies = [
"indexmap 2.12.1",
"serde_core",
"serde_spanned 1.0.3",
"toml_datetime 0.7.3",
"serde_spanned 1.0.4",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow",
@@ -4230,9 +4459,9 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "0.7.3"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
@@ -4253,21 +4482,21 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.23.9"
version = "0.23.10+spec-1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d7cbc3b4b49633d57a0509303158ca50de80ae32c265093b24c414705807832"
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
dependencies = [
"indexmap 2.12.1",
"toml_datetime 0.7.3",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.0.4"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e"
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
dependencies = [
"winnow",
]
@@ -4280,9 +4509,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.0.4"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
[[package]]
name = "tonic"
@@ -4449,9 +4678,9 @@ dependencies = [
[[package]]
name = "tracing"
version = "0.1.43"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
@@ -4472,9 +4701,9 @@ dependencies = [
[[package]]
name = "tracing-core"
version = "0.1.35"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
@@ -4808,6 +5037,22 @@ dependencies = [
"wasite",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -4817,6 +5062,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -5130,7 +5381,7 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
name = "xml_derive"
version = "0.11.3"
version = "0.11.6"
dependencies = [
"darling 0.23.0",
"heck",

View File

@@ -2,7 +2,7 @@
members = ["crates/*"]
[workspace.package]
version = "0.11.3"
version = "0.11.6"
rust-version = "1.91"
edition = "2024"
description = "A CalDAV server"
@@ -32,12 +32,11 @@ opentelemetry = [
"dep:tracing-opentelemetry",
]
[profile.dev]
debug = 0
[workspace.dependencies]
rustical_dav = { path = "./crates/dav/" }
rustical_dav = { path = "./crates/dav/", features = ["ical"] }
rustical_dav_push = { path = "./crates/dav_push/" }
rustical_store = { path = "./crates/store/" }
rustical_store_sqlite = { path = "./crates/store_sqlite/" }
@@ -108,11 +107,12 @@ strum = "0.27"
strum_macros = "0.27"
serde_json = { version = "1.0", features = ["raw_value"] }
sqlx-sqlite = { version = "0.8", features = ["bundled"] }
ical = { git = "https://github.com/lennart-k/ical-rs", features = [
"generator",
"serde",
ical = { path = "../ical-rs/", features = [
"chrono-tz",
] }
# ical = { git = "https://github.com/lennart-k/ical-rs", features = [
# "chrono-tz",
# ] }
toml = "0.9"
tower = "0.5"
tower-http = { version = "0.6", features = [
@@ -150,6 +150,7 @@ openssl = { version = "0.10", features = ["vendored"] }
async-std = { version = "1.13", features = ["attributes"] }
similar-asserts = "1.7"
insta = { version = "1.44", features = ["filters"] }
criterion = { version = "0.8", features = ["async_tokio"] }
[dev-dependencies]
rstest.workspace = true

View File

@@ -29,7 +29,7 @@ base64.workspace = true
serde.workspace = true
tokio.workspace = true
url.workspace = true
rustical_dav.workspace = true
rustical_dav = { workspace = true, features = ["ical"] }
rustical_store.workspace = true
chrono.workspace = true
chrono-tz.workspace = true

View File

@@ -5,10 +5,11 @@ use axum::extract::State;
use axum::{extract::Path, response::Response};
use headers::{ContentType, HeaderMapExt};
use http::{HeaderValue, Method, StatusCode, header};
use ical::generator::{Emitter, IcalCalendarBuilder};
use ical::builder::calendar::IcalCalendarBuilder;
use ical::component::CalendarInnerData;
use ical::generator::Emitter;
use ical::property::Property;
use percent_encoding::{CONTROLS, utf8_percent_encode};
use rustical_ical::{CalendarObjectComponent, EventObject};
use rustical_store::{CalendarStore, SubscriptionStore, auth::Principal};
use std::collections::HashMap;
use std::str::FromStr;
@@ -35,59 +36,53 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
let mut vtimezones = HashMap::new();
let objects = cal_store.get_objects(&principal, &calendar_id).await?;
let mut ical_calendar_builder = IcalCalendarBuilder::version("4.0")
let mut ical_calendar_builder = IcalCalendarBuilder::version("2.0")
.gregorian()
.prodid("RustiCal");
if let Some(displayname) = calendar.meta.displayname {
ical_calendar_builder = ical_calendar_builder.set(Property {
name: "X-WR-CALNAME".to_owned(),
value: Some(displayname),
params: None,
params: vec![],
});
}
if let Some(description) = calendar.meta.description {
ical_calendar_builder = ical_calendar_builder.set(Property {
name: "X-WR-CALDESC".to_owned(),
value: Some(description),
params: None,
params: vec![],
});
}
if let Some(timezone_id) = calendar.timezone_id {
ical_calendar_builder = ical_calendar_builder.set(Property {
name: "X-WR-TIMEZONE".to_owned(),
value: Some(timezone_id),
params: None,
params: vec![],
});
}
for object in &objects {
vtimezones.extend(object.get_vtimezones());
match object.get_data() {
CalendarObjectComponent::Event(EventObject { event, .. }, overrides) => {
ical_calendar_builder = ical_calendar_builder.add_event(event.clone());
for ev_override in overrides {
ical_calendar_builder =
ical_calendar_builder.add_event(ev_override.event.clone());
}
for (_object_id, object) in &objects {
vtimezones.extend(object.get_inner().get_vtimezones());
match object.get_inner().get_inner() {
CalendarInnerData::Event(main, overrides) => {
ical_calendar_builder = ical_calendar_builder
.add_event(main.clone())
.add_events(overrides.iter().cloned());
}
CalendarObjectComponent::Todo(todo, overrides) => {
ical_calendar_builder = ical_calendar_builder.add_todo(todo.clone());
for ev_override in overrides {
ical_calendar_builder = ical_calendar_builder.add_todo(ev_override.clone());
}
CalendarInnerData::Todo(main, overrides) => {
ical_calendar_builder = ical_calendar_builder
.add_todo(main.clone())
.add_todos(overrides.iter().cloned());
}
CalendarObjectComponent::Journal(journal, overrides) => {
ical_calendar_builder = ical_calendar_builder.add_journal(journal.clone());
for ev_override in overrides {
ical_calendar_builder = ical_calendar_builder.add_journal(ev_override.clone());
}
CalendarInnerData::Journal(main, overrides) => {
ical_calendar_builder = ical_calendar_builder
.add_journal(main.clone())
.add_journals(overrides.iter().cloned());
}
}
}
for vtimezone in vtimezones.into_values() {
ical_calendar_builder = ical_calendar_builder.add_tz(vtimezone.to_owned());
}
ical_calendar_builder = ical_calendar_builder.add_timezones(vtimezones.into_values().cloned());
let ical_calendar = ical_calendar_builder
.build()
@@ -95,7 +90,7 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
let mut resp = Response::builder().status(StatusCode::OK);
let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ContentType::from_str("text/calendar").unwrap());
hdrs.typed_insert(ContentType::from_str("text/calendar; charset=utf-8").unwrap());
let filename = format!("{}_{}.ics", calendar.principal, calendar.id);
let filename = utf8_percent_encode(&filename, CONTROLS);

View File

@@ -82,7 +82,10 @@ pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
let objects = expanded_cals
.into_iter()
.map(|cal| cal.generate())
.map(|ics| CalendarObject::from_ics(ics, None))
.map(|ics| {
CalendarObject::from_ics(ics)
.map(|object| (object.get_inner().get_uid().to_owned(), object))
})
.collect::<Result<Vec<_>, _>>()?;
let new_cal = Calendar {
principal,

View File

@@ -21,7 +21,7 @@ pub async fn get_objects_calendar_multiget<C: CalendarStore>(
principal: &str,
cal_id: &str,
store: &C,
) -> Result<(Vec<CalendarObject>, Vec<String>), Error> {
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>), Error> {
let mut result = vec![];
let mut not_found = vec![];
@@ -32,7 +32,7 @@ pub async fn get_objects_calendar_multiget<C: CalendarStore>(
let filename = filename.trim_start_matches('/');
if let Some(object_id) = filename.strip_suffix(".ics") {
match store.get_object(principal, cal_id, object_id, false).await {
Ok(object) => result.push(object),
Ok(object) => result.push((object_id.to_owned(), object)),
Err(rustical_store::Error::NotFound) => not_found.push(href.to_string()),
Err(err) => return Err(err.into()),
}

View File

@@ -2,8 +2,8 @@ use crate::calendar::methods::report::calendar_query::{
TimeRangeElement,
prop_filter::{PropFilterElement, PropFilterable},
};
use ical::parser::ical::component::IcalTimeZone;
use rustical_ical::{CalendarObject, CalendarObjectComponent, CalendarObjectType};
use ical::{component::IcalCalendarObject, parser::ical::component::IcalTimeZone};
use rustical_ical::{CalendarObject, CalendarObjectType};
use rustical_xml::XmlDeserialize;
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
@@ -80,10 +80,11 @@ impl CompFilterable for CalendarObject {
fn match_subcomponents(&self, comp_filter: &CompFilterElement) -> bool {
let mut matches = self
.get_inner()
.get_vtimezones()
.values()
.map(|tz| tz.matches(comp_filter))
.chain([self.get_data().matches(comp_filter)]);
.chain([self.matches(comp_filter)]);
if comp_filter.is_not_defined.is_some() {
matches.all(|x| x)
@@ -107,7 +108,7 @@ impl CompFilterable for IcalTimeZone {
}
}
impl CompFilterable for CalendarObjectComponent {
impl CompFilterable for IcalCalendarObject {
fn get_comp_name(&self) -> &'static str {
CalendarObjectType::from(self).as_str()
}
@@ -120,7 +121,7 @@ impl CompFilterable for CalendarObjectComponent {
return false;
}
if let Some(end) = &time_range.end
&& let Some(first_occurence) = self.get_first_occurence().unwrap_or(None)
&& let Some(first_occurence) = self.get_dtstart().unwrap_or(None)
&& **end < first_occurence.utc()
{
return false;
@@ -137,13 +138,11 @@ impl CompFilterable for CalendarObjectComponent {
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Utc};
use rustical_dav::xml::{NegateCondition, TextCollation, TextMatchElement};
use rustical_ical::{CalendarObject, UtcDateTime};
use crate::calendar::methods::report::calendar_query::{
CompFilterable, TextMatchElement, TimeRangeElement,
comp_filter::CompFilterElement,
prop_filter::PropFilterElement,
text_match::{NegateCondition, TextCollation},
CompFilterElement, CompFilterable, PropFilterElement, TimeRangeElement,
};
const ICS: &str = r"BEGIN:VCALENDAR
@@ -168,7 +167,7 @@ END:VCALENDAR";
#[test]
fn test_comp_filter_matching() {
let object = CalendarObject::from_ics(ICS.to_string(), None).unwrap();
let object = CalendarObject::from_ics(ICS.to_string()).unwrap();
let comp_filter = CompFilterElement {
is_not_defined: Some(()),
@@ -258,7 +257,7 @@ END:VCALENDAR";
}
#[test]
fn test_comp_filter_time_range() {
let object = CalendarObject::from_ics(ICS.to_string(), None).unwrap();
let object = CalendarObject::from_ics(ICS.to_string()).unwrap();
let comp_filter = CompFilterElement {
is_not_defined: None,
@@ -313,7 +312,7 @@ END:VCALENDAR";
#[test]
fn test_match_timezone() {
let object = CalendarObject::from_ics(ICS.to_string(), None).unwrap();
let object = CalendarObject::from_ics(ICS.to_string()).unwrap();
let comp_filter = CompFilterElement {
is_not_defined: None,

View File

@@ -1,11 +1,6 @@
use crate::{
calendar::methods::report::calendar_query::{
TextMatchElement,
comp_filter::{CompFilterElement, CompFilterable},
},
calendar_object::CalendarObjectPropWrapperName,
};
use rustical_dav::xml::PropfindType;
use super::comp_filter::{CompFilterElement, CompFilterable};
use crate::calendar_object::CalendarObjectPropWrapperName;
use rustical_dav::xml::{PropfindType, TextMatchElement};
use rustical_ical::{CalendarObject, UtcDateTime};
use rustical_store::calendar_store::CalendarQuery;
use rustical_xml::XmlDeserialize;

View File

@@ -5,47 +5,39 @@ use rustical_store::CalendarStore;
mod comp_filter;
mod elements;
mod prop_filter;
pub mod text_match;
#[allow(unused_imports)]
pub use comp_filter::{CompFilterElement, CompFilterable};
pub use elements::*;
#[allow(unused_imports)]
pub use prop_filter::{PropFilterElement, PropFilterable};
#[allow(unused_imports)]
pub use text_match::TextMatchElement;
pub async fn get_objects_calendar_query<C: CalendarStore>(
cal_query: &CalendarQueryRequest,
principal: &str,
cal_id: &str,
store: &C,
) -> Result<Vec<CalendarObject>, Error> {
) -> Result<Vec<(String, CalendarObject)>, Error> {
let mut objects = store
.calendar_query(principal, cal_id, cal_query.into())
.await?;
if let Some(filter) = &cal_query.filter {
objects.retain(|object| filter.matches(object));
objects.retain(|(_, object)| filter.matches(object));
}
Ok(objects)
}
#[cfg(test)]
mod tests {
use rustical_dav::xml::PropElement;
use rustical_xml::XmlDocument;
use super::{
CalendarQueryRequest, FilterElement, ParamFilterElement, comp_filter::CompFilterElement,
prop_filter::PropFilterElement,
};
use crate::{
calendar::methods::report::{
ReportRequest,
calendar_query::{
CalendarQueryRequest, FilterElement, ParamFilterElement, TextMatchElement,
comp_filter::CompFilterElement,
prop_filter::PropFilterElement,
text_match::{NegateCondition, TextCollation},
},
},
calendar::methods::report::ReportRequest,
calendar_object::{CalendarData, CalendarObjectPropName, CalendarObjectPropWrapperName},
};
use rustical_dav::xml::{NegateCondition, PropElement, TextCollation, TextMatchElement};
use rustical_xml::XmlDocument;
#[test]
fn calendar_query_7_8_7() {

View File

@@ -1,19 +1,18 @@
use std::collections::HashMap;
use super::{ParamFilterElement, TimeRangeElement};
use ical::{
component::{CalendarInnerData, IcalCalendarObject},
generator::{IcalCalendar, IcalEvent},
parser::{
Component,
ical::component::{IcalJournal, IcalTimeZone, IcalTodo},
},
property::Property,
types::CalDateTime,
};
use rustical_ical::{CalDateTime, CalendarObject, CalendarObjectComponent, UtcDateTime};
use rustical_dav::xml::TextMatchElement;
use rustical_ical::{CalendarObject, UtcDateTime};
use rustical_xml::XmlDeserialize;
use crate::calendar::methods::report::calendar_query::{
ParamFilterElement, TextMatchElement, TimeRangeElement,
};
use std::collections::HashMap;
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]
@@ -82,7 +81,7 @@ pub trait PropFilterable {
impl PropFilterable for CalendarObject {
fn get_property(&self, name: &str) -> Option<&Property> {
Self::get_property(self, name)
self.get_property(name)
}
}
@@ -116,12 +115,12 @@ impl PropFilterable for IcalTimeZone {
}
}
impl PropFilterable for CalendarObjectComponent {
impl PropFilterable for IcalCalendarObject {
fn get_property(&self, name: &str) -> Option<&Property> {
match self {
Self::Event(event, _) => PropFilterable::get_property(&event.event, name),
Self::Todo(todo, _) => PropFilterable::get_property(todo, name),
Self::Journal(journal, _) => PropFilterable::get_property(journal, name),
match self.get_inner() {
CalendarInnerData::Event(event, _) => PropFilterable::get_property(event, name),
CalendarInnerData::Todo(todo, _) => PropFilterable::get_property(todo, name),
CalendarInnerData::Journal(journal, _) => PropFilterable::get_property(journal, name),
}
}
}

View File

@@ -51,7 +51,7 @@ impl ReportRequest {
}
fn objects_response(
objects: Vec<CalendarObject>,
objects: Vec<(String, CalendarObject)>,
not_found: Vec<String>,
path: &str,
principal: &str,
@@ -60,11 +60,12 @@ fn objects_response(
prop: &PropfindType<CalendarObjectPropWrapperName>,
) -> Result<MultistatusElement<CalendarObjectPropWrapper, String>, Error> {
let mut responses = Vec::new();
for object in objects {
let path = format!("{}/{}.ics", path, object.get_id());
for (object_id, object) in objects {
let path = format!("{}/{}.ics", path, &object_id);
responses.push(
CalendarObjectResource {
object,
object_id,
principal: principal.to_owned(),
}
.propfind(&path, prop, None, puri, user)?,

View File

@@ -32,11 +32,12 @@ pub async fn handle_sync_collection<C: CalendarStore>(
.await?;
let mut responses = Vec::new();
for object in new_objects {
let path = format!("{}/{}.ics", path, object.get_id());
for (object_id, object) in new_objects {
let path = format!("{}/{}.ics", path, &object_id);
responses.push(
CalendarObjectResource {
object,
object_id,
principal: principal.to_owned(),
}
.propfind(&path, &sync_collection.prop, None, puri, user)?,

View File

@@ -1,10 +1,9 @@
use derive_more::derive::{From, Into};
use rustical_dav::xml::TextCollation;
use rustical_ical::CalendarObjectType;
use rustical_xml::{XmlDeserialize, XmlSerialize};
use strum_macros::VariantArray;
use crate::calendar::methods::report::calendar_query::text_match::TextCollation;
#[derive(Debug, Clone, XmlSerialize, XmlDeserialize, PartialEq, Eq, From, Into)]
pub struct SupportedCalendarComponent {
#[xml(ty = "attr")]

View File

@@ -4,6 +4,7 @@ use crate::calendar::prop::{ReportMethod, SupportedCollationSet};
use chrono::{DateTime, Utc};
use derive_more::derive::{From, Into};
use ical::IcalParser;
use ical::types::CalDateTime;
use rustical_dav::extensions::{
CommonPropertiesExtension, CommonPropertiesProp, SyncTokenExtension, SyncTokenExtensionProp,
};
@@ -11,7 +12,6 @@ use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner, SupportedReportSet};
use rustical_dav_push::{DavPushExtension, DavPushExtensionProp};
use rustical_ical::CalDateTime;
use rustical_store::Calendar;
use rustical_store::auth::Principal;
use rustical_xml::{EnumVariants, PropName};

View File

@@ -78,9 +78,10 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
.get_objects(principal, cal_id)
.await?
.into_iter()
.map(|object| CalendarObjectResource {
.map(|(object_id, object)| CalendarObjectResource {
object,
principal: principal.to_owned(),
object_id,
})
.collect())
}

View File

@@ -42,7 +42,7 @@ pub async fn get_event<C: CalendarStore>(
let mut resp = Response::builder().status(StatusCode::OK);
let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ETag::from_str(&event.get_etag()).unwrap());
hdrs.typed_insert(ContentType::from_str("text/calendar").unwrap());
hdrs.typed_insert(ContentType::from_str("text/calendar; charset=utf-8").unwrap());
if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
@@ -78,12 +78,12 @@ pub async fn put_event<C: CalendarStore>(
true
};
let Ok(object) = CalendarObject::from_ics(body.clone(), Some(object_id)) else {
let Ok(object) = CalendarObject::from_ics(body.clone()) else {
debug!("invalid calendar data:\n{body}");
return Err(Error::PreconditionFailed(Precondition::ValidCalendarData));
};
cal_store
.put_object(principal, calendar_id, object, overwrite)
.put_object(principal, calendar_id, (object_id, object), overwrite)
.await?;
Ok(StatusCode::CREATED.into_response())

View File

@@ -4,6 +4,7 @@ use super::prop::{
};
use crate::Error;
use derive_more::derive::{From, Into};
use ical::generator::Emitter;
use rustical_dav::{
extensions::CommonPropertiesExtension,
privileges::UserPrivilegeSet,
@@ -16,12 +17,13 @@ use rustical_store::auth::Principal;
#[derive(Clone, From, Into)]
pub struct CalendarObjectResource {
pub object: CalendarObject,
pub object_id: String,
pub principal: String,
}
impl ResourceName for CalendarObjectResource {
fn get_name(&self) -> String {
format!("{}.ics", self.object.get_id())
format!("{}.ics", self.object_id)
}
}
@@ -52,10 +54,14 @@ impl Resource for CalendarObjectResource {
}
CalendarObjectPropName::CalendarData(CalendarData { expand, .. }) => {
CalendarObjectProp::CalendarData(if let Some(expand) = expand.as_ref() {
self.object.expand_recurrence(
Some(expand.start.to_utc()),
Some(expand.end.to_utc()),
)?
self.object
.get_inner()
.expand_recurrence(
Some(expand.start.to_utc()),
Some(expand.end.to_utc()),
)
.map_err(rustical_ical::Error::ParserError)?
.generate()
} else {
self.object.get_ics().to_owned()
})

View File

@@ -67,6 +67,7 @@ impl<C: CalendarStore> ResourceService for CalendarObjectResourceService<C> {
Ok(CalendarObjectResource {
object,
principal: principal.to_owned(),
object_id: object_id.to_owned(),
})
}

View File

@@ -22,7 +22,7 @@ base64.workspace = true
serde.workspace = true
tokio.workspace = true
url.workspace = true
rustical_dav.workspace = true
rustical_dav = { workspace = true, features = ["ical"] }
rustical_store.workspace = true
chrono.workspace = true
rustical_xml.workspace = true

View File

@@ -50,7 +50,7 @@ pub async fn get_object<AS: AddressbookStore>(
let mut resp = Response::builder().status(StatusCode::OK);
let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ETag::from_str(&object.get_etag()).unwrap());
hdrs.typed_insert(ContentType::from_str("text/vcard").unwrap());
hdrs.typed_insert(ContentType::from_str("text/vcard; charset=utf-8").unwrap());
if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {

View File

@@ -46,7 +46,7 @@ pub async fn route_get<AS: AddressbookStore, S: SubscriptionStore>(
let mut resp = Response::builder().status(StatusCode::OK);
let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ContentType::from_str("text/vcard").unwrap());
hdrs.typed_insert(ContentType::from_str("text/vcard; charset=utf-8").unwrap());
let filename = format!("{principal}_{addressbook_id}.vcf");
let filename = utf8_percent_encode(&filename, CONTROLS);
hdrs.insert(

View File

@@ -36,7 +36,7 @@ pub async fn route_import<AS: AddressbookStore, S: SubscriptionStore>(
card_mut.set_property(Property {
name: "UID".to_owned(),
value: Some(uuid::Uuid::new_v4().to_string()),
params: None,
params: vec![],
});
card = card_mut.verify().unwrap();
}

View File

@@ -0,0 +1,77 @@
use crate::{
address_object::AddressObjectPropWrapperName,
addressbook::methods::report::addressbook_query::PropFilterElement,
};
use rustical_dav::xml::{PropfindType, TextMatchElement};
use rustical_ical::{AddressObject, UtcDateTime};
use rustical_xml::XmlDeserialize;
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]
pub struct TimeRangeElement {
#[xml(ty = "attr")]
pub(crate) start: Option<UtcDateTime>,
#[xml(ty = "attr")]
pub(crate) end: Option<UtcDateTime>,
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]
// https://www.rfc-editor.org/rfc/rfc4791#section-9.7.3
pub struct ParamFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
pub(crate) is_not_defined: Option<()>,
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
pub(crate) text_match: Option<TextMatchElement>,
#[xml(ty = "attr")]
pub(crate) name: String,
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]
// <!ELEMENT filter (prop-filter*)>
// <!ATTLIST filter test (anyof | allof) "anyof">
// <!-- test value:
// anyof logical OR for prop-filter matches
// allof logical AND for prop-filter matches -->
pub struct FilterElement {
#[xml(ty = "attr")]
pub anyof: Option<String>,
#[xml(ty = "attr")]
pub allof: Option<String>,
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV", flatten)]
pub(crate) prop_filter: Vec<PropFilterElement>,
}
impl FilterElement {
#[must_use]
pub fn matches(&self, addr_object: &AddressObject) -> bool {
let allof = match (self.allof.is_some(), self.anyof.is_some()) {
(true, false) => true,
(false, _) => false,
(true, true) => panic!("wat"),
};
let mut results = self
.prop_filter
.iter()
.map(|prop_filter| prop_filter.match_component(addr_object));
if allof {
results.all(|x| x)
} else {
results.any(|x| x)
}
}
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]
// <!ELEMENT addressbook-query ((DAV:allprop |
// DAV:propname |
// DAV:prop)?, filter, limit?)>
pub struct AddressbookQueryRequest {
#[xml(ty = "untagged")]
pub prop: PropfindType<AddressObjectPropWrapperName>,
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
pub(crate) filter: FilterElement,
}

View File

@@ -0,0 +1,19 @@
use crate::Error;
mod elements;
mod prop_filter;
pub use elements::*;
#[allow(unused_imports)]
pub use prop_filter::{PropFilterElement, PropFilterable};
use rustical_ical::AddressObject;
use rustical_store::AddressbookStore;
pub async fn get_objects_addressbook_query<AS: AddressbookStore>(
addr_query: &AddressbookQueryRequest,
principal: &str,
addressbook_id: &str,
store: &AS,
) -> Result<Vec<AddressObject>, Error> {
let mut objects = store.get_objects(principal, addressbook_id).await?;
objects.retain(|object| addr_query.filter.matches(object));
Ok(objects)
}

View File

@@ -0,0 +1,75 @@
use super::ParamFilterElement;
use ical::{parser::Component, property::Property};
use rustical_dav::xml::TextMatchElement;
use rustical_ical::AddressObject;
use rustical_xml::XmlDeserialize;
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]
// <!ELEMENT prop-filter (is-not-defined |
// (text-match*, param-filter*))>
//
// <!ATTLIST prop-filter name CDATA #REQUIRED
// test (anyof | allof) "anyof">
// <!-- name value: a vCard property name (e.g., "NICKNAME")
// test value:
// anyof logical OR for text-match/param-filter matches
// allof logical AND for text-match/param-filter matches -->
pub struct PropFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
pub(crate) is_not_defined: Option<()>,
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV", flatten)]
pub(crate) text_match: Vec<TextMatchElement>,
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV", flatten)]
pub(crate) param_filter: Vec<ParamFilterElement>,
#[xml(ty = "attr")]
pub(crate) name: String,
#[xml(ty = "attr")]
pub anyof: Option<String>,
#[xml(ty = "attr")]
pub allof: Option<String>,
}
impl PropFilterElement {
pub fn match_component(&self, comp: &impl PropFilterable) -> bool {
let property = comp.get_property(&self.name);
let _property = match (self.is_not_defined.is_some(), property) {
// We are the component that's not supposed to be defined
(true, Some(_))
// We don't match
| (false, None) => return false,
// We shall not be and indeed we aren't
(true, None) => return true,
(false, Some(property)) => property
};
let _allof = match (self.allof.is_some(), self.anyof.is_some()) {
(true, false) => true,
(false, _) => false,
(true, true) => panic!("wat"),
};
// TODO: IMPLEMENT
// if let Some(text_match) = &self.text_match
// && !text_match.match_property(property)
// {
// return false;
// }
// TODO: param-filter
true
}
}
pub trait PropFilterable {
fn get_property(&self, name: &str) -> Option<&Property>;
}
impl PropFilterable for AddressObject {
fn get_property(&self, name: &str) -> Option<&Property> {
self.get_vcard().get_property(name)
}
}

View File

@@ -1,6 +1,14 @@
use crate::{
CardDavPrincipalUri, Error, address_object::AddressObjectPropWrapperName,
addressbook::AddressbookResourceService,
CardDavPrincipalUri, Error,
address_object::{
AddressObjectPropWrapper, AddressObjectPropWrapperName, resource::AddressObjectResource,
},
addressbook::{
AddressbookResourceService,
methods::report::addressbook_query::{
AddressbookQueryRequest, get_objects_addressbook_query,
},
},
};
use addressbook_multiget::{AddressbookMultigetRequest, handle_addressbook_multiget};
use axum::{
@@ -8,19 +16,30 @@ use axum::{
extract::{OriginalUri, Path, State},
response::IntoResponse,
};
use rustical_dav::xml::{PropfindType, sync_collection::SyncCollectionRequest};
use http::StatusCode;
use rustical_dav::{
resource::{PrincipalUri, Resource},
xml::{
MultistatusElement, PropfindType, multistatus::ResponseElement,
sync_collection::SyncCollectionRequest,
},
};
use rustical_ical::AddressObject;
use rustical_store::{AddressbookStore, SubscriptionStore, auth::Principal};
use rustical_xml::{XmlDeserialize, XmlDocument};
use sync_collection::handle_sync_collection;
use tracing::instrument;
mod addressbook_multiget;
mod addressbook_query;
mod sync_collection;
#[derive(XmlDeserialize, XmlDocument, Clone, Debug, PartialEq)]
pub(crate) enum ReportRequest {
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
AddressbookMultiget(AddressbookMultigetRequest),
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
AddressbookQuery(AddressbookQueryRequest),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
SyncCollection(SyncCollectionRequest<AddressObjectPropWrapperName>),
}
@@ -29,11 +48,49 @@ impl ReportRequest {
const fn props(&self) -> &PropfindType<AddressObjectPropWrapperName> {
match self {
Self::AddressbookMultiget(AddressbookMultigetRequest { prop, .. })
| Self::SyncCollection(SyncCollectionRequest { prop, .. }) => prop,
| Self::SyncCollection(SyncCollectionRequest { prop, .. })
| Self::AddressbookQuery(AddressbookQueryRequest { prop, .. }) => prop,
}
}
}
fn objects_response(
objects: Vec<AddressObject>,
not_found: Vec<String>,
path: &str,
principal: &str,
puri: &impl PrincipalUri,
user: &Principal,
prop: &PropfindType<AddressObjectPropWrapperName>,
) -> Result<MultistatusElement<AddressObjectPropWrapper, String>, Error> {
let mut responses = Vec::new();
for object in objects {
let path = format!("{}/{}.vcf", path, object.get_id());
responses.push(
AddressObjectResource {
object,
principal: principal.to_owned(),
}
.propfind(&path, prop, None, puri, user)?,
);
}
let not_found_responses = not_found
.into_iter()
.map(|path| ResponseElement {
href: path,
status: Some(StatusCode::NOT_FOUND),
..Default::default()
})
.collect();
Ok(MultistatusElement {
responses,
member_responses: not_found_responses,
..Default::default()
})
}
#[instrument(skip(addr_store))]
pub async fn route_report_addressbook<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addressbook_id)): Path<(String, String)>,
@@ -75,13 +132,34 @@ pub async fn route_report_addressbook<AS: AddressbookStore, S: SubscriptionStore
)
.await?
}
ReportRequest::AddressbookQuery(addr_query) => {
let objects = get_objects_addressbook_query(
addr_query,
&principal,
&addressbook_id,
addr_store.as_ref(),
)
.await?;
objects_response(
objects,
vec![],
uri.path(),
&principal,
&puri,
&user,
&addr_query.prop,
)?
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::address_object::AddressObjectPropName;
use crate::{
address_object::AddressObjectPropName,
addressbook::methods::report::addressbook_query::{FilterElement, PropFilterElement},
};
use rustical_dav::xml::{PropElement, sync_collection::SyncLevel};
#[test]
@@ -144,4 +222,46 @@ mod tests {
})
);
}
#[test]
fn test_xml_addressbook_query() {
let report_request = ReportRequest::parse_str(
r#"
<?xml version="1.0" encoding="utf-8"?>
<card:addressbook-query xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:d="DAV:">
<d:prop>
<d:getetag/>
</d:prop>
<card:filter>
<card:prop-filter name="FN"/>
</card:filter>
</card:addressbook-query>
"#,
)
.unwrap();
assert_eq!(
report_request,
ReportRequest::AddressbookQuery(AddressbookQueryRequest {
prop: rustical_dav::xml::PropfindType::Prop(PropElement(
vec![AddressObjectPropWrapperName::AddressObject(
AddressObjectPropName::Getetag
),],
vec![]
)),
filter: FilterElement {
anyof: None,
allof: None,
prop_filter: vec![PropFilterElement {
name: "FN".to_owned(),
is_not_defined: None,
text_match: vec![],
param_filter: vec![],
allof: None,
anyof: None
}]
}
})
);
}
}

View File

@@ -28,3 +28,7 @@ headers.workspace = true
strum.workspace = true
matchit.workspace = true
matchit-serde.workspace = true
ical = { workspace = true, optional = true }
[features]
ical = ["dep:ical"]

View File

@@ -15,3 +15,7 @@ mod report_set;
pub use report_set::SupportedReportSet;
mod group;
pub use group::*;
#[cfg(feature = "ical")]
mod text_match;
#[cfg(feature = "ical")]
pub use text_match::*;

View File

@@ -64,9 +64,9 @@ pub struct TextMatchElement {
#[xml(ty = "attr", default = "Default::default")]
pub collation: TextCollation,
#[xml(ty = "attr", default = "Default::default")]
pub(crate) negate_condition: NegateCondition,
pub negate_condition: NegateCondition,
#[xml(ty = "text")]
pub(crate) needle: String,
pub needle: String,
}
impl TextMatchElement {
@@ -90,7 +90,7 @@ impl TextMatchElement {
#[cfg(test)]
mod tests {
use crate::calendar::methods::report::calendar_query::text_match::TextCollation;
use super::TextCollation;
#[test]
fn test_collation() {

View File

@@ -11,8 +11,8 @@
]
},
"imports": {
"@deno/vite-plugin": "npm:@deno/vite-plugin@^1.0.5",
"@deno/vite-plugin": "npm:@deno/vite-plugin@^1.0.6",
"lit": "npm:lit@^3.3.1",
"vite": "npm:vite@^7.2.7"
"vite": "npm:vite@^7.3.0"
}
}

View File

@@ -1,145 +1,145 @@
{
"version": "5",
"specifiers": {
"npm:@deno/vite-plugin@^1.0.5": "1.0.5_vite@7.2.7__picomatch@4.0.3",
"npm:@deno/vite-plugin@^1.0.6": "1.0.6_vite@7.3.0__picomatch@4.0.3",
"npm:lit@^3.3.1": "3.3.1",
"npm:vite@*": "7.2.7_picomatch@4.0.3",
"npm:vite@^7.2.7": "7.2.7_picomatch@4.0.3"
"npm:vite@*": "7.3.0_picomatch@4.0.3",
"npm:vite@^7.3.0": "7.3.0_picomatch@4.0.3"
},
"npm": {
"@deno/vite-plugin@1.0.5_vite@7.2.7__picomatch@4.0.3": {
"integrity": "sha512-tLja5n4dyMhcze1NzvSs2iiriBymfBlDCZIrjMTxb9O2ru0gvmV6mn5oBD2teNw5Sd92cj3YJzKwsAs8tMJXlg==",
"@deno/vite-plugin@1.0.6_vite@7.3.0__picomatch@4.0.3": {
"integrity": "sha512-Sh5XqvFuKAwjARTesi0n6xRpEXm1V0UeqKh+SxIrexCofxOaieNDMqXZD02RiZCg0mrJ43V8eCMuVrDfq6mLmg==",
"dependencies": [
"vite"
]
},
"@esbuild/aix-ppc64@0.25.12": {
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"@esbuild/aix-ppc64@0.27.1": {
"integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==",
"os": ["aix"],
"cpu": ["ppc64"]
},
"@esbuild/android-arm64@0.25.12": {
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"@esbuild/android-arm64@0.27.1": {
"integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==",
"os": ["android"],
"cpu": ["arm64"]
},
"@esbuild/android-arm@0.25.12": {
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"@esbuild/android-arm@0.27.1": {
"integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==",
"os": ["android"],
"cpu": ["arm"]
},
"@esbuild/android-x64@0.25.12": {
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"@esbuild/android-x64@0.27.1": {
"integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==",
"os": ["android"],
"cpu": ["x64"]
},
"@esbuild/darwin-arm64@0.25.12": {
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"@esbuild/darwin-arm64@0.27.1": {
"integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"@esbuild/darwin-x64@0.25.12": {
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"@esbuild/darwin-x64@0.27.1": {
"integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==",
"os": ["darwin"],
"cpu": ["x64"]
},
"@esbuild/freebsd-arm64@0.25.12": {
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"@esbuild/freebsd-arm64@0.27.1": {
"integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==",
"os": ["freebsd"],
"cpu": ["arm64"]
},
"@esbuild/freebsd-x64@0.25.12": {
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"@esbuild/freebsd-x64@0.27.1": {
"integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==",
"os": ["freebsd"],
"cpu": ["x64"]
},
"@esbuild/linux-arm64@0.25.12": {
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"@esbuild/linux-arm64@0.27.1": {
"integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@esbuild/linux-arm@0.25.12": {
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"@esbuild/linux-arm@0.27.1": {
"integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==",
"os": ["linux"],
"cpu": ["arm"]
},
"@esbuild/linux-ia32@0.25.12": {
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"@esbuild/linux-ia32@0.27.1": {
"integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==",
"os": ["linux"],
"cpu": ["ia32"]
},
"@esbuild/linux-loong64@0.25.12": {
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"@esbuild/linux-loong64@0.27.1": {
"integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==",
"os": ["linux"],
"cpu": ["loong64"]
},
"@esbuild/linux-mips64el@0.25.12": {
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"@esbuild/linux-mips64el@0.27.1": {
"integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==",
"os": ["linux"],
"cpu": ["mips64el"]
},
"@esbuild/linux-ppc64@0.25.12": {
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"@esbuild/linux-ppc64@0.27.1": {
"integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==",
"os": ["linux"],
"cpu": ["ppc64"]
},
"@esbuild/linux-riscv64@0.25.12": {
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"@esbuild/linux-riscv64@0.27.1": {
"integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==",
"os": ["linux"],
"cpu": ["riscv64"]
},
"@esbuild/linux-s390x@0.25.12": {
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"@esbuild/linux-s390x@0.27.1": {
"integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==",
"os": ["linux"],
"cpu": ["s390x"]
},
"@esbuild/linux-x64@0.25.12": {
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"@esbuild/linux-x64@0.27.1": {
"integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==",
"os": ["linux"],
"cpu": ["x64"]
},
"@esbuild/netbsd-arm64@0.25.12": {
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"@esbuild/netbsd-arm64@0.27.1": {
"integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==",
"os": ["netbsd"],
"cpu": ["arm64"]
},
"@esbuild/netbsd-x64@0.25.12": {
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"@esbuild/netbsd-x64@0.27.1": {
"integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==",
"os": ["netbsd"],
"cpu": ["x64"]
},
"@esbuild/openbsd-arm64@0.25.12": {
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"@esbuild/openbsd-arm64@0.27.1": {
"integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==",
"os": ["openbsd"],
"cpu": ["arm64"]
},
"@esbuild/openbsd-x64@0.25.12": {
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"@esbuild/openbsd-x64@0.27.1": {
"integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==",
"os": ["openbsd"],
"cpu": ["x64"]
},
"@esbuild/openharmony-arm64@0.25.12": {
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"@esbuild/openharmony-arm64@0.27.1": {
"integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==",
"os": ["openharmony"],
"cpu": ["arm64"]
},
"@esbuild/sunos-x64@0.25.12": {
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"@esbuild/sunos-x64@0.27.1": {
"integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==",
"os": ["sunos"],
"cpu": ["x64"]
},
"@esbuild/win32-arm64@0.25.12": {
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"@esbuild/win32-arm64@0.27.1": {
"integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==",
"os": ["win32"],
"cpu": ["arm64"]
},
"@esbuild/win32-ia32@0.25.12": {
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"@esbuild/win32-ia32@0.27.1": {
"integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==",
"os": ["win32"],
"cpu": ["ia32"]
},
"@esbuild/win32-x64@0.25.12": {
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"@esbuild/win32-x64@0.27.1": {
"integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==",
"os": ["win32"],
"cpu": ["x64"]
},
@@ -268,8 +268,8 @@
"@types/trusted-types@2.0.7": {
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
},
"esbuild@0.25.12": {
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"esbuild@0.27.1": {
"integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==",
"optionalDependencies": [
"@esbuild/aix-ppc64",
"@esbuild/android-arm",
@@ -397,8 +397,8 @@
"picomatch"
]
},
"vite@7.2.7_picomatch@4.0.3": {
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
"vite@7.3.0_picomatch@4.0.3": {
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dependencies": [
"esbuild",
"fdir",
@@ -415,9 +415,9 @@
},
"workspace": {
"dependencies": [
"npm:@deno/vite-plugin@^1.0.5",
"npm:@deno/vite-plugin@^1.0.6",
"npm:lit@^3.3.1",
"npm:vite@^7.2.7"
"npm:vite@^7.3.0"
]
}
}

View File

@@ -1,4 +1,4 @@
const t$3 = globalThis, e$4 = t$3.ShadowRoot && (void 0 === t$3.ShadyCSS || t$3.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, s$3 = Symbol(), o$6 = /* @__PURE__ */ new WeakMap();
const t$3 = globalThis, e$4 = t$3.ShadowRoot && (void 0 === t$3.ShadyCSS || t$3.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, s$3 = /* @__PURE__ */ Symbol(), o$6 = /* @__PURE__ */ new WeakMap();
let n$5 = class n {
constructor(t2, e2, o2) {
if (this._$cssResult$ = true, o2 !== s$3) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");
@@ -57,7 +57,7 @@ const { is: i$3, defineProperty: e$3, getOwnPropertyDescriptor: h$3, getOwnPrope
}
return i3;
} }, f$3 = (t2, s2) => !i$3(t2, s2), b$1 = { attribute: true, type: String, converter: u$1, reflect: false, useDefault: false, hasChanged: f$3 };
Symbol.metadata ??= Symbol("metadata"), a$1.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();
Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), a$1.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();
let y$1 = class y extends HTMLElement {
static addInitializer(t2) {
this._$Ei(), (this.l ??= []).push(t2);
@@ -67,7 +67,7 @@ let y$1 = class y extends HTMLElement {
}
static createProperty(t2, s2 = b$1) {
if (s2.state && (s2.attribute = false), this._$Ei(), this.prototype.hasOwnProperty(t2) && ((s2 = Object.create(s2)).wrapped = true), this.elementProperties.set(t2, s2), !s2.noAccessor) {
const i3 = Symbol(), h2 = this.getPropertyDescriptor(t2, i3, s2);
const i3 = /* @__PURE__ */ Symbol(), h2 = this.getPropertyDescriptor(t2, i3, s2);
void 0 !== h2 && e$3(this.prototype, t2, h2);
}
}
@@ -241,7 +241,7 @@ let y$1 = class y extends HTMLElement {
};
y$1.elementStyles = [], y$1.shadowRootOptions = { mode: "open" }, y$1[d$1("elementProperties")] = /* @__PURE__ */ new Map(), y$1[d$1("finalized")] = /* @__PURE__ */ new Map(), p$1?.({ ReactiveElement: y$1 }), (a$1.reactiveElementVersions ??= []).push("2.1.1");
const t$2 = globalThis, i$2 = t$2.trustedTypes, s$2 = i$2 ? i$2.createPolicy("lit-html", { createHTML: (t2) => t2 }) : void 0, e$2 = "$lit$", h$2 = `lit$${Math.random().toFixed(9).slice(2)}$`, o$4 = "?" + h$2, n$3 = `<${o$4}>`, r$2 = document, l = () => r$2.createComment(""), c$1 = (t2) => null === t2 || "object" != typeof t2 && "function" != typeof t2, a = Array.isArray, u = (t2) => a(t2) || "function" == typeof t2?.[Symbol.iterator], d = "[ \n\f\r]", f$2 = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, v = /-->/g, _ = />/g, m = RegExp(`>|${d}(?:([^\\s"'>=/]+)(${d}*=${d}*(?:[^
\f\r"'\`<>=]|("|')|))|$)`, "g"), p = /'/g, g = /"/g, $ = /^(?:script|style|textarea|title)$/i, y2 = (t2) => (i3, ...s2) => ({ _$litType$: t2, strings: i3, values: s2 }), x = y2(1), b = y2(2), T = Symbol.for("lit-noChange"), E = Symbol.for("lit-nothing"), A = /* @__PURE__ */ new WeakMap(), C = r$2.createTreeWalker(r$2, 129);
\f\r"'\`<>=]|("|')|))|$)`, "g"), p = /'/g, g = /"/g, $ = /^(?:script|style|textarea|title)$/i, y2 = (t2) => (i3, ...s2) => ({ _$litType$: t2, strings: i3, values: s2 }), x = y2(1), b = y2(2), T = /* @__PURE__ */ Symbol.for("lit-noChange"), E = /* @__PURE__ */ Symbol.for("lit-nothing"), A = /* @__PURE__ */ new WeakMap(), C = r$2.createTreeWalker(r$2, 129);
function P(t2, i3) {
if (!a(t2) || !t2.hasOwnProperty("raw")) throw Error("invalid template strings array");
return void 0 !== s$2 ? s$2.createHTML(i3) : i3;

View File

@@ -1,4 +1,3 @@
use crate::{CalDateTime, LOCAL_DATE};
use crate::{CalendarObject, Error};
use chrono::Datelike;
use ical::generator::Emitter;
@@ -6,8 +5,9 @@ use ical::parser::{
Component,
vcard::{self, component::VcardContact},
};
use ical::types::CalDate;
use sha2::{Digest, Sha256};
use std::{collections::HashMap, io::BufReader};
use std::io::BufReader;
#[derive(Debug, Clone)]
pub struct AddressObject {
@@ -64,15 +64,15 @@ impl AddressObject {
}
#[must_use]
pub fn get_anniversary(&self) -> Option<(CalDateTime, bool)> {
pub fn get_anniversary(&self) -> Option<(CalDate, bool)> {
let prop = self.vcard.get_property("ANNIVERSARY")?.value.as_deref()?;
CalDateTime::parse_vcard(prop).ok()
CalDate::parse_vcard(prop).ok()
}
#[must_use]
pub fn get_birthday(&self) -> Option<(CalDateTime, bool)> {
pub fn get_birthday(&self) -> Option<(CalDate, bool)> {
let prop = self.vcard.get_property("BDAY")?.value.as_deref()?;
CalDateTime::parse_vcard(prop).ok()
CalDate::parse_vcard(prop).ok()
}
#[must_use]
@@ -87,19 +87,14 @@ impl AddressObject {
let Some(fullname) = self.get_full_name() else {
return Ok(None);
};
let anniversary = anniversary.date();
let year = contains_year.then_some(anniversary.year());
let anniversary_start = anniversary.format(LOCAL_DATE);
let anniversary_end = anniversary
.succ_opt()
.unwrap_or(anniversary)
.format(LOCAL_DATE);
let anniversary_start = anniversary.format();
let anniversary_end = anniversary.succ_opt().unwrap_or(anniversary).format();
let uid = format!("{}-anniversary", self.get_id());
let year_suffix = year.map(|year| format!(" ({year})")).unwrap_or_default();
Some(CalendarObject::from_ics(
format!(
r"BEGIN:VCALENDAR
Some(CalendarObject::from_ics(format!(
r"BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//github.com/lennart-k/rustical birthday calendar//EN
@@ -117,9 +112,7 @@ DESCRIPTION:💍 {fullname}{year_suffix}
END:VALARM
END:VEVENT
END:VCALENDAR",
),
None,
)?)
))?)
} else {
None
},
@@ -132,16 +125,14 @@ END:VCALENDAR",
let Some(fullname) = self.get_full_name() else {
return Ok(None);
};
let birthday = birthday.date();
let year = contains_year.then_some(birthday.year());
let birthday_start = birthday.format(LOCAL_DATE);
let birthday_end = birthday.succ_opt().unwrap_or(birthday).format(LOCAL_DATE);
let birthday_start = birthday.format();
let birthday_end = birthday.succ_opt().unwrap_or(birthday).format();
let uid = format!("{}-birthday", self.get_id());
let year_suffix = year.map(|year| format!(" ({year})")).unwrap_or_default();
Some(CalendarObject::from_ics(
format!(
r"BEGIN:VCALENDAR
Some(CalendarObject::from_ics(format!(
r"BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//github.com/lennart-k/rustical birthday calendar//EN
@@ -159,9 +150,7 @@ DESCRIPTION:🎂 {fullname}{year_suffix}
END:VALARM
END:VEVENT
END:VCALENDAR",
),
None,
)?)
))?)
} else {
None
},
@@ -169,14 +158,19 @@ END:VCALENDAR",
}
/// Get significant dates associated with this address object
pub fn get_significant_dates(&self) -> Result<HashMap<&'static str, CalendarObject>, Error> {
let mut out = HashMap::new();
pub fn get_significant_dates(&self) -> Result<Vec<(String, CalendarObject)>, Error> {
let mut out = vec![];
if let Some(birthday) = self.get_birthday_object()? {
out.insert("birthday", birthday);
out.push((birthday.get_inner().get_uid().to_owned(), birthday));
}
if let Some(anniversary) = self.get_anniversary_object()? {
out.insert("anniversary", anniversary);
out.push((anniversary.get_inner().get_uid().to_owned(), anniversary));
}
Ok(out)
}
#[must_use]
pub const fn get_vcard(&self) -> &VcardContact {
&self.vcard
}
}

View File

@@ -1,6 +1,5 @@
use axum::{http::StatusCode, response::IntoResponse};
use crate::CalDateTimeError;
use ical::types::CalDateTimeError;
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum Error {

View File

@@ -20,22 +20,6 @@ impl EventObject {
self.event.get_uid()
}
pub fn get_dtstart(&self) -> Result<Option<CalDateTime>, Error> {
if let Some(dtstart) = self.event.get_dtstart() {
Ok(Some(CalDateTime::parse_prop(dtstart, &self.timezones)?))
} else {
Ok(None)
}
}
pub fn get_dtend(&self) -> Result<Option<CalDateTime>, Error> {
if let Some(dtend) = self.event.get_dtend() {
Ok(Some(CalDateTime::parse_prop(dtend, &self.timezones)?))
} else {
Ok(None)
}
}
pub fn get_last_occurence(&self) -> Result<Option<CalDateTime>, Error> {
if self.event.get_rrule().is_some() {
// TODO: understand recurrence rules
@@ -51,134 +35,6 @@ impl EventObject {
let first_occurence = self.get_dtstart()?;
Ok(first_occurence.map(|first_occurence| first_occurence + duration))
}
pub fn recurrence_ruleset(&self) -> Result<Option<rrule::RRuleSet>, Error> {
let dtstart: DateTime<rrule::Tz> = if let Some(dtstart) = self.get_dtstart()? {
if let Some(dtend) = self.get_dtend()? {
// DTSTART and DTEND MUST have the same timezone
assert_eq!(dtstart.timezone(), dtend.timezone());
}
dtstart
.as_datetime()
.with_timezone(&dtstart.timezone().into())
} else {
return Ok(None);
};
let mut rrule_set = RRuleSet::new(dtstart);
for prop in &self.event.properties {
rrule_set = match prop.name.as_str() {
"RRULE" => {
let rrule = RRule::from_str(prop.value.as_ref().ok_or_else(|| {
Error::RRuleError(rrule::ParseError::MissingDateGenerationRules.into())
})?)?
.validate(dtstart)
.unwrap();
rrule_set.rrule(rrule)
}
"RDATE" => {
let rdate = CalDateTime::parse_prop(prop, &self.timezones)?.into();
rrule_set.rdate(rdate)
}
"EXDATE" => {
let exdate = CalDateTime::parse_prop(prop, &self.timezones)?.into();
rrule_set.exdate(exdate)
}
_ => rrule_set,
}
}
Ok(Some(rrule_set))
}
pub fn expand_recurrence(
&self,
start: Option<DateTime<Utc>>,
end: Option<DateTime<Utc>>,
overrides: &[Self],
) -> Result<Vec<IcalEvent>, Error> {
if let Some(mut rrule_set) = self.recurrence_ruleset()? {
if let Some(start) = start {
rrule_set = rrule_set.after(start.with_timezone(&rrule::Tz::UTC));
}
if let Some(end) = end {
rrule_set = rrule_set.before(end.with_timezone(&rrule::Tz::UTC));
}
let mut events = vec![];
let dates = rrule_set.all(2048).dates;
let dtstart = self.get_dtstart()?.expect("We must have a DTSTART here");
let computed_duration = self
.get_dtend()?
.map(|dtend| dtend.as_datetime().into_owned() - dtstart.as_datetime().into_owned());
'recurrence: for date in dates {
let date = CalDateTime::from(date);
let dateformat = if dtstart.is_date() {
date.format_date()
} else {
date.format()
};
for ev_override in overrides {
if let Some(override_id) = &ev_override
.event
.get_recurrence_id()
.as_ref()
.expect("overrides have a recurrence id")
.value
&& override_id == &dateformat
{
// We have an override for this occurence
//
events.push(ev_override.event.clone());
continue 'recurrence;
}
}
let mut ev = self.event.clone().mutable();
ev.remove_property("RRULE");
ev.remove_property("RDATE");
ev.remove_property("EXDATE");
ev.remove_property("EXRULE");
let dtstart_prop = ev
.get_property("DTSTART")
.expect("We must have a DTSTART here")
.clone();
ev.remove_property("DTSTART");
ev.remove_property("DTEND");
ev.set_property(Property {
name: "RECURRENCE-ID".to_string(),
value: Some(dateformat.clone()),
params: None,
});
ev.set_property(Property {
name: "DTSTART".to_string(),
value: Some(dateformat),
params: dtstart_prop.params.clone(),
});
if let Some(duration) = computed_duration {
let dtend = date + duration;
let dtendformat = if dtstart.is_date() {
dtend.format_date()
} else {
dtend.format()
};
ev.set_property(Property {
name: "DTEND".to_string(),
value: Some(dtendformat),
params: dtstart_prop.params,
});
}
events.push(ev.verify()?);
}
Ok(events)
} else {
Ok(vec![self.event.clone()])
}
}
}
#[cfg(test)]

View File

@@ -1,5 +1,5 @@
mod event;
mod object;
mod object_type;
pub use event::*;
pub use object::*;
pub use object_type::*;

View File

@@ -1,195 +1,20 @@
use super::EventObject;
use crate::CalDateTime;
use crate::CalendarObjectType;
use crate::Error;
use chrono::DateTime;
use chrono::Utc;
use derive_more::Display;
use ical::generator::{Emitter, IcalCalendar};
use ical::parser::ical::component::IcalJournal;
use ical::parser::ical::component::IcalTimeZone;
use ical::parser::ical::component::IcalTodo;
use ical::component::IcalCalendarObject;
use ical::parser::Component;
use ical::property::Property;
use serde::Deserialize;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::{collections::HashMap, io::BufReader};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Display)]
// specified in https://datatracker.ietf.org/doc/html/rfc5545#section-3.6
pub enum CalendarObjectType {
#[serde(rename = "VEVENT")]
Event = 0,
#[serde(rename = "VTODO")]
Todo = 1,
#[serde(rename = "VJOURNAL")]
Journal = 2,
}
impl CalendarObjectType {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Event => "VEVENT",
Self::Todo => "VTODO",
Self::Journal => "VJOURNAL",
}
}
}
impl rustical_xml::ValueSerialize for CalendarObjectType {
fn serialize(&self) -> String {
self.as_str().to_owned()
}
}
impl rustical_xml::ValueDeserialize for CalendarObjectType {
fn deserialize(val: &str) -> std::result::Result<Self, rustical_xml::XmlError> {
match <String as rustical_xml::ValueDeserialize>::deserialize(val)?.as_str() {
"VEVENT" => Ok(Self::Event),
"VTODO" => Ok(Self::Todo),
"VJOURNAL" => Ok(Self::Journal),
_ => Err(rustical_xml::XmlError::InvalidValue(
rustical_xml::ParseValueError::Other(format!(
"Invalid value '{val}', must be VEVENT, VTODO, or VJOURNAL"
)),
)),
}
}
}
#[derive(Debug, Clone)]
pub enum CalendarObjectComponent {
Event(EventObject, Vec<EventObject>),
Todo(IcalTodo, Vec<IcalTodo>),
Journal(IcalJournal, Vec<IcalJournal>),
}
impl CalendarObjectComponent {
#[must_use]
pub fn get_uid(&self) -> &str {
match &self {
// We've made sure before that the first component exists and all components share the
// same UID
Self::Todo(todo, _) => todo.get_uid(),
Self::Event(event, _) => event.event.get_uid(),
Self::Journal(journal, _) => journal.get_uid(),
}
}
}
impl From<&CalendarObjectComponent> for CalendarObjectType {
fn from(value: &CalendarObjectComponent) -> Self {
match value {
CalendarObjectComponent::Event(..) => Self::Event,
CalendarObjectComponent::Todo(..) => Self::Todo,
CalendarObjectComponent::Journal(..) => Self::Journal,
}
}
}
impl CalendarObjectComponent {
fn from_events(mut events: Vec<EventObject>) -> Result<Self, Error> {
let main_event = events
.extract_if(.., |event| event.event.get_recurrence_id().is_none())
.next()
.expect("there must be one main event");
let overrides = events;
for event in &overrides {
if event.get_uid() != main_event.get_uid() {
return Err(Error::InvalidData(
"Calendar object contains multiple UIDs".to_owned(),
));
}
if event.event.get_recurrence_id().is_none() {
return Err(Error::InvalidData(
"Calendar object can only contain one main component".to_owned(),
));
}
}
Ok(Self::Event(main_event, overrides))
}
fn from_todos(mut todos: Vec<IcalTodo>) -> Result<Self, Error> {
let main_todo = todos
.extract_if(.., |todo| todo.get_recurrence_id().is_none())
.next()
.expect("there must be one main event");
let overrides = todos;
for todo in &overrides {
if todo.get_uid() != main_todo.get_uid() {
return Err(Error::InvalidData(
"Calendar object contains multiple UIDs".to_owned(),
));
}
if todo.get_recurrence_id().is_none() {
return Err(Error::InvalidData(
"Calendar object can only contain one main component".to_owned(),
));
}
}
Ok(Self::Todo(main_todo, overrides))
}
fn from_journals(mut journals: Vec<IcalJournal>) -> Result<Self, Error> {
let main_journal = journals
.extract_if(.., |journal| journal.get_recurrence_id().is_none())
.next()
.expect("there must be one main event");
let overrides = journals;
for journal in &overrides {
if journal.get_uid() != main_journal.get_uid() {
return Err(Error::InvalidData(
"Calendar object contains multiple UIDs".to_owned(),
));
}
if journal.get_recurrence_id().is_none() {
return Err(Error::InvalidData(
"Calendar object can only contain one main component".to_owned(),
));
}
}
Ok(Self::Journal(main_journal, overrides))
}
pub fn get_first_occurence(&self) -> Result<Option<CalDateTime>, Error> {
match &self {
Self::Event(main_event, overrides) => Ok(overrides
.iter()
.chain(std::iter::once(main_event))
.map(super::event::EventObject::get_dtstart)
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.min()),
_ => Ok(None),
}
}
pub fn get_last_occurence(&self) -> Result<Option<CalDateTime>, Error> {
match &self {
Self::Event(main_event, overrides) => Ok(overrides
.iter()
.chain(std::iter::once(main_event))
.map(super::event::EventObject::get_last_occurence)
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.max()),
_ => Ok(None),
}
}
}
use std::io::BufReader;
#[derive(Debug, Clone)]
pub struct CalendarObject {
data: CalendarObjectComponent,
properties: Vec<Property>,
id: String,
ics: String,
vtimezones: HashMap<String, IcalTimeZone>,
inner: IcalCalendarObject,
}
impl CalendarObject {
pub fn from_ics(ics: String, id: Option<String>) -> Result<Self, Error> {
let mut parser = ical::IcalParser::new(BufReader::new(ics.as_bytes()));
pub fn from_ics(ics: String) -> Result<Self, Error> {
let mut parser = ical::IcalObjectParser::new(BufReader::new(ics.as_bytes()));
let cal = parser.next().ok_or(Error::MissingCalendar)??;
if parser.next().is_some() {
return Err(Error::InvalidData(
@@ -197,85 +22,18 @@ impl CalendarObject {
));
}
if u8::from(!cal.events.is_empty())
+ u8::from(!cal.todos.is_empty())
+ u8::from(!cal.journals.is_empty())
+ u8::from(!cal.free_busys.is_empty())
!= 1
{
// https://datatracker.ietf.org/doc/html/rfc4791#section-4.1
return Err(Error::InvalidData(
"iCalendar object must have exactly one component type".to_owned(),
));
}
let timezones: HashMap<String, Option<chrono_tz::Tz>> = cal
.timezones
.clone()
.into_iter()
.map(|timezone| (timezone.get_tzid().to_owned(), (&timezone).try_into().ok()))
.collect();
let vtimezones = cal
.timezones
.clone()
.into_iter()
.map(|timezone| (timezone.get_tzid().to_owned(), timezone))
.collect();
let data = if !cal.events.is_empty() {
CalendarObjectComponent::from_events(
cal.events
.into_iter()
.map(|event| EventObject {
event,
timezones: timezones.clone(),
})
.collect(),
)?
} else if !cal.todos.is_empty() {
CalendarObjectComponent::from_todos(cal.todos)?
} else if !cal.journals.is_empty() {
CalendarObjectComponent::from_journals(cal.journals)?
} else {
return Err(Error::InvalidData(
"iCalendar component type not supported :(".to_owned(),
));
};
Ok(Self {
id: id.unwrap_or_else(|| data.get_uid().to_owned()),
data,
properties: cal.properties,
ics,
vtimezones,
})
Ok(Self { ics, inner: cal })
}
#[must_use]
pub const fn get_vtimezones(&self) -> &HashMap<String, IcalTimeZone> {
&self.vtimezones
}
#[must_use]
pub const fn get_data(&self) -> &CalendarObjectComponent {
&self.data
}
#[must_use]
pub fn get_uid(&self) -> &str {
self.data.get_uid()
}
#[must_use]
pub fn get_id(&self) -> &str {
&self.id
pub const fn get_inner(&self) -> &IcalCalendarObject {
&self.inner
}
#[must_use]
pub fn get_etag(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(self.get_uid());
hasher.update(self.inner.get_uid());
hasher.update(self.get_ics());
format!("\"{:x}\"", hasher.finalize())
}
@@ -285,47 +43,13 @@ impl CalendarObject {
&self.ics
}
#[must_use]
pub fn get_component_name(&self) -> &str {
self.get_object_type().as_str()
}
#[must_use]
pub fn get_object_type(&self) -> CalendarObjectType {
(&self.data).into()
}
pub fn get_first_occurence(&self) -> Result<Option<CalDateTime>, Error> {
self.data.get_first_occurence()
}
pub fn get_last_occurence(&self) -> Result<Option<CalDateTime>, Error> {
self.data.get_last_occurence()
}
pub fn expand_recurrence(
&self,
start: Option<DateTime<Utc>>,
end: Option<DateTime<Utc>>,
) -> Result<String, Error> {
// Only events can be expanded
match &self.data {
CalendarObjectComponent::Event(main_event, overrides) => {
let cal = IcalCalendar {
properties: self.properties.clone(),
events: main_event.expand_recurrence(start, end, overrides)?,
..Default::default()
};
Ok(cal.generate())
}
_ => Ok(self.get_ics().to_string()),
}
(&self.inner).into()
}
#[must_use]
pub fn get_property(&self, name: &str) -> Option<&Property> {
self.properties
.iter()
.find(|property| property.name == name)
self.inner.get_property(name)
}
}

View File

@@ -0,0 +1,56 @@
use derive_more::Display;
use ical::component::{CalendarInnerData, IcalCalendarObject};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Display)]
// specified in https://datatracker.ietf.org/doc/html/rfc5545#section-3.6
pub enum CalendarObjectType {
#[serde(rename = "VEVENT")]
Event = 0,
#[serde(rename = "VTODO")]
Todo = 1,
#[serde(rename = "VJOURNAL")]
Journal = 2,
}
impl CalendarObjectType {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Event => "VEVENT",
Self::Todo => "VTODO",
Self::Journal => "VJOURNAL",
}
}
}
impl From<&IcalCalendarObject> for CalendarObjectType {
fn from(value: &IcalCalendarObject) -> Self {
match value.get_inner() {
CalendarInnerData::Event(..) => Self::Event,
CalendarInnerData::Todo(..) => Self::Todo,
CalendarInnerData::Journal(..) => Self::Journal,
}
}
}
impl rustical_xml::ValueSerialize for CalendarObjectType {
fn serialize(&self) -> String {
self.as_str().to_owned()
}
}
impl rustical_xml::ValueDeserialize for CalendarObjectType {
fn deserialize(val: &str) -> std::result::Result<Self, rustical_xml::XmlError> {
match <String as rustical_xml::ValueDeserialize>::deserialize(val)?.as_str() {
"VEVENT" => Ok(Self::Event),
"VTODO" => Ok(Self::Todo),
"VJOURNAL" => Ok(Self::Journal),
_ => Err(rustical_xml::XmlError::InvalidValue(
rustical_xml::ParseValueError::Other(format!(
"Invalid value '{val}', must be VEVENT, VTODO, or VJOURNAL"
)),
)),
}
}
}

View File

@@ -1,9 +1,7 @@
#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
mod timestamp;
mod timezone;
pub use timestamp::*;
pub use timezone::*;
mod icalendar;
pub use icalendar::*;

View File

@@ -1,35 +1,8 @@
use super::timezone::ICalTimezone;
use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use chrono_tz::Tz;
use chrono::{DateTime, NaiveDateTime, Utc};
use derive_more::derive::Deref;
use ical::property::Property;
use rustical_xml::{ValueDeserialize, ValueSerialize};
use std::{borrow::Cow, collections::HashMap, ops::Add, sync::LazyLock};
static RE_VCARD_DATE_MM_DD: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"^--(?<m>\d{2})(?<d>\d{2})$").unwrap());
const LOCAL_DATE_TIME: &str = "%Y%m%dT%H%M%S";
const UTC_DATE_TIME: &str = "%Y%m%dT%H%M%SZ";
pub const LOCAL_DATE: &str = "%Y%m%d";
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum CalDateTimeError {
#[error(
"Timezone has X-LIC-LOCATION property to specify a timezone from the Olson database, however its value {0} is invalid"
)]
InvalidOlson(String),
#[error("TZID {0} does not refer to a valid timezone")]
InvalidTZID(String),
#[error("Timestamp doesn't exist because of gap in local time")]
LocalTimeGap,
#[error("Datetime string {0} has an invalid format")]
InvalidDatetimeFormat(String),
#[error("Could not parse datetime {0}")]
ParseError(String),
#[error("Duration string {0} has an invalid format")]
InvalidDurationFormat(String),
}
#[derive(Debug, Clone, Deref, PartialEq, Eq, Hash)]
pub struct UtcDateTime(pub DateTime<Utc>);
@@ -54,367 +27,3 @@ impl ValueSerialize for UtcDateTime {
format!("{}", self.0.format(UTC_DATE_TIME))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CalDateTime {
// Form 1, example: 19980118T230000 -> Local
// Form 2, example: 19980119T070000Z -> UTC
// Form 3, example: TZID=America/New_York:19980119T020000 -> Olson
// https://en.wikipedia.org/wiki/Tz_database
DateTime(DateTime<ICalTimezone>),
Date(NaiveDate, ICalTimezone),
}
impl From<CalDateTime> for DateTime<rrule::Tz> {
fn from(value: CalDateTime) -> Self {
value
.as_datetime()
.into_owned()
.with_timezone(&value.timezone().into())
}
}
impl From<DateTime<rrule::Tz>> for CalDateTime {
fn from(value: DateTime<rrule::Tz>) -> Self {
Self::DateTime(value.with_timezone(&value.timezone().into()))
}
}
impl PartialOrd for CalDateTime {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for CalDateTime {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
match (&self, &other) {
(Self::DateTime(a), Self::DateTime(b)) => a.cmp(b),
(Self::DateTime(a), Self::Date(..)) => a.cmp(&other.as_datetime()),
(Self::Date(..), Self::DateTime(b)) => self.as_datetime().as_ref().cmp(b),
(Self::Date(..), Self::Date(..)) => self.as_datetime().cmp(&other.as_datetime()),
}
}
}
impl From<DateTime<Local>> for CalDateTime {
fn from(value: DateTime<Local>) -> Self {
Self::DateTime(value.with_timezone(&ICalTimezone::Local))
}
}
impl From<DateTime<Utc>> for CalDateTime {
fn from(value: DateTime<Utc>) -> Self {
Self::DateTime(value.with_timezone(&ICalTimezone::Olson(chrono_tz::UTC)))
}
}
impl Add<Duration> for CalDateTime {
type Output = Self;
fn add(self, duration: Duration) -> Self::Output {
match self {
Self::DateTime(datetime) => Self::DateTime(datetime + duration),
Self::Date(date, tz) => Self::DateTime(
date.and_time(NaiveTime::default())
.and_local_timezone(tz)
.earliest()
.expect("Local timezone has constant offset")
+ duration,
),
}
}
}
impl CalDateTime {
pub fn parse_prop(
prop: &Property,
timezones: &HashMap<String, Option<chrono_tz::Tz>>,
) -> Result<Self, CalDateTimeError> {
let prop_value = prop
.value
.as_ref()
.ok_or_else(|| CalDateTimeError::InvalidDatetimeFormat("empty property".into()))?;
let timezone = if let Some(tzid) = prop.get_param("TZID") {
if let Some(timezone) = timezones.get(tzid) {
timezone.to_owned()
} else {
// TZID refers to timezone that does not exist
return Err(CalDateTimeError::InvalidTZID(tzid.to_string()));
}
} else {
// No explicit timezone specified.
// This is valid and will be localtime or UTC depending on the value
// We will stick to this default as documented in https://github.com/lennart-k/rustical/issues/102
None
};
Self::parse(prop_value, timezone)
}
#[must_use]
pub fn format(&self) -> String {
match self {
Self::DateTime(datetime) => match datetime.timezone() {
ICalTimezone::Olson(chrono_tz::UTC) => datetime.format(UTC_DATE_TIME).to_string(),
_ => datetime.format(LOCAL_DATE_TIME).to_string(),
},
Self::Date(date, _) => date.format(LOCAL_DATE).to_string(),
}
}
#[must_use]
pub fn format_date(&self) -> String {
match self {
Self::DateTime(datetime) => datetime.format(LOCAL_DATE).to_string(),
Self::Date(date, _) => date.format(LOCAL_DATE).to_string(),
}
}
#[must_use]
pub fn date(&self) -> NaiveDate {
match self {
Self::DateTime(datetime) => datetime.date_naive(),
Self::Date(date, _) => date.to_owned(),
}
}
#[must_use]
pub const fn is_date(&self) -> bool {
matches!(&self, Self::Date(_, _))
}
#[must_use]
pub fn as_datetime(&self) -> Cow<'_, DateTime<ICalTimezone>> {
match self {
Self::DateTime(datetime) => Cow::Borrowed(datetime),
Self::Date(date, tz) => Cow::Owned(
date.and_time(NaiveTime::default())
.and_local_timezone(tz.to_owned())
.earliest()
.expect("Midnight always exists"),
),
}
}
pub fn parse(value: &str, timezone: Option<Tz>) -> Result<Self, CalDateTimeError> {
if let Ok(datetime) = NaiveDateTime::parse_from_str(value, LOCAL_DATE_TIME) {
if let Some(timezone) = timezone {
return Ok(Self::DateTime(
datetime
.and_local_timezone(timezone.into())
.earliest()
.ok_or(CalDateTimeError::LocalTimeGap)?,
));
}
return Ok(Self::DateTime(
datetime
.and_local_timezone(ICalTimezone::Local)
.earliest()
.ok_or(CalDateTimeError::LocalTimeGap)?,
));
}
if let Ok(datetime) = NaiveDateTime::parse_from_str(value, UTC_DATE_TIME) {
return Ok(datetime.and_utc().into());
}
let timezone = timezone.map_or(ICalTimezone::Local, ICalTimezone::Olson);
if let Ok(date) = NaiveDate::parse_from_str(value, LOCAL_DATE) {
return Ok(Self::Date(date, timezone));
}
if let Ok(date) = NaiveDate::parse_from_str(value, "%Y-%m-%d") {
return Ok(Self::Date(date, timezone));
}
if let Ok(date) = NaiveDate::parse_from_str(value, "%Y%m%d") {
return Ok(Self::Date(date, timezone));
}
Err(CalDateTimeError::InvalidDatetimeFormat(value.to_string()))
}
// Also returns whether the date contains a year
pub fn parse_vcard(value: &str) -> Result<(Self, bool), CalDateTimeError> {
if let Ok(datetime) = Self::parse(value, None) {
return Ok((datetime, true));
}
if let Some(captures) = RE_VCARD_DATE_MM_DD.captures(value) {
// Because 1972 is a leap year
let year = 1972;
// Cannot fail because of the regex
let month = captures.name("m").unwrap().as_str().parse().ok().unwrap();
let day = captures.name("d").unwrap().as_str().parse().ok().unwrap();
return Ok((
Self::Date(
NaiveDate::from_ymd_opt(year, month, day)
.ok_or_else(|| CalDateTimeError::ParseError(value.to_string()))?,
ICalTimezone::Local,
),
false,
));
}
Err(CalDateTimeError::InvalidDatetimeFormat(value.to_string()))
}
#[must_use]
pub fn utc(&self) -> DateTime<Utc> {
self.as_datetime().to_utc()
}
#[must_use]
pub fn timezone(&self) -> ICalTimezone {
match &self {
Self::DateTime(datetime) => datetime.timezone(),
Self::Date(_, tz) => tz.to_owned(),
}
}
}
impl From<CalDateTime> for DateTime<Utc> {
fn from(value: CalDateTime) -> Self {
value.utc()
}
}
impl Datelike for CalDateTime {
fn year(&self) -> i32 {
match &self {
Self::DateTime(datetime) => datetime.year(),
Self::Date(date, _) => date.year(),
}
}
fn month(&self) -> u32 {
match &self {
Self::DateTime(datetime) => datetime.month(),
Self::Date(date, _) => date.month(),
}
}
fn month0(&self) -> u32 {
match &self {
Self::DateTime(datetime) => datetime.month0(),
Self::Date(date, _) => date.month0(),
}
}
fn day(&self) -> u32 {
match &self {
Self::DateTime(datetime) => datetime.day(),
Self::Date(date, _) => date.day(),
}
}
fn day0(&self) -> u32 {
match &self {
Self::DateTime(datetime) => datetime.day0(),
Self::Date(date, _) => date.day0(),
}
}
fn ordinal(&self) -> u32 {
match &self {
Self::DateTime(datetime) => datetime.ordinal(),
Self::Date(date, _) => date.ordinal(),
}
}
fn ordinal0(&self) -> u32 {
match &self {
Self::DateTime(datetime) => datetime.ordinal0(),
Self::Date(date, _) => date.ordinal0(),
}
}
fn weekday(&self) -> chrono::Weekday {
match &self {
Self::DateTime(datetime) => datetime.weekday(),
Self::Date(date, _) => date.weekday(),
}
}
fn iso_week(&self) -> chrono::IsoWeek {
match &self {
Self::DateTime(datetime) => datetime.iso_week(),
Self::Date(date, _) => date.iso_week(),
}
}
fn with_year(&self, year: i32) -> Option<Self> {
match &self {
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_year(year)?)),
Self::Date(date, tz) => Some(Self::Date(date.with_year(year)?, tz.to_owned())),
}
}
fn with_month(&self, month: u32) -> Option<Self> {
match &self {
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_month(month)?)),
Self::Date(date, tz) => Some(Self::Date(date.with_month(month)?, tz.to_owned())),
}
}
fn with_month0(&self, month0: u32) -> Option<Self> {
match &self {
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_month0(month0)?)),
Self::Date(date, tz) => Some(Self::Date(date.with_month0(month0)?, tz.to_owned())),
}
}
fn with_day(&self, day: u32) -> Option<Self> {
match &self {
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_day(day)?)),
Self::Date(date, tz) => Some(Self::Date(date.with_day(day)?, tz.to_owned())),
}
}
fn with_day0(&self, day0: u32) -> Option<Self> {
match &self {
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_day0(day0)?)),
Self::Date(date, tz) => Some(Self::Date(date.with_day0(day0)?, tz.to_owned())),
}
}
fn with_ordinal(&self, ordinal: u32) -> Option<Self> {
match &self {
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_ordinal(ordinal)?)),
Self::Date(date, tz) => Some(Self::Date(date.with_ordinal(ordinal)?, tz.to_owned())),
}
}
fn with_ordinal0(&self, ordinal0: u32) -> Option<Self> {
match &self {
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_ordinal0(ordinal0)?)),
Self::Date(date, tz) => Some(Self::Date(date.with_ordinal0(ordinal0)?, tz.to_owned())),
}
}
}
#[cfg(test)]
mod tests {
use crate::CalDateTime;
use chrono::NaiveDate;
#[test]
fn test_vcard_date() {
assert_eq!(
CalDateTime::parse_vcard("19850412").unwrap(),
(
CalDateTime::Date(
NaiveDate::from_ymd_opt(1985, 4, 12).unwrap(),
crate::ICalTimezone::Local
),
true
)
);
assert_eq!(
CalDateTime::parse_vcard("1985-04-12").unwrap(),
(
CalDateTime::Date(
NaiveDate::from_ymd_opt(1985, 4, 12).unwrap(),
crate::ICalTimezone::Local
),
true
)
);
assert_eq!(
CalDateTime::parse_vcard("--0412").unwrap(),
(
CalDateTime::Date(
NaiveDate::from_ymd_opt(1972, 4, 12).unwrap(),
crate::ICalTimezone::Local
),
false
)
);
}
}

View File

@@ -1,92 +0,0 @@
use chrono::{Local, NaiveDate, NaiveDateTime, TimeZone};
use chrono_tz::Tz;
use derive_more::{Display, From};
#[derive(Debug, Clone, From, PartialEq, Eq)]
pub enum ICalTimezone {
Local,
Olson(Tz),
}
impl From<ICalTimezone> for rrule::Tz {
fn from(value: ICalTimezone) -> Self {
match value {
ICalTimezone::Local => Self::LOCAL,
ICalTimezone::Olson(tz) => Self::Tz(tz),
}
}
}
impl From<rrule::Tz> for ICalTimezone {
fn from(value: rrule::Tz) -> Self {
match value {
rrule::Tz::Local(_) => Self::Local,
rrule::Tz::Tz(tz) => Self::Olson(tz),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Display)]
pub enum CalTimezoneOffset {
Local(chrono::FixedOffset),
Olson(chrono_tz::TzOffset),
}
impl chrono::Offset for CalTimezoneOffset {
fn fix(&self) -> chrono::FixedOffset {
match self {
Self::Local(local) => local.fix(),
Self::Olson(olson) => olson.fix(),
}
}
}
impl TimeZone for ICalTimezone {
type Offset = CalTimezoneOffset;
fn from_offset(offset: &Self::Offset) -> Self {
match offset {
CalTimezoneOffset::Local(_) => Self::Local,
CalTimezoneOffset::Olson(offset) => Self::Olson(Tz::from_offset(offset)),
}
}
fn offset_from_local_date(&self, local: &NaiveDate) -> chrono::MappedLocalTime<Self::Offset> {
match self {
Self::Local => Local
.offset_from_local_date(local)
.map(CalTimezoneOffset::Local),
Self::Olson(tz) => tz
.offset_from_local_date(local)
.map(CalTimezoneOffset::Olson),
}
}
fn offset_from_local_datetime(
&self,
local: &NaiveDateTime,
) -> chrono::MappedLocalTime<Self::Offset> {
match self {
Self::Local => Local
.offset_from_local_datetime(local)
.map(CalTimezoneOffset::Local),
Self::Olson(tz) => tz
.offset_from_local_datetime(local)
.map(CalTimezoneOffset::Olson),
}
}
fn offset_from_utc_datetime(&self, utc: &NaiveDateTime) -> Self::Offset {
match self {
Self::Local => CalTimezoneOffset::Local(Local.offset_from_utc_datetime(utc)),
Self::Olson(tz) => CalTimezoneOffset::Olson(tz.offset_from_utc_datetime(utc)),
}
}
fn offset_from_utc_date(&self, utc: &NaiveDate) -> Self::Offset {
match self {
Self::Local => CalTimezoneOffset::Local(Local.offset_from_utc_date(utc)),
Self::Olson(tz) => CalTimezoneOffset::Olson(tz.offset_from_utc_date(utc)),
}
}
}

View File

@@ -25,6 +25,6 @@ END:VCALENDAR
#[test]
fn parse_calendar_object() {
let object = CalendarObject::from_ics(MULTI_VEVENT.to_string(), None).unwrap();
object.expand_recurrence(None, None).unwrap();
let object = CalendarObject::from_ics(MULTI_VEVENT.to_string()).unwrap();
object.get_inner().expand_recurrence(None, None).unwrap();
}

View File

@@ -37,7 +37,7 @@ pub trait CalendarStore: Send + Sync + 'static {
async fn import_calendar(
&self,
calendar: Calendar,
objects: Vec<CalendarObject>,
objects: Vec<(String, CalendarObject)>,
merge_existing: bool,
) -> Result<(), Error>;
@@ -46,7 +46,7 @@ pub trait CalendarStore: Send + Sync + 'static {
principal: &str,
cal_id: &str,
synctoken: i64,
) -> Result<(Vec<CalendarObject>, Vec<String>, i64), Error>;
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error>;
/// Since the <calendar-query> rules are rather complex this function
/// is only meant to do some prefiltering
@@ -55,7 +55,7 @@ pub trait CalendarStore: Send + Sync + 'static {
principal: &str,
cal_id: &str,
_query: CalendarQuery,
) -> Result<Vec<CalendarObject>, Error> {
) -> Result<Vec<(String, CalendarObject)>, Error> {
self.get_objects(principal, cal_id).await
}
@@ -69,7 +69,7 @@ pub trait CalendarStore: Send + Sync + 'static {
&self,
principal: &str,
cal_id: &str,
) -> Result<Vec<CalendarObject>, Error>;
) -> Result<Vec<(String, CalendarObject)>, Error>;
async fn get_object(
&self,
principal: &str,
@@ -77,13 +77,23 @@ pub trait CalendarStore: Send + Sync + 'static {
object_id: &str,
show_deleted: bool,
) -> Result<CalendarObject, Error>;
async fn put_objects(
&self,
principal: String,
cal_id: String,
objects: Vec<(String, CalendarObject)>,
overwrite: bool,
) -> Result<(), Error>;
async fn put_object(
&self,
principal: String,
cal_id: String,
object: CalendarObject,
object: (String, CalendarObject),
overwrite: bool,
) -> Result<(), Error>;
) -> Result<(), Error> {
self.put_objects(principal, cal_id, vec![object], overwrite)
.await
}
async fn delete_object(
&self,
principal: &str,

View File

@@ -1,5 +1,6 @@
use crate::CalendarStore;
use async_trait::async_trait;
use rustical_ical::CalendarObject;
use std::{collections::HashMap, sync::Arc};
pub trait PrefixedCalendarStore: CalendarStore {
@@ -88,7 +89,7 @@ impl CalendarStore for CombinedCalendarStore {
principal: &str,
cal_id: &str,
synctoken: i64,
) -> Result<(Vec<rustical_ical::CalendarObject>, Vec<String>, i64), crate::Error> {
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), crate::Error> {
self.store_for_id(cal_id)
.sync_changes(principal, cal_id, synctoken)
.await
@@ -97,7 +98,7 @@ impl CalendarStore for CombinedCalendarStore {
async fn import_calendar(
&self,
calendar: crate::Calendar,
objects: Vec<rustical_ical::CalendarObject>,
objects: Vec<(String, CalendarObject)>,
merge_existing: bool,
) -> Result<(), crate::Error> {
self.store_for_id(&calendar.id)
@@ -110,7 +111,7 @@ impl CalendarStore for CombinedCalendarStore {
principal: &str,
cal_id: &str,
query: crate::calendar_store::CalendarQuery,
) -> Result<Vec<rustical_ical::CalendarObject>, crate::Error> {
) -> Result<Vec<(String, CalendarObject)>, crate::Error> {
self.store_for_id(cal_id)
.calendar_query(principal, cal_id, query)
.await
@@ -141,21 +142,21 @@ impl CalendarStore for CombinedCalendarStore {
&self,
principal: &str,
cal_id: &str,
) -> Result<Vec<rustical_ical::CalendarObject>, crate::Error> {
) -> Result<Vec<(String, CalendarObject)>, crate::Error> {
self.store_for_id(cal_id)
.get_objects(principal, cal_id)
.await
}
async fn put_object(
async fn put_objects(
&self,
principal: String,
cal_id: String,
object: rustical_ical::CalendarObject,
objects: Vec<(String, CalendarObject)>,
overwrite: bool,
) -> Result<(), crate::Error> {
self.store_for_id(&cal_id)
.put_object(principal, cal_id, object, overwrite)
.put_objects(principal, cal_id, objects, overwrite)
.await
}

View File

@@ -11,8 +11,13 @@ publish = false
[features]
test = ["dep:rstest"]
[[bench]]
name = "insert_calendar_object"
harness = false
[dev-dependencies]
rstest.workspace = true
criterion.workspace = true
[dependencies]
tokio.workspace = true
@@ -31,3 +36,4 @@ pbkdf2.workspace = true
rustical_ical.workspace = true
rstest = { workspace = true, optional = true }
sha2.workspace = true
ical.workspace = true

View File

@@ -0,0 +1,47 @@
BEGIN:VCALENDAR
CALSCALE:GREGORIAN
PRODID:-//Ximian//NONSGML Evolution Calendar//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
BEGIN:DAYLIGHT
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
DTSTART:19810329T020000
RRULE:FREQ=YEARLY;UNTIL=20370329T010000Z;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
DTSTART:19961027T030000
RRULE:FREQ=YEARLY;UNTIL=20361026T010000Z;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
UID:fa915b604e6e3f36772501ff869439e6a3c5cf67
DTSTAMP:20250726T112617Z
DTSTART;VALUE=DATE:20250806
DTEND;VALUE=DATE:20250807
SEQUENCE:2
SUMMARY:all day event
TRANSP:OPAQUE
CLASS:PUBLIC
CREATED:20250726T144426Z
LAST-MODIFIED:20250726T144426Z
BEGIN:VALARM
TRIGGER:-PT30M
REPEAT:2
DURATION:PT15M
ACTION:DISPLAY
DESCRIPTION:Breakfast meeting with executive\n
team at 8:30 AM EST.
END:VALARM
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,79 @@
use criterion::{Criterion, criterion_group, criterion_main};
use rustical_ical::{CalendarObject, CalendarObjectType};
use rustical_store::{Calendar, CalendarMetadata, CalendarStore};
use rustical_store_sqlite::tests::get_test_calendar_store;
fn benchmark(c: &mut Criterion) {
let runtime = tokio::runtime::Runtime::new().unwrap();
let cal_store = runtime.block_on(async {
let cal_store = get_test_calendar_store().await;
cal_store
.insert_calendar(Calendar {
meta: CalendarMetadata {
displayname: Some("Yeet".to_owned()),
order: 0,
description: None,
color: None,
},
principal: "user".to_owned(),
id: "okwow".to_owned(),
timezone_id: None,
deleted_at: None,
synctoken: 0,
subscription_url: None,
push_topic: "asd".to_owned(),
components: vec![
CalendarObjectType::Event,
CalendarObjectType::Todo,
CalendarObjectType::Journal,
],
})
.await
.unwrap();
cal_store
});
let object = CalendarObject::from_ics(include_str!("ical_event.ics").to_owned()).unwrap();
let batch_size = 1000;
let objects: Vec<_> = std::iter::repeat_n(
(object.get_inner().get_uid().to_owned(), object.clone()),
batch_size,
)
.collect();
c.bench_function("put_batch", |b| {
b.to_async(&runtime).iter(async || {
// yeet
cal_store
.put_objects("user".to_owned(), "okwow".to_owned(), objects.clone(), true)
.await
.unwrap();
});
});
c.bench_function("put_single", |b| {
b.to_async(&runtime).iter(async || {
// yeet
for _ in 0..1000 {
cal_store
.put_object(
"user".to_owned(),
"okwow".to_owned(),
(object.get_inner().get_uid().to_owned(), object.clone()),
true,
)
.await
.unwrap();
}
});
});
runtime
.block_on(cal_store.delete_calendar("user", "okwow", false))
.unwrap();
}
criterion_group!(benches, benchmark);
criterion_main!(benches);

View File

@@ -8,7 +8,6 @@ use rustical_store::{
};
use sha2::{Digest, Sha256};
use sqlx::{Executor, Sqlite};
use std::collections::HashMap;
use tracing::instrument;
pub const BIRTHDAYS_PREFIX: &str = "_birthdays_";
@@ -312,7 +311,7 @@ impl CalendarStore for SqliteAddressbookStore {
async fn import_calendar(
&self,
_calendar: Calendar,
_objects: Vec<CalendarObject>,
_objects: Vec<(String, CalendarObject)>,
_merge_existing: bool,
) -> Result<(), Error> {
Err(Error::ReadOnly)
@@ -324,17 +323,19 @@ impl CalendarStore for SqliteAddressbookStore {
principal: &str,
cal_id: &str,
synctoken: i64,
) -> Result<(Vec<CalendarObject>, Vec<String>, i64), Error> {
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error> {
let cal_id = cal_id
.strip_prefix(BIRTHDAYS_PREFIX)
.ok_or(Error::NotFound)?;
let (objects, deleted_objects, new_synctoken) =
AddressbookStore::sync_changes(self, principal, cal_id, synctoken).await?;
let objects: Result<Vec<Option<CalendarObject>>, rustical_ical::Error> = objects
let objects = objects
.iter()
.map(AddressObject::get_birthday_object)
.map(AddressObject::get_significant_dates)
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.collect();
let objects = objects?.into_iter().flatten().collect();
Ok((objects, deleted_objects, new_synctoken))
}
@@ -356,22 +357,18 @@ impl CalendarStore for SqliteAddressbookStore {
&self,
principal: &str,
cal_id: &str,
) -> Result<Vec<CalendarObject>, Error> {
) -> Result<Vec<(String, CalendarObject)>, Error> {
let cal_id = cal_id
.strip_prefix(BIRTHDAYS_PREFIX)
.ok_or(Error::NotFound)?;
let objects: Result<Vec<HashMap<&'static str, CalendarObject>>, rustical_ical::Error> =
AddressbookStore::get_objects(self, principal, cal_id)
.await?
.iter()
.map(AddressObject::get_significant_dates)
.collect();
let objects = objects?
Ok(AddressbookStore::get_objects(self, principal, cal_id)
.await?
.iter()
.map(AddressObject::get_significant_dates)
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flat_map(HashMap::into_values)
.collect();
Ok(objects)
.flatten()
.collect())
}
#[instrument]
@@ -386,19 +383,22 @@ impl CalendarStore for SqliteAddressbookStore {
.strip_prefix(BIRTHDAYS_PREFIX)
.ok_or(Error::NotFound)?;
let (addressobject_id, date_type) = object_id.rsplit_once('-').ok_or(Error::NotFound)?;
AddressbookStore::get_object(self, principal, cal_id, addressobject_id, show_deleted)
.await?
.get_significant_dates()?
.remove(date_type)
.ok_or(Error::NotFound)
let addr_object =
AddressbookStore::get_object(self, principal, cal_id, addressobject_id, show_deleted)
.await?;
match date_type {
"birthday" => addr_object.get_birthday_object()?.ok_or(Error::NotFound),
"anniversary" => addr_object.get_anniversary_object()?.ok_or(Error::NotFound),
_ => Err(Error::NotFound),
}
}
#[instrument]
async fn put_object(
async fn put_objects(
&self,
_principal: String,
_cal_id: String,
_object: CalendarObject,
_objects: Vec<(String, CalendarObject)>,
_overwrite: bool,
) -> Result<(), Error> {
Err(Error::ReadOnly)

View File

@@ -3,7 +3,8 @@ use crate::BEGIN_IMMEDIATE;
use async_trait::async_trait;
use chrono::TimeDelta;
use derive_more::derive::Constructor;
use rustical_ical::{CalDateTime, CalendarObject, CalendarObjectType};
use ical::types::CalDateOrDateTime;
use rustical_ical::{CalendarObject, CalendarObjectType};
use rustical_store::calendar_store::CalendarQuery;
use rustical_store::synctoken::format_synctoken;
use rustical_store::{Calendar, CalendarMetadata, CalendarStore, CollectionMetadata, Error};
@@ -20,17 +21,27 @@ struct CalendarObjectRow {
uid: String,
}
impl TryFrom<CalendarObjectRow> for (String, CalendarObject) {
type Error = rustical_store::Error;
fn try_from(value: CalendarObjectRow) -> Result<Self, Self::Error> {
let object_id = value.id.clone();
Ok((object_id, value.try_into()?))
}
}
impl TryFrom<CalendarObjectRow> for CalendarObject {
type Error = rustical_store::Error;
fn try_from(value: CalendarObjectRow) -> Result<Self, Self::Error> {
let object = Self::from_ics(value.ics, Some(value.id))?;
if object.get_uid() != value.uid {
let object = Self::from_ics(value.ics)?;
if object.get_inner().get_uid() != value.uid {
return Err(rustical_store::Error::IcalError(
rustical_ical::Error::InvalidData(format!(
"uid={} and UID={} don't match",
value.uid,
object.get_uid()
object.get_inner().get_uid()
)),
));
}
@@ -378,7 +389,7 @@ impl SqliteCalendarStore {
executor: E,
principal: &str,
cal_id: &str,
) -> Result<Vec<CalendarObject>, Error> {
) -> Result<Vec<(String, CalendarObject)>, Error> {
sqlx::query_as!(
CalendarObjectRow,
"SELECT id, uid, ics FROM calendarobjects WHERE principal = ? AND cal_id = ? AND deleted_at IS NULL",
@@ -388,7 +399,7 @@ impl SqliteCalendarStore {
.fetch_all(executor)
.await.map_err(crate::Error::from)?
.into_iter()
.map(std::convert::TryInto::try_into)
.map(TryInto::try_into)
.collect()
}
@@ -397,7 +408,7 @@ impl SqliteCalendarStore {
principal: &str,
cal_id: &str,
query: CalendarQuery,
) -> Result<Vec<CalendarObject>, Error> {
) -> Result<Vec<(String, CalendarObject)>, Error> {
// We extend our query interval by one day in each direction since we really don't want to
// miss any objects because of timezone differences
// I've previously tried NaiveDate::MIN,MAX, but it seems like sqlite cannot handle these
@@ -422,7 +433,7 @@ impl SqliteCalendarStore {
.await
.map_err(crate::Error::from)?
.into_iter()
.map(std::convert::TryInto::try_into)
.map(TryInto::try_into)
.collect()
}
@@ -452,23 +463,26 @@ impl SqliteCalendarStore {
executor: E,
principal: &str,
cal_id: &str,
object_id: &str,
object: &CalendarObject,
overwrite: bool,
) -> Result<(), Error> {
let (object_id, uid, ics) = (object.get_id(), object.get_uid(), object.get_ics());
let (uid, ics) = (object.get_inner().get_uid(), object.get_ics());
let first_occurence = object
.get_first_occurence()
.get_inner()
.get_dtstart()
.ok()
.flatten()
.as_ref()
.map(CalDateTime::date);
.map(CalDateOrDateTime::date_floor);
let last_occurence = object
.get_inner()
.get_last_occurence()
.ok()
.flatten()
.as_ref()
.map(CalDateTime::date);
.map(CalDateOrDateTime::date_ceil);
let etag = object.get_etag();
let object_type = object.get_object_type() as u8;
@@ -560,7 +574,7 @@ impl SqliteCalendarStore {
principal: &str,
cal_id: &str,
synctoken: i64,
) -> Result<(Vec<CalendarObject>, Vec<String>, i64), Error> {
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error> {
struct Row {
object_id: String,
synctoken: i64,
@@ -587,7 +601,7 @@ impl SqliteCalendarStore {
for Row { object_id, .. } in changes {
match Self::_get_object(&mut *conn, principal, cal_id, &object_id, false).await {
Ok(object) => objects.push(object),
Ok(object) => objects.push((object_id, object)),
Err(rustical_store::Error::NotFound) => deleted_objects.push(object_id),
Err(err) => return Err(err),
}
@@ -672,7 +686,7 @@ impl CalendarStore for SqliteCalendarStore {
async fn import_calendar(
&self,
calendar: Calendar,
objects: Vec<CalendarObject>,
objects: Vec<(String, CalendarObject)>,
merge_existing: bool,
) -> Result<(), Error> {
let mut tx = self
@@ -695,15 +709,23 @@ impl CalendarStore for SqliteCalendarStore {
}
let mut sync_token = None;
for object in objects {
Self::_put_object(&mut *tx, &calendar.principal, &calendar.id, &object, false).await?;
for (object_id, object) in objects {
Self::_put_object(
&mut *tx,
&calendar.principal,
&calendar.id,
&object_id,
&object,
false,
)
.await?;
sync_token = Some(
Self::log_object_operation(
&mut tx,
&calendar.principal,
&calendar.id,
object.get_id(),
&object_id,
ChangeOperation::Add,
)
.await?,
@@ -729,7 +751,7 @@ impl CalendarStore for SqliteCalendarStore {
principal: &str,
cal_id: &str,
query: CalendarQuery,
) -> Result<Vec<CalendarObject>, Error> {
) -> Result<Vec<(String, CalendarObject)>, Error> {
Self::_calendar_query(&self.db, principal, cal_id, query).await
}
@@ -760,7 +782,7 @@ impl CalendarStore for SqliteCalendarStore {
&self,
principal: &str,
cal_id: &str,
) -> Result<Vec<CalendarObject>, Error> {
) -> Result<Vec<(String, CalendarObject)>, Error> {
Self::_get_objects(&self.db, principal, cal_id).await
}
@@ -776,11 +798,11 @@ impl CalendarStore for SqliteCalendarStore {
}
#[instrument]
async fn put_object(
async fn put_objects(
&self,
principal: String,
cal_id: String,
object: CalendarObject,
objects: Vec<(String, CalendarObject)>,
overwrite: bool,
) -> Result<(), Error> {
let mut tx = self
@@ -789,33 +811,40 @@ impl CalendarStore for SqliteCalendarStore {
.await
.map_err(crate::Error::from)?;
let object_id = object.get_id().to_owned();
let calendar = Self::_get_calendar(&mut *tx, &principal, &cal_id, true).await?;
if calendar.subscription_url.is_some() {
// We cannot commit an object to a subscription calendar
return Err(Error::ReadOnly);
}
Self::_put_object(&mut *tx, &principal, &cal_id, &object, overwrite).await?;
let sync_token = Self::log_object_operation(
&mut tx,
&principal,
&cal_id,
&object_id,
ChangeOperation::Add,
)
.await?;
let mut sync_token = None;
for (object_id, object) in objects {
sync_token = Some(
Self::log_object_operation(
&mut tx,
&principal,
&cal_id,
&object_id,
ChangeOperation::Add,
)
.await?,
);
Self::_put_object(
&mut *tx, &principal, &cal_id, &object_id, &object, overwrite,
)
.await?;
}
tx.commit().await.map_err(crate::Error::from)?;
self.send_push_notification(
CollectionOperationInfo::Content { sync_token },
self.get_calendar(&principal, &cal_id, true)
.await?
.push_topic,
);
if let Some(sync_token) = sync_token {
self.send_push_notification(
CollectionOperationInfo::Content { sync_token },
self.get_calendar(&principal, &cal_id, true)
.await?
.push_topic,
);
}
Ok(())
}
@@ -881,7 +910,7 @@ impl CalendarStore for SqliteCalendarStore {
principal: &str,
cal_id: &str,
synctoken: i64,
) -> Result<(Vec<CalendarObject>, Vec<String>, i64), Error> {
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error> {
Self::_sync_changes(&self.db, principal, cal_id, synctoken).await
}

View File

@@ -3,7 +3,7 @@ source: src/integration_tests/caldav/calendar.rs
expression: body
---
BEGIN:VCALENDAR
VERSION:4.0
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:RustiCal
X-WR-CALNAME:Calendar