Compare commits

...

17 Commits

Author SHA1 Message Date
Lennart
ef40f5ea8c version 0.11.2 2025-12-11 20:34:34 +01:00
Lennart
1230e29243 frontend: tiny improvement to import forms 2025-12-11 20:17:04 +01:00
Lennart
1b2296c00a clippy appeasement 2025-12-11 20:16:44 +01:00
Lennart
ac6ab0ca9a clippy appeasement 2025-12-11 20:01:04 +01:00
Lennart
6312f52b10 update license information 2025-12-11 19:59:20 +01:00
Lennart
ec28cb9d9a frontend: improve calendar creation form and fix data binding bugs 2025-12-11 19:52:48 +01:00
Lennart
4b4210b4d7 Add initial test for app initialisation 2025-12-10 14:43:06 +01:00
Lennart
8fadff1b57 fix insta snapshots 2025-12-10 14:30:18 +01:00
Lennart
61a8c32af4 add some more propfind regression tests 2025-12-10 14:22:04 +01:00
Lennart
a45e0b2efd carddav: Try out some tests with insta 2025-12-10 12:26:31 +01:00
Lennart
eecc03b7b7 caldav: add debug to principal resource 2025-12-10 12:25:59 +01:00
Lennart
e8303b9c82 main: slight refactoring 2025-12-10 12:25:13 +01:00
Lennart
a686286d06 sqlite_store: Refactor notification logic 2025-12-10 10:44:41 +01:00
Lennart
d81074de3b version 0.11.1 2025-12-05 20:32:17 +01:00
Lennart
42386adcfa frontend: remove debug statement from template 2025-12-05 20:31:58 +01:00
Lennart
d2f5f7c89b version 0.11.0 2025-12-05 15:06:01 +01:00
Lennart Kämmle
15e431ce12 Merge pull request #138 from lennart-k/feature/birthday-calendar
Feature/birthday calendar
2025-12-05 15:03:56 +01:00
62 changed files with 1826 additions and 2167 deletions

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "REPLACE INTO davpush_subscriptions (id, topic, expiration, push_resource, public_key, public_key_type, auth_secret) VALUES (?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "583069cbeba5285c63c2b95e989669d3faed66a75fbfc7cd93e5f64b778f45ab"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO davpush_subscriptions (id, topic, expiration, push_resource, public_key, public_key_type, auth_secret) VALUES (?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "6d08d3a014743da9b445ab012437ec11f81fd86d3b02fc1df07a036c6b47ace2"
}

39
Cargo.lock generated
View File

@@ -1789,6 +1789,17 @@ version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
[[package]]
name = "insta"
version = "1.44.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698"
dependencies = [
"console",
"once_cell",
"similar",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.11.0" version = "2.11.0"
@@ -3047,7 +3058,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical" name = "rustical"
version = "0.10.5" version = "0.11.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -3058,6 +3069,7 @@ dependencies = [
"figment", "figment",
"headers", "headers",
"http", "http",
"insta",
"opentelemetry", "opentelemetry",
"opentelemetry-otlp", "opentelemetry-otlp",
"opentelemetry-semantic-conventions", "opentelemetry-semantic-conventions",
@@ -3067,6 +3079,7 @@ dependencies = [
"quick-xml", "quick-xml",
"reqwest", "reqwest",
"rpassword", "rpassword",
"rstest",
"rustical_caldav", "rustical_caldav",
"rustical_carddav", "rustical_carddav",
"rustical_dav", "rustical_dav",
@@ -3090,7 +3103,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_caldav" name = "rustical_caldav"
version = "0.10.5" version = "0.11.2"
dependencies = [ dependencies = [
"async-std", "async-std",
"async-trait", "async-trait",
@@ -3104,6 +3117,7 @@ dependencies = [
"headers", "headers",
"http", "http",
"ical", "ical",
"insta",
"percent-encoding", "percent-encoding",
"quick-xml", "quick-xml",
"rstest", "rstest",
@@ -3131,7 +3145,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_carddav" name = "rustical_carddav"
version = "0.10.5" version = "0.11.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -3142,6 +3156,7 @@ dependencies = [
"futures-util", "futures-util",
"http", "http",
"ical", "ical",
"insta",
"percent-encoding", "percent-encoding",
"quick-xml", "quick-xml",
"rustical_dav", "rustical_dav",
@@ -3163,7 +3178,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_dav" name = "rustical_dav"
version = "0.10.5" version = "0.11.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -3188,7 +3203,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_dav_push" name = "rustical_dav_push"
version = "0.10.5" version = "0.11.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -3213,7 +3228,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_frontend" name = "rustical_frontend"
version = "0.10.5" version = "0.11.2"
dependencies = [ dependencies = [
"askama", "askama",
"askama_web", "askama_web",
@@ -3249,7 +3264,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_ical" name = "rustical_ical"
version = "0.10.5" version = "0.11.2"
dependencies = [ dependencies = [
"axum", "axum",
"chrono", "chrono",
@@ -3266,7 +3281,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_oidc" name = "rustical_oidc"
version = "0.10.5" version = "0.11.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -3282,7 +3297,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_store" name = "rustical_store"
version = "0.10.5" version = "0.11.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -3315,7 +3330,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_store_sqlite" name = "rustical_store_sqlite"
version = "0.10.5" version = "0.11.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
@@ -3337,7 +3352,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_xml" name = "rustical_xml"
version = "0.10.5" version = "0.11.2"
dependencies = [ dependencies = [
"quick-xml", "quick-xml",
"thiserror 2.0.17", "thiserror 2.0.17",
@@ -5114,7 +5129,7 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]] [[package]]
name = "xml_derive" name = "xml_derive"
version = "0.10.5" version = "0.11.2"
dependencies = [ dependencies = [
"darling 0.23.0", "darling 0.23.0",
"heck", "heck",

View File

@@ -2,7 +2,7 @@
members = ["crates/*"] members = ["crates/*"]
[workspace.package] [workspace.package]
version = "0.10.5" version = "0.11.2"
rust-version = "1.91" rust-version = "1.91"
edition = "2024" edition = "2024"
description = "A CalDAV server" description = "A CalDAV server"
@@ -149,6 +149,12 @@ ece = { version = "2.3", default-features = false, features = [
openssl = { version = "0.10", features = ["vendored"] } openssl = { version = "0.10", features = ["vendored"] }
async-std = { version = "1.13", features = ["attributes"] } async-std = { version = "1.13", features = ["attributes"] }
similar-asserts = "1.7" similar-asserts = "1.7"
insta = "1.44"
[dev-dependencies]
rstest.workspace = true
rustical_store_sqlite = { workspace = true, features = ["test"] }
insta.workspace = true
[dependencies] [dependencies]
rustical_store.workspace = true rustical_store.workspace = true

View File

@@ -9,5 +9,7 @@ accepted = [
"AGPL-3.0", "AGPL-3.0",
"GPL-3.0", "GPL-3.0",
"MPL-2.0", "MPL-2.0",
"AGPL-3.0-or-later",
"GPL-3.0-or-later",
] ]
workarounds = ["ring", "chrono", "rustls"] workarounds = ["ring", "chrono", "rustls"]

View File

@@ -13,6 +13,7 @@ rustical_store_sqlite = { workspace = true, features = ["test"] }
rstest.workspace = true rstest.workspace = true
async-std.workspace = true async-std.workspace = true
serde_json.workspace = true serde_json.workspace = true
insta.workspace = true
[dependencies] [dependencies]
axum.workspace = true axum.workspace = true

View File

@@ -16,6 +16,7 @@ pub struct CompFilterElement {
pub(crate) time_range: Option<TimeRangeElement>, pub(crate) time_range: Option<TimeRangeElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)] #[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)]
pub(crate) prop_filter: Vec<PropFilterElement>, pub(crate) prop_filter: Vec<PropFilterElement>,
#[allow(clippy::use_self)]
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)] #[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)]
pub(crate) comp_filter: Vec<CompFilterElement>, pub(crate) comp_filter: Vec<CompFilterElement>,

View File

@@ -6,7 +6,7 @@ use rustical_store::auth::PrincipalType;
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize}; use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
use strum_macros::VariantArray; use strum_macros::VariantArray;
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName, Debug)]
#[xml(unit_variants_ident = "PrincipalPropName")] #[xml(unit_variants_ident = "PrincipalPropName")]
pub enum PrincipalProp { pub enum PrincipalProp {
// Scheduling Extensions to CalDAV (RFC 6638) // Scheduling Extensions to CalDAV (RFC 6638)
@@ -34,17 +34,17 @@ pub enum PrincipalProp {
CalendarHomeSet(CalendarHomeSet), CalendarHomeSet(CalendarHomeSet),
} }
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, Debug)]
pub struct CalendarHomeSet(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>); pub struct CalendarHomeSet(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName, Debug)]
#[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)] #[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)]
pub enum PrincipalPropWrapper { pub enum PrincipalPropWrapper {
Principal(PrincipalProp), Principal(PrincipalProp),
Common(CommonPropertiesProp), Common(CommonPropertiesProp),
} }
#[derive(XmlSerialize, PartialEq, Eq, Clone, VariantArray)] #[derive(XmlSerialize, PartialEq, Eq, Debug, Clone, VariantArray)]
pub enum ReportMethod { pub enum ReportMethod {
// We don't actually support principal-match // We don't actually support principal-match
#[xml(ns = "rustical_dav::namespace::NS_DAV")] #[xml(ns = "rustical_dav::namespace::NS_DAV")]

View File

@@ -0,0 +1,136 @@
---
source: crates/caldav/src/principal/tests.rs
expression: response
---
ResponseElement {
href: "/caldav/principal/user/",
status: None,
propstat: [
Normal(
PropstatElement {
prop: PropTagWrapper(
[
Principal(
CalendarUserType(
Individual,
),
),
Principal(
CalendarUserAddressSet(
HrefElement {
href: "/caldav/principal/user/",
},
),
),
Principal(
PrincipalUrl(
HrefElement {
href: "/caldav/principal/user/",
},
),
),
Principal(
GroupMembership(
GroupMembership(
[
HrefElement {
href: "/caldav/principal/group/",
},
],
),
),
),
Principal(
GroupMemberSet(
GroupMemberSet(
[],
),
),
),
Principal(
AlternateUriSet,
),
Principal(
SupportedReportSet(
SupportedReportSet {
supported_report: [
ReportWrapper {
report: PrincipalMatch,
},
],
},
),
),
Principal(
CalendarHomeSet(
CalendarHomeSet(
[
HrefElement {
href: "/caldav/principal/group/",
},
HrefElement {
href: "/caldav/principal/user/",
},
],
),
),
),
Common(
Resourcetype(
Resourcetype(
[
ResourcetypeInner(
Some(
Namespace("DAV:"),
),
"collection",
),
ResourcetypeInner(
Some(
Namespace("DAV:"),
),
"principal",
),
],
),
),
),
Common(
Displayname(
Some(
"user",
),
),
),
Common(
CurrentUserPrincipal(
HrefElement {
href: "/caldav/principal/user/",
},
),
),
Common(
CurrentUserPrivilegeSet(
UserPrivilegeSet {
privileges: {
All,
},
},
),
),
Common(
Owner(
Some(
HrefElement {
href: "/caldav/principal/user/",
},
),
),
),
],
),
status: 200,
},
),
],
}

View File

@@ -0,0 +1,53 @@
---
source: crates/caldav/src/principal/tests.rs
expression: response.serialize_to_string().unwrap()
---
<?xml version="1.0" encoding="utf-8"?>
<response xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:PUSH="https://bitfire.at/webdav-push">
<href>/caldav/principal/user/</href>
<propstat>
<prop>
<CAL:calendar-user-type>INDIVIDUAL</CAL:calendar-user-type>
<CAL:calendar-user-address-set>
<href>/caldav/principal/user/</href>
</CAL:calendar-user-address-set>
<principal-URL>
<href>/caldav/principal/user/</href>
</principal-URL>
<group-membership>
<href>/caldav/principal/group/</href>
</group-membership>
<group-member-set>
</group-member-set>
<alternate-URI-set/>
<supported-report-set>
<supported-report>
<report>
<principal-match/>
</report>
</supported-report>
</supported-report-set>
<CAL:calendar-home-set>
<href>/caldav/principal/group/</href>
<href>/caldav/principal/user/</href>
</CAL:calendar-home-set>
<resourcetype>
<collection/>
<principal/>
</resourcetype>
<displayname>user</displayname>
<current-user-principal>
<href>/caldav/principal/user/</href>
</current-user-principal>
<current-user-privilege-set>
<privilege>
<all/>
</privilege>
</current-user-privilege-set>
<owner>
<href>/caldav/principal/user/</href>
</owner>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>

View File

@@ -0,0 +1,8 @@
---
source: crates/caldav/src/principal/tests.rs
expression: propfind
---
PropfindElement {
prop: Allprop,
include: None,
}

View File

@@ -1,5 +1,3 @@
use std::sync::Arc;
use crate::{ use crate::{
CalDavPrincipalUri, CalDavPrincipalUri,
principal::{PrincipalResource, PrincipalResourceService}, principal::{PrincipalResource, PrincipalResourceService},
@@ -14,6 +12,7 @@ use rustical_store_sqlite::{
tests::{get_test_calendar_store, get_test_principal_store, get_test_subscription_store}, tests::{get_test_calendar_store, get_test_principal_store, get_test_subscription_store},
}; };
use rustical_xml::XmlSerializeRoot; use rustical_xml::XmlSerializeRoot;
use std::sync::Arc;
#[rstest] #[rstest]
#[tokio::test] #[tokio::test]
@@ -64,6 +63,8 @@ async fn test_propfind() {
) )
.unwrap(); .unwrap();
insta::assert_debug_snapshot!(propfind);
let principal = Principal { let principal = Principal {
id: "user".to_string(), id: "user".to_string(),
displayname: None, displayname: None,
@@ -88,5 +89,6 @@ async fn test_propfind() {
) )
.unwrap(); .unwrap();
let _output = response.serialize_to_string().unwrap(); insta::assert_debug_snapshot!(response);
insta::assert_snapshot!(response.serialize_to_string().unwrap());
} }

View File

@@ -35,3 +35,6 @@ percent-encoding.workspace = true
ical.workspace = true ical.workspace = true
strum.workspace = true strum.workspace = true
strum_macros.workspace = true strum_macros.workspace = true
[dev-dependencies]
insta.workspace = true

View File

@@ -3,3 +3,5 @@ pub mod prop;
pub mod resource; pub mod resource;
mod service; mod service;
pub use service::*; pub use service::*;
#[cfg(test)]
pub mod tests;

View File

@@ -6,7 +6,7 @@ use rustical_dav_push::DavPushExtensionProp;
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize}; use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
use strum_macros::VariantArray; use strum_macros::VariantArray;
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName, Debug)]
#[xml(unit_variants_ident = "AddressbookPropName")] #[xml(unit_variants_ident = "AddressbookPropName")]
pub enum AddressbookProp { pub enum AddressbookProp {
// CardDAV (RFC 6352) // CardDAV (RFC 6352)
@@ -20,7 +20,7 @@ pub enum AddressbookProp {
MaxResourceSize(i64), MaxResourceSize(i64),
} }
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName, Debug)]
#[xml(unit_variants_ident = "AddressbookPropWrapperName", untagged)] #[xml(unit_variants_ident = "AddressbookPropWrapperName", untagged)]
pub enum AddressbookPropWrapper { pub enum AddressbookPropWrapper {
Addressbook(AddressbookProp), Addressbook(AddressbookProp),

View File

@@ -0,0 +1,151 @@
---
source: crates/carddav/src/addressbook/tests.rs
expression: response
---
ResponseElement {
href: "/carddav/principal/user/yeet/",
status: None,
propstat: [
Normal(
PropstatElement {
prop: PropTagWrapper(
[
Addressbook(
AddressbookDescription(
None,
),
),
Addressbook(
SupportedAddressData(
SupportedAddressData {
address_data_type: [
AddressDataType {
content_type: "text/vcard",
version: "3.0",
},
AddressDataType {
content_type: "text/vcard",
version: "4.0",
},
],
},
),
),
Addressbook(
SupportedReportSet(
SupportedReportSet {
supported_report: [
ReportWrapper {
report: AddressbookMultiget,
},
ReportWrapper {
report: SyncCollection,
},
],
},
),
),
Addressbook(
MaxResourceSize(
10000000,
),
),
SyncToken(
SyncToken(
"github.com/lennart-k/rustical/ns/0",
),
),
SyncToken(
Getctag(
"github.com/lennart-k/rustical/ns/0",
),
),
DavPush(
Transports(
Transports {
transports: [
WebPush,
],
},
),
),
DavPush(
Topic(
"asdasd",
),
),
DavPush(
SupportedTriggers(
SupportedTriggers(
[
ContentUpdate(
ContentUpdate(
One,
),
),
PropertyUpdate(
PropertyUpdate(
One,
),
),
],
),
),
),
Common(
Resourcetype(
Resourcetype(
[
ResourcetypeInner(
Some(
Namespace("DAV:"),
),
"collection",
),
ResourcetypeInner(
Some(
Namespace("urn:ietf:params:xml:ns:carddav"),
),
"addressbook",
),
],
),
),
),
Common(
Displayname(
None,
),
),
Common(
CurrentUserPrincipal(
HrefElement {
href: "/carddav/principal/user/",
},
),
),
Common(
CurrentUserPrivilegeSet(
UserPrivilegeSet {
privileges: {
All,
},
},
),
),
Common(
Owner(
Some(
HrefElement {
href: "/carddav/principal/user/",
},
),
),
),
],
),
status: 200,
},
),
],
}

View File

@@ -0,0 +1,59 @@
---
source: crates/carddav/src/addressbook/tests.rs
expression: response.serialize_to_string().unwrap()
---
<?xml version="1.0" encoding="utf-8"?>
<response xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:PUSH="https://bitfire.at/webdav-push">
<href>/carddav/principal/user/yeet/</href>
<propstat>
<prop>
<CARD:supported-address-data>
<CARD:address-data-type content-type="text/vcard" version="3.0"/>
<CARD:address-data-type content-type="text/vcard" version="4.0"/>
</CARD:supported-address-data>
<supported-report-set>
<supported-report>
<report>
<CARD:addressbook-multiget/>
</report>
</supported-report>
<supported-report>
<report>
<sync-collection/>
</report>
</supported-report>
</supported-report-set>
<max-resource-size>10000000</max-resource-size>
<sync-token>github.com/lennart-k/rustical/ns/0</sync-token>
<CS:getctag>github.com/lennart-k/rustical/ns/0</CS:getctag>
<PUSH:transports>
<PUSH:web-push/>
</PUSH:transports>
<PUSH:topic>asdasd</PUSH:topic>
<PUSH:supported-triggers>
<PUSH:content-update>
<depth>1</depth>
</PUSH:content-update>
<PUSH:property-update>
<depth>1</depth>
</PUSH:property-update>
</PUSH:supported-triggers>
<resourcetype>
<collection/>
<CARD:addressbook/>
</resourcetype>
<current-user-principal>
<href>/carddav/principal/user/</href>
</current-user-principal>
<current-user-privilege-set>
<privilege>
<all/>
</privilege>
</current-user-privilege-set>
<owner>
<href>/carddav/principal/user/</href>
</owner>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>

View File

@@ -0,0 +1,8 @@
---
source: crates/carddav/src/addressbook/tests.rs
expression: propfind
---
PropfindElement {
prop: Allprop,
include: None,
}

View File

@@ -0,0 +1,49 @@
use crate::{CardDavPrincipalUri, addressbook::resource::AddressbookResource};
use rustical_dav::resource::Resource;
use rustical_store::{Addressbook, auth::Principal};
use rustical_xml::XmlSerializeRoot;
#[test]
fn test_propfind() {
let propfind = AddressbookResource::parse_propfind(
r#"<?xml version="1.0" encoding="UTF-8"?><propfind xmlns="DAV:"><allprop/></propfind>"#,
)
.unwrap();
insta::assert_debug_snapshot!(propfind);
let principal = Principal {
id: "user".to_string(),
displayname: None,
principal_type: rustical_store::auth::PrincipalType::Individual,
password: None,
memberships: vec!["group".to_string()],
};
let addressbook = Addressbook {
id: "yeet".to_string(),
principal: "user".to_string(),
displayname: None,
description: None,
deleted_at: None,
synctoken: 0,
push_topic: "asdasd".to_string(),
};
let resource = AddressbookResource(addressbook.clone());
let response = resource
.propfind(
&format!(
"/carddav/principal/{}/{}",
addressbook.principal, addressbook.id
),
&propfind.prop,
propfind.include.as_ref(),
&CardDavPrincipalUri("/carddav"),
&principal,
)
.unwrap();
insta::assert_debug_snapshot!(response);
insta::assert_snapshot!(response.serialize_to_string().unwrap());
}

View File

@@ -1,6 +1,5 @@
use axum::response::IntoResponse; use axum::response::IntoResponse;
use http::StatusCode; use http::StatusCode;
use tracing::error;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {

View File

@@ -11,11 +11,13 @@ mod service;
pub use service::*; pub use service::*;
mod prop; mod prop;
pub use prop::*; pub use prop::*;
#[cfg(test)]
pub mod tests;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PrincipalResource { pub struct PrincipalResource {
principal: Principal, pub principal: Principal,
members: Vec<String>, pub members: Vec<String>,
} }
impl ResourceName for PrincipalResource { impl ResourceName for PrincipalResource {

View File

@@ -4,7 +4,7 @@ use rustical_dav::{
}; };
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize}; use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName, Debug)]
#[xml(unit_variants_ident = "PrincipalPropName")] #[xml(unit_variants_ident = "PrincipalPropName")]
pub enum PrincipalProp { pub enum PrincipalProp {
// WebDAV Access Control (RFC 3744) // WebDAV Access Control (RFC 3744)
@@ -27,10 +27,10 @@ pub enum PrincipalProp {
PrincipalAddress(Option<HrefElement>), PrincipalAddress(Option<HrefElement>),
} }
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, Debug)]
pub struct AddressbookHomeSet(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>); pub struct AddressbookHomeSet(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName, Debug)]
#[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)] #[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)]
pub enum PrincipalPropWrapper { pub enum PrincipalPropWrapper {
Principal(PrincipalProp), Principal(PrincipalProp),

View File

@@ -0,0 +1,125 @@
---
source: crates/carddav/src/principal/tests.rs
expression: response
---
ResponseElement {
href: "/carddav/principal/user/",
status: None,
propstat: [
Normal(
PropstatElement {
prop: PropTagWrapper(
[
Principal(
PrincipalUrl(
HrefElement {
href: "/carddav/principal/user/",
},
),
),
Principal(
GroupMembership(
GroupMembership(
[
HrefElement {
href: "/carddav/principal/group/",
},
],
),
),
),
Principal(
GroupMemberSet(
GroupMemberSet(
[],
),
),
),
Principal(
AlternateUriSet,
),
Principal(
PrincipalCollectionSet(
HrefElement {
href: "/carddav/principal/",
},
),
),
Principal(
AddressbookHomeSet(
AddressbookHomeSet(
[
HrefElement {
href: "/carddav/principal/group/",
},
HrefElement {
href: "/carddav/principal/user/",
},
],
),
),
),
Principal(
PrincipalAddress(
None,
),
),
Common(
Resourcetype(
Resourcetype(
[
ResourcetypeInner(
Some(
Namespace("DAV:"),
),
"collection",
),
ResourcetypeInner(
Some(
Namespace("DAV:"),
),
"principal",
),
],
),
),
),
Common(
Displayname(
Some(
"user",
),
),
),
Common(
CurrentUserPrincipal(
HrefElement {
href: "/carddav/principal/user/",
},
),
),
Common(
CurrentUserPrivilegeSet(
UserPrivilegeSet {
privileges: {
All,
},
},
),
),
Common(
Owner(
Some(
HrefElement {
href: "/carddav/principal/user/",
},
),
),
),
],
),
status: 200,
},
),
],
}

View File

@@ -0,0 +1,45 @@
---
source: crates/carddav/src/principal/tests.rs
expression: response.serialize_to_string().unwrap()
---
<?xml version="1.0" encoding="utf-8"?>
<response xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:PUSH="https://bitfire.at/webdav-push">
<href>/carddav/principal/user/</href>
<propstat>
<prop>
<principal-URL>
<href>/carddav/principal/user/</href>
</principal-URL>
<group-membership>
<href>/carddav/principal/group/</href>
</group-membership>
<group-member-set>
</group-member-set>
<alternate-URI-set/>
<principal-collection-set>
<href>/carddav/principal/</href>
</principal-collection-set>
<CARD:addressbook-home-set>
<href>/carddav/principal/group/</href>
<href>/carddav/principal/user/</href>
</CARD:addressbook-home-set>
<resourcetype>
<collection/>
<principal/>
</resourcetype>
<displayname>user</displayname>
<current-user-principal>
<href>/carddav/principal/user/</href>
</current-user-principal>
<current-user-privilege-set>
<privilege>
<all/>
</privilege>
</current-user-privilege-set>
<owner>
<href>/carddav/principal/user/</href>
</owner>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>

View File

@@ -0,0 +1,8 @@
---
source: crates/carddav/src/principal/tests.rs
expression: propfind
---
PropfindElement {
prop: Allprop,
include: None,
}

View File

@@ -0,0 +1,41 @@
use rustical_dav::resource::Resource;
use rustical_store::auth::Principal;
use rustical_xml::XmlSerializeRoot;
use crate::{CardDavPrincipalUri, principal::PrincipalResource};
#[test]
fn test_propfind() {
let propfind = PrincipalResource::parse_propfind(
r#"<?xml version="1.0" encoding="UTF-8"?><propfind xmlns="DAV:"><allprop/></propfind>"#,
)
.unwrap();
insta::assert_debug_snapshot!(propfind);
let principal = Principal {
id: "user".to_string(),
displayname: None,
principal_type: rustical_store::auth::PrincipalType::Individual,
password: None,
memberships: vec!["group".to_string()],
};
let resource = PrincipalResource {
principal: principal.clone(),
members: vec![],
};
let response = resource
.propfind(
&format!("/carddav/principal/{}", principal.id),
&propfind.prop,
propfind.include.as_ref(),
&CardDavPrincipalUri("/carddav"),
&principal,
)
.unwrap();
insta::assert_debug_snapshot!(response);
insta::assert_snapshot!(response.serialize_to_string().unwrap());
}

View File

@@ -6,7 +6,7 @@ use crate::{
}; };
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize}; use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, PropName, EnumVariants)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Debug, Clone, PropName, EnumVariants)]
#[xml(unit_variants_ident = "CommonPropertiesPropName")] #[xml(unit_variants_ident = "CommonPropertiesPropName")]
pub enum CommonPropertiesProp { pub enum CommonPropertiesProp {
// WebDAV (RFC 2518) // WebDAV (RFC 2518)

View File

@@ -1,6 +1,6 @@
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize}; use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, PropName, EnumVariants)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, PropName, EnumVariants, Debug)]
#[xml(unit_variants_ident = "SyncTokenExtensionPropName")] #[xml(unit_variants_ident = "SyncTokenExtensionPropName")]
pub enum SyncTokenExtensionProp { pub enum SyncTokenExtensionProp {
// Collection Synchronization (RFC 6578) // Collection Synchronization (RFC 6578)

View File

@@ -1,8 +1,8 @@
use crate::xml::HrefElement; use crate::xml::HrefElement;
use rustical_xml::{XmlDeserialize, XmlSerialize}; use rustical_xml::{XmlDeserialize, XmlSerialize};
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, Debug)]
pub struct GroupMembership(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>); pub struct GroupMembership(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, Debug)]
pub struct GroupMemberSet(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>); pub struct GroupMemberSet(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>);

View File

@@ -3,9 +3,9 @@ use headers::{CacheControl, ContentType, HeaderMapExt};
use http::StatusCode; use http::StatusCode;
use quick_xml::name::Namespace; use quick_xml::name::Namespace;
use rustical_xml::{XmlRootTag, XmlSerialize, XmlSerializeRoot}; use rustical_xml::{XmlRootTag, XmlSerialize, XmlSerializeRoot};
use std::collections::HashMap; use std::{collections::HashMap, fmt::Debug};
#[derive(XmlSerialize)] #[derive(XmlSerialize, Debug)]
pub struct PropTagWrapper<T: XmlSerialize>(#[xml(flatten, ty = "untagged")] pub Vec<T>); pub struct PropTagWrapper<T: XmlSerialize>(#[xml(flatten, ty = "untagged")] pub Vec<T>);
// RFC 2518 // RFC 2518
@@ -30,7 +30,7 @@ fn xml_serialize_status(
XmlSerialize::serialize(&format!("HTTP/1.1 {status}"), ns, tag, namespaces, writer) XmlSerialize::serialize(&format!("HTTP/1.1 {status}"), ns, tag, namespaces, writer)
} }
#[derive(XmlSerialize)] #[derive(XmlSerialize, Debug)]
#[xml(untagged)] #[xml(untagged)]
pub enum PropstatWrapper<T: XmlSerialize> { pub enum PropstatWrapper<T: XmlSerialize> {
Normal(PropstatElement<PropTagWrapper<T>>), Normal(PropstatElement<PropTagWrapper<T>>),
@@ -40,7 +40,7 @@ pub enum PropstatWrapper<T: XmlSerialize> {
// RFC 2518 // RFC 2518
// <!ELEMENT response (href, ((href*, status)|(propstat+)), // <!ELEMENT response (href, ((href*, status)|(propstat+)),
// responsedescription?) > // responsedescription?) >
#[derive(XmlSerialize, XmlRootTag)] #[derive(XmlSerialize, XmlRootTag, Debug)]
#[xml(ns = "crate::namespace::NS_DAV", root = "response")] #[xml(ns = "crate::namespace::NS_DAV", root = "response")]
#[xml(ns_prefix( #[xml(ns_prefix(
crate::namespace::NS_DAV = "", crate::namespace::NS_DAV = "",

View File

@@ -2,7 +2,7 @@ use crate::{ContentUpdate, PropertyUpdate, SupportedTriggers, Transports, Trigge
use rustical_dav::header::Depth; use rustical_dav::header::Depth;
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize}; use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, PropName, EnumVariants)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, PropName, EnumVariants, Debug)]
#[xml(unit_variants_ident = "DavPushExtensionPropName")] #[xml(unit_variants_ident = "DavPushExtensionPropName")]
pub enum DavPushExtensionProp { pub enum DavPushExtensionProp {
// WebDav Push // WebDav Push

View File

@@ -22,7 +22,7 @@ impl Default for Transports {
} }
} }
#[derive(XmlSerialize, XmlDeserialize, PartialEq, Eq, Clone)] #[derive(XmlSerialize, XmlDeserialize, PartialEq, Eq, Clone, Debug)]
pub struct SupportedTriggers(#[xml(flatten, ty = "untagged")] pub Vec<Trigger>); pub struct SupportedTriggers(#[xml(flatten, ty = "untagged")] pub Vec<Trigger>);
#[derive(XmlSerialize, XmlDeserialize, PartialEq, Eq, Debug, Clone)] #[derive(XmlSerialize, XmlDeserialize, PartialEq, Eq, Debug, Clone)]

View File

@@ -36,27 +36,27 @@ export class CreateAddressbookForm extends LitElement {
<form @submit=${this.submit} ${ref(this.form)}> <form @submit=${this.submit} ${ref(this.form)}>
<label> <label>
principal (for group addressbooks) principal (for group addressbooks)
<select name="principal" value=${this.user} @change=${e => this.principal = e.target.value}> <select .value=${this.user} @change=${e => this.principal = e.target.value}>
<option value=${this.user}>${this.user}</option> <option .value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map(membership => html` ${window.rusticalUser.memberships.map(membership => html`
<option value=${membership}>${membership}</option> <option .value=${membership}>${membership}</option>
`)} `)}
</select> </select>
</label> </label>
<br> <br>
<label> <label>
id id
<input type="text" name="id" value=${this.addr_id} @change=${e => this.addr_id = e.target.value} /> <input type="text" .value=${this.addr_id} @change=${e => this.addr_id = e.target.value} />
</label> </label>
<br> <br>
<label> <label>
Displayname Displayname
<input type="text" name="displayname" value=${this.displayname} @change=${e => this.displayname = e.target.value} /> <input type="text" .value=${this.displayname} @change=${e => this.displayname = e.target.value} />
</label> </label>
<br> <br>
<label> <label>
Description Description
<input type="text" name="description" @change=${e => this.description = e.target.value} /> <input type="text" .value=${this.description} @change=${e => this.description = e.target.value} />
</label> </label>
<br> <br>
<button type="submit">Create</button> <button type="submit">Create</button>

View File

@@ -34,17 +34,17 @@ export class CreateCalendarForm extends LitElement {
<form @submit=${this.submit} ${ref(this.form)}> <form @submit=${this.submit} ${ref(this.form)}>
<label> <label>
Displayname Displayname
<input type="text" name="displayname" value=${this.displayname} @change=${e => this.displayname = e.target.value} /> <input type="text" .value=${this.displayname} required @change=${e => this.displayname = e.target.value} />
</label> </label>
<br> <br>
<label> <label>
Description Description
<input type="text" name="description" @change=${e => this.description = e.target.value} /> <input type="text" .value=${this.description} @change=${e => this.description = e.target.value} />
</label> </label>
<br> <br>
<label> <label>
Color Color
<input type="color" name="color" @change=${e => this.color = e.target.value} /> <input type="color" .value=${this.color} @change=${e => this.color = e.target.value} />
</label> </label>
<br> <br>
<button type="submit">Create</button> <button type="submit">Create</button>

View File

@@ -1,16 +1,30 @@
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js'; import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { escapeXml } from "."; import { escapeXml, SVG_ICON_CALENDAR, SVG_ICON_INTERNET } from ".";
import { getTimezones } from "./timezones.ts"; import { getTimezones } from "./timezones.ts";
@customElement("create-calendar-form") @customElement("create-calendar-form")
export class CreateCalendarForm extends LitElement { export class CreateCalendarForm extends LitElement {
constructor() { constructor() {
super() super()
this.resetForm()
this.fetchTimezones() this.fetchTimezones()
} }
resetForm() {
this.form.value?.reset()
this.principal = this.user
this.cal_id = self.crypto.randomUUID()
this.displayname = ''
this.description = ''
this.timezone_id = ''
this.color = ''
this.isSubscription = false
this.subscriptionUrl = null
this.components = new Set(["VEVENT", "VTODO"])
}
async fetchTimezones() { async fetchTimezones() {
this.timezones = await getTimezones() this.timezones = await getTimezones()
} }
@@ -22,23 +36,23 @@ export class CreateCalendarForm extends LitElement {
@property() @property()
user: string = '' user: string = ''
@property() @property()
principal: string = '' principal: string
@property() @property()
cal_id: string = self.crypto.randomUUID() cal_id: string
@property() @property()
displayname: string = '' displayname: string
@property() @property()
description: string = '' description: string
@property() @property()
timezone_id: string = '' timezone_id: string
@property() @property()
color: string = '' color: string
@property() @property()
isSubscription: boolean = false isSubscription: boolean
@property() @property()
subscriptionUrl: string = '' subscriptionUrl: string
@property() @property()
components: Set<"VEVENT" | "VTODO" | "VJOURNAL"> = new Set() components: Set<"VEVENT" | "VTODO" | "VJOURNAL">
dialog: Ref<HTMLDialogElement> = createRef() dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef() form: Ref<HTMLFormElement> = createRef()
@@ -47,13 +61,13 @@ export class CreateCalendarForm extends LitElement {
override render() { override render() {
return html` return html`
<button @click=${() => this.dialog.value.showModal()}>Create calendar</button> <button @click=${e => this.dialog.value.showModal()}>Create calendar</button>
<dialog ${ref(this.dialog)}> <dialog ${ref(this.dialog)} @close=${e => this.resetForm()}>
<h3>Create calendar</h3> <h3>Create calendar</h3>
<form @submit=${this.submit} ${ref(this.form)}> <form @submit=${this.submit} ${ref(this.form)}>
<label> <label>
principal (for group calendars) principal (for group calendars)
<select name="principal" value=${this.user} @change=${e => this.principal = e.target.value}> <select required value=${this.user} @change=${e => this.principal = e.target.value}>
<option value=${this.user}>${this.user}</option> <option value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map(membership => html` ${window.rusticalUser.memberships.map(membership => html`
<option value=${membership}>${membership}</option> <option value=${membership}>${membership}</option>
@@ -63,17 +77,17 @@ export class CreateCalendarForm extends LitElement {
<br> <br>
<label> <label>
id id
<input type="text" name="id" value=${this.cal_id} @change=${e => this.cal_id = e.target.value} /> <input type="text" required .value=${this.cal_id} @change=${e => this.cal_id = e.target.value} />
</label> </label>
<br> <br>
<label> <label>
Displayname Displayname
<input type="text" name="displayname" value=${this.displayname} @change=${e => this.displayname = e.target.value} /> <input type="text" required .value=${this.displayname} @change=${e => this.displayname = e.target.value} />
</label> </label>
<br> <br>
<label> <label>
Timezone (optional) Timezone (optional)
<select name="timezone" .value=${this.timezone_id} @change=${e => this.timezone_id = e.target.value}> <select .value=${this.timezone_id} @change=${e => this.timezone_id = e.target.value}>
<option value="">No timezone</option> <option value="">No timezone</option>
${this.timezones.map(timezone => html` ${this.timezones.map(timezone => html`
<option value=${timezone} ?selected=${timezone === this.timezone_id}>${timezone}</option> <option value=${timezone} ?selected=${timezone === this.timezone_id}>${timezone}</option>
@@ -83,45 +97,57 @@ export class CreateCalendarForm extends LitElement {
<br> <br>
<label> <label>
Description Description
<input type="text" name="description" @change=${e => this.description = e.target.value} /> <input type="text" .value=${this.description} @change=${e => this.description = e.target.value} />
</label> </label>
<br> <br>
<label> <label>
Color Color
<input type="color" name="color" @change=${e => this.color = e.target.value} /> <input type="color" .value=${this.color} @change=${e => this.color = e.target.value} />
</label> </label>
<br> <br>
<br> <br>
<label> <label>Type</label>
Calendar is subscription to external calendar <div class="tab-radio">
<input type="checkbox" name="is_subscription" @change=${e => this.isSubscription = e.target.checked} /> <label>
</label> <input type="radio" name="type" .checked=${!this.isSubscription} @change=${e => this.isSubscription = false}></input>
${SVG_ICON_CALENDAR}
Calendar
</label>
<label>
<input type="radio" name="type" .checked=${this.isSubscription} @change=${e => this.isSubscription = true}></input>
${SVG_ICON_INTERNET}
webCal Subscription
</label>
</div>
<br> <br>
${this.isSubscription ? html` ${this.isSubscription ? html`
<label> <label>
Subscription URL Subscription URL
<input type="text" name="subscription_url" @change=${e => this.subscriptionUrl = e.target.value} /> <input type="text" pattern="https://.*" .required=${this.isSubscription} .value=${this.subscriptionUrl} @change=${e => this.subscriptionUrl = e.target.value} />
</label> </label>
<br> <br>
<br>
`: html``} `: html``}
<br>
${["VEVENT", "VTODO", "VJOURNAL"].map(comp => html` <label>Components</label>
<label> <div>
Support ${comp} ${["VEVENT", "VTODO", "VJOURNAL"].map(comp => html`
<input type="checkbox" value=${comp} @change=${e => e.target.checked ? this.components.add(e.target.value) : this.components.delete(e.target.value)} /> <label>
</label> Support ${comp}
<br> <input type="checkbox" .value=${comp} @change=${e => e.target.checked ? this.components.add(e.target.value) : this.components.delete(e.target.value)} .checked=${this.components.has(comp)} />
`)} </label>
<br>
`)}
</div>
<br> <br>
<button type="submit">Create</button> <button type="submit">Create</button>
<button type="submit" @click=${event => { event.preventDefault(); this.dialog.value.close(); this.form.value.reset() }} class="cancel">Cancel</button> <button type="submit" @click=${event => { event.preventDefault(); this.dialog.value.close();}} class="cancel">Cancel</button>
</form> </form>
</dialog> </dialog>
` `
} }
async submit(e: SubmitEvent) { async submit(e: SubmitEvent) {
console.log(this.displayname)
e.preventDefault() e.preventDefault()
if (!this.cal_id) { if (!this.cal_id) {
alert("Empty id") alert("Empty id")
@@ -135,6 +161,10 @@ export class CreateCalendarForm extends LitElement {
alert("No calendar components selected") alert("No calendar components selected")
return return
} }
if (this.isSubscription && !this.subscriptionUrl) {
alert("Invalid subscription url")
return
}
let response = await fetch(`/caldav/principal/${this.principal || this.user}/${this.cal_id}`, { let response = await fetch(`/caldav/principal/${this.principal || this.user}/${this.cal_id}`, {
method: 'MKCOL', method: 'MKCOL',

View File

@@ -34,12 +34,12 @@ export class EditAddressbookForm extends LitElement {
<form @submit=${this.submit} ${ref(this.form)}> <form @submit=${this.submit} ${ref(this.form)}>
<label> <label>
Displayname Displayname
<input type="text" name="displayname" .value=${this.displayname} @change=${e => this.displayname = e.target.value} /> <input type="text" .value=${this.displayname} @change=${e => this.displayname = e.target.value} />
</label> </label>
<br> <br>
<label> <label>
Description Description
<input type="text" name="description" .value=${this.description} @change=${e => this.description = e.target.value} /> <input type="text" .value=${this.description} @change=${e => this.description = e.target.value} />
</label> </label>
<br> <br>
<button type="submit">Submit</button> <button type="submit">Submit</button>

View File

@@ -53,12 +53,12 @@ export class EditCalendarForm extends LitElement {
<form @submit=${this.submit} ${ref(this.form)}> <form @submit=${this.submit} ${ref(this.form)}>
<label> <label>
Displayname Displayname
<input type="text" name="displayname" .value=${this.displayname} @change=${e => this.displayname = e.target.value} /> <input type="text" required .value=${this.displayname} @change=${e => this.displayname = e.target.value} />
</label> </label>
<br> <br>
<label> <label>
Timezone (optional) Timezone (optional)
<select name="timezone" .value=${this.timezone_id} @change=${e => this.timezone_id = e.target.value}> <select .value=${this.timezone_id} @change=${e => this.timezone_id = e.target.value}>
<option value="">No timezone</option> <option value="">No timezone</option>
${this.timezones.map(timezone => html` ${this.timezones.map(timezone => html`
<option value=${timezone} ?selected=${timezone === this.timezone_id}>${timezone}</option> <option value=${timezone} ?selected=${timezone === this.timezone_id}>${timezone}</option>
@@ -68,18 +68,18 @@ export class EditCalendarForm extends LitElement {
<br> <br>
<label> <label>
Description Description
<input type="text" name="description" .value=${this.description} @change=${e => this.description = e.target.value} /> <input type="text" .value=${this.description} @change=${e => this.description = e.target.value} />
</label> </label>
<br> <br>
<label> <label>
Color Color
<input type="color" name="color" .value=${this.color} @change=${e => this.color = e.target.value} /> <input type="color" .value=${this.color} @change=${e => this.color = e.target.value} />
</label> </label>
<br> <br>
${["VEVENT", "VTODO", "VJOURNAL"].map(comp => html` ${["VEVENT", "VTODO", "VJOURNAL"].map(comp => html`
<label> <label>
Support ${comp} Support ${comp}
<input type="checkbox" value=${comp} ?checked=${this.components.has(comp)} @change=${e => e.target.checked ? this.components.add(e.target.value) : this.components.delete(e.target.value)} /> <input type="checkbox" .value=${comp} ?checked=${this.components.has(comp)} @change=${e => e.target.checked ? this.components.add(e.target.value) : this.components.delete(e.target.value)} />
</label> </label>
<br> <br>
`)} `)}

View File

@@ -32,23 +32,25 @@ export class ImportAddressbookForm extends LitElement {
<form @submit=${this.submit} ${ref(this.form)}> <form @submit=${this.submit} ${ref(this.form)}>
<label> <label>
principal (for group addressbook) principal (for group addressbook)
<select name="principal" value=${this.user} @change=${e => this.principal = e.target.value}> <select name="principal" required .value=${this.user} @change=${e => this.principal = e.target.value}>
<option value=${this.user}>${this.user}</option> <option .value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map(membership => html` ${window.rusticalUser.memberships.map(membership => html`
<option value=${membership}>${membership}</option> <option .value=${membership}>${membership}</option>
`)} `)}
</select> </select>
</label> </label>
<br> <br>
<label> <label>
id id
<input type="text" name="id" value=${this.addressbook_id} @change=${e => this.addressbook_id = e.target.value} /> <input type="text" required .value=${this.addressbook_id} @change=${e => this.addressbook_id = e.target.value} />
</label> </label>
<br> <br>
<label> <label>
file file
<input type="file" accept="text/vcard" name="file" @change=${e => this.file = e.target.files[0]} /> <input type="file" accept="text/vcard" required @change=${e => this.file = e.target.files[0]} />
</label> </label>
<br>
<br>
<button type="submit">Import</button> <button type="submit">Import</button>
<button type="submit" @click=${event => { event.preventDefault(); this.dialog.value.close(); this.form.value.reset() }} class="cancel">Cancel</button> <button type="submit" @click=${event => { event.preventDefault(); this.dialog.value.close(); this.form.value.reset() }} class="cancel">Cancel</button>
</form> </form>

View File

@@ -32,23 +32,25 @@ export class ImportCalendarForm extends LitElement {
<form @submit=${this.submit} ${ref(this.form)}> <form @submit=${this.submit} ${ref(this.form)}>
<label> <label>
principal (for group calendars) principal (for group calendars)
<select name="principal" value=${this.user} @change=${e => this.principal = e.target.value}> <select name="principal" required .value=${this.user} @change=${e => this.principal = e.target.value}>
<option value=${this.user}>${this.user}</option> <option .value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map(membership => html` ${window.rusticalUser.memberships.map(membership => html`
<option value=${membership}>${membership}</option> <option .value=${membership}>${membership}</option>
`)} `)}
</select> </select>
</label> </label>
<br> <br>
<label> <label>
id id
<input type="text" name="id" value=${this.cal_id} @change=${e => this.cal_id = e.target.value} /> <input type="text" required .value=${this.cal_id} @change=${e => this.cal_id = e.target.value} />
</label> </label>
<br> <br>
<label> <label>
file file
<input type="file" accept="text/calendar" name="file" @change=${e => this.file = e.target.files[0]} /> <input type="file" required accept="text/calendar" @change=${e => this.file = e.target.files[0]} />
</label> </label>
<br>
<br>
<button type="submit">Import</button> <button type="submit">Import</button>
<button type="submit" @click=${event => { event.preventDefault(); this.dialog.value.close(); this.form.value.reset() }} class="cancel">Cancel</button> <button type="submit" @click=${event => { event.preventDefault(); this.dialog.value.close(); this.form.value.reset() }} class="cancel">Cancel</button>
</form> </form>

View File

@@ -1,3 +1,5 @@
import {svg} from 'lit'
export function escapeXml(unsafe: string): string { export function escapeXml(unsafe: string): string {
return unsafe.replace(/&/g, '&amp;') return unsafe.replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
@@ -5,3 +7,23 @@ export function escapeXml(unsafe: string): string {
.replace(/"/g, '&quot;') .replace(/"/g, '&quot;')
.replace(/'/g, '&apos;') .replace(/'/g, '&apos;')
} }
export const SVG_ICON_CALENDAR = svg`<!-- Adapted from https://iconoir.com/ -->
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="icon">
<path d="M15 4V2M15 4V6M15 4H10.5M3 10V19C3 20.1046 3.89543 21 5 21H19C20.1046 21 21 20.1046 21 19V10H3Z" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M3 10V6C3 4.89543 3.89543 4 5 4H7" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M7 2V6" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M21 10V6C21 4.89543 20.1046 4 19 4H18.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
`
export const SVG_ICON_INTERNET = svg`<!-- Adapted from https://iconoir.com/ -->
<svg class="icon" width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M13 2.04932C13 2.04932 16 5.99994 16 11.9999" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M11 21.9506C11 21.9506 8 17.9999 8 11.9999C8 5.99994 11 2.04932 11 2.04932" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M2.62964 15.5H12" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M2.62964 8.5H21.3704" stroke-linecap="round" stroke-linejoin="round"></path>
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.8789 17.9174C22.3727 18.2211 22.3423 18.9604 21.8337 19.0181L19.2671 19.309L18.1159 21.6213C17.8878 22.0795 17.1827 21.8552 17.0661 21.2873L15.8108 15.1713C15.7123 14.6913 16.1437 14.3892 16.561 14.646L21.8789 17.9174Z"></path>
</svg>
`

View File

@@ -1,7 +1,7 @@
import { i, x } from "./lit-DkXrt_Iv.mjs"; import { i, x } from "./lit-DKg0et_P.mjs";
import { n as n$1, t } from "./property-B8WoKf1Y.mjs"; import { n as n$1, t } from "./property-C8WJQOrH.mjs";
import { e, n } from "./ref-BwbQvJBB.mjs"; import { e, n } from "./ref-BivNNNRN.mjs";
import { e as escapeXml } from "./index-_IB1wMbZ.mjs"; import { e as escapeXml } from "./index-fgowJCc1.mjs";
var __defProp = Object.defineProperty; var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => { var __decorateClass = (decorators, target, key, kind) => {
@@ -34,27 +34,27 @@ let CreateAddressbookForm = class extends i {
<form @submit=${this.submit} ${n(this.form)}> <form @submit=${this.submit} ${n(this.form)}>
<label> <label>
principal (for group addressbooks) principal (for group addressbooks)
<select name="principal" value=${this.user} @change=${(e2) => this.principal = e2.target.value}> <select .value=${this.user} @change=${(e2) => this.principal = e2.target.value}>
<option value=${this.user}>${this.user}</option> <option .value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map((membership) => x` ${window.rusticalUser.memberships.map((membership) => x`
<option value=${membership}>${membership}</option> <option .value=${membership}>${membership}</option>
`)} `)}
</select> </select>
</label> </label>
<br> <br>
<label> <label>
id id
<input type="text" name="id" value=${this.addr_id} @change=${(e2) => this.addr_id = e2.target.value} /> <input type="text" .value=${this.addr_id} @change=${(e2) => this.addr_id = e2.target.value} />
</label> </label>
<br> <br>
<label> <label>
Displayname Displayname
<input type="text" name="displayname" value=${this.displayname} @change=${(e2) => this.displayname = e2.target.value} /> <input type="text" .value=${this.displayname} @change=${(e2) => this.displayname = e2.target.value} />
</label> </label>
<br> <br>
<label> <label>
Description Description
<input type="text" name="description" @change=${(e2) => this.description = e2.target.value} /> <input type="text" .value=${this.description} @change=${(e2) => this.description = e2.target.value} />
</label> </label>
<br> <br>
<button type="submit">Create</button> <button type="submit">Create</button>

View File

@@ -1,7 +1,7 @@
import { i, x } from "./lit-DkXrt_Iv.mjs"; import { i, x } from "./lit-DKg0et_P.mjs";
import { n as n$1, t } from "./property-B8WoKf1Y.mjs"; import { n as n$1, t } from "./property-C8WJQOrH.mjs";
import { e, n } from "./ref-BwbQvJBB.mjs"; import { e, n } from "./ref-BivNNNRN.mjs";
import { e as escapeXml } from "./index-_IB1wMbZ.mjs"; import { e as escapeXml } from "./index-fgowJCc1.mjs";
var __defProp = Object.defineProperty; var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => { var __decorateClass = (decorators, target, key, kind) => {
@@ -35,17 +35,17 @@ let CreateCalendarForm = class extends i {
<form @submit=${this.submit} ${n(this.form)}> <form @submit=${this.submit} ${n(this.form)}>
<label> <label>
Displayname Displayname
<input type="text" name="displayname" value=${this.displayname} @change=${(e2) => this.displayname = e2.target.value} /> <input type="text" .value=${this.displayname} required @change=${(e2) => this.displayname = e2.target.value} />
</label> </label>
<br> <br>
<label> <label>
Description Description
<input type="text" name="description" @change=${(e2) => this.description = e2.target.value} /> <input type="text" .value=${this.description} @change=${(e2) => this.description = e2.target.value} />
</label> </label>
<br> <br>
<label> <label>
Color Color
<input type="color" name="color" @change=${(e2) => this.color = e2.target.value} /> <input type="color" .value=${this.color} @change=${(e2) => this.color = e2.target.value} />
</label> </label>
<br> <br>
<button type="submit">Create</button> <button type="submit">Create</button>

View File

@@ -1,7 +1,7 @@
import { i, x } from "./lit-DkXrt_Iv.mjs"; import { i, x } from "./lit-DKg0et_P.mjs";
import { n as n$1, t } from "./property-B8WoKf1Y.mjs"; import { n as n$1, t } from "./property-C8WJQOrH.mjs";
import { e, n } from "./ref-BwbQvJBB.mjs"; import { e, n } from "./ref-BivNNNRN.mjs";
import { e as escapeXml } from "./index-_IB1wMbZ.mjs"; import { S as SVG_ICON_CALENDAR, a as SVG_ICON_INTERNET, e as escapeXml } from "./index-fgowJCc1.mjs";
import { g as getTimezones } from "./timezones-B0vBBzCP.mjs"; import { g as getTimezones } from "./timezones-B0vBBzCP.mjs";
var __defProp = Object.defineProperty; var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -17,19 +17,23 @@ let CreateCalendarForm = class extends i {
constructor() { constructor() {
super(); super();
this.user = ""; this.user = "";
this.principal = ""; this.dialog = e();
this.form = e();
this.timezones = [];
this.resetForm();
this.fetchTimezones();
}
resetForm() {
this.form.value?.reset();
this.principal = this.user;
this.cal_id = self.crypto.randomUUID(); this.cal_id = self.crypto.randomUUID();
this.displayname = ""; this.displayname = "";
this.description = ""; this.description = "";
this.timezone_id = ""; this.timezone_id = "";
this.color = ""; this.color = "";
this.isSubscription = false; this.isSubscription = false;
this.subscriptionUrl = ""; this.subscriptionUrl = null;
this.components = /* @__PURE__ */ new Set(); this.components = /* @__PURE__ */ new Set(["VEVENT", "VTODO"]);
this.dialog = e();
this.form = e();
this.timezones = [];
this.fetchTimezones();
} }
async fetchTimezones() { async fetchTimezones() {
this.timezones = await getTimezones(); this.timezones = await getTimezones();
@@ -39,13 +43,13 @@ let CreateCalendarForm = class extends i {
} }
render() { render() {
return x` return x`
<button @click=${() => this.dialog.value.showModal()}>Create calendar</button> <button @click=${(e2) => this.dialog.value.showModal()}>Create calendar</button>
<dialog ${n(this.dialog)}> <dialog ${n(this.dialog)} @close=${(e2) => this.resetForm()}>
<h3>Create calendar</h3> <h3>Create calendar</h3>
<form @submit=${this.submit} ${n(this.form)}> <form @submit=${this.submit} ${n(this.form)}>
<label> <label>
principal (for group calendars) principal (for group calendars)
<select name="principal" value=${this.user} @change=${(e2) => this.principal = e2.target.value}> <select required value=${this.user} @change=${(e2) => this.principal = e2.target.value}>
<option value=${this.user}>${this.user}</option> <option value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map((membership) => x` ${window.rusticalUser.memberships.map((membership) => x`
<option value=${membership}>${membership}</option> <option value=${membership}>${membership}</option>
@@ -55,17 +59,17 @@ let CreateCalendarForm = class extends i {
<br> <br>
<label> <label>
id id
<input type="text" name="id" value=${this.cal_id} @change=${(e2) => this.cal_id = e2.target.value} /> <input type="text" required .value=${this.cal_id} @change=${(e2) => this.cal_id = e2.target.value} />
</label> </label>
<br> <br>
<label> <label>
Displayname Displayname
<input type="text" name="displayname" value=${this.displayname} @change=${(e2) => this.displayname = e2.target.value} /> <input type="text" required .value=${this.displayname} @change=${(e2) => this.displayname = e2.target.value} />
</label> </label>
<br> <br>
<label> <label>
Timezone (optional) Timezone (optional)
<select name="timezone" .value=${this.timezone_id} @change=${(e2) => this.timezone_id = e2.target.value}> <select .value=${this.timezone_id} @change=${(e2) => this.timezone_id = e2.target.value}>
<option value="">No timezone</option> <option value="">No timezone</option>
${this.timezones.map((timezone) => x` ${this.timezones.map((timezone) => x`
<option value=${timezone} ?selected=${timezone === this.timezone_id}>${timezone}</option> <option value=${timezone} ?selected=${timezone === this.timezone_id}>${timezone}</option>
@@ -75,48 +79,59 @@ let CreateCalendarForm = class extends i {
<br> <br>
<label> <label>
Description Description
<input type="text" name="description" @change=${(e2) => this.description = e2.target.value} /> <input type="text" .value=${this.description} @change=${(e2) => this.description = e2.target.value} />
</label> </label>
<br> <br>
<label> <label>
Color Color
<input type="color" name="color" @change=${(e2) => this.color = e2.target.value} /> <input type="color" .value=${this.color} @change=${(e2) => this.color = e2.target.value} />
</label> </label>
<br> <br>
<br> <br>
<label> <label>Type</label>
Calendar is subscription to external calendar <div class="tab-radio">
<input type="checkbox" name="is_subscription" @change=${(e2) => this.isSubscription = e2.target.checked} /> <label>
</label> <input type="radio" name="type" .checked=${!this.isSubscription} @change=${(e2) => this.isSubscription = false}></input>
${SVG_ICON_CALENDAR}
Calendar
</label>
<label>
<input type="radio" name="type" .checked=${this.isSubscription} @change=${(e2) => this.isSubscription = true}></input>
${SVG_ICON_INTERNET}
webCal Subscription
</label>
</div>
<br> <br>
${this.isSubscription ? x` ${this.isSubscription ? x`
<label> <label>
Subscription URL Subscription URL
<input type="text" name="subscription_url" @change=${(e2) => this.subscriptionUrl = e2.target.value} /> <input type="text" pattern="https://.*" .required=${this.isSubscription} .value=${this.subscriptionUrl} @change=${(e2) => this.subscriptionUrl = e2.target.value} />
</label> </label>
<br> <br>
<br>
` : x``} ` : x``}
<br>
${["VEVENT", "VTODO", "VJOURNAL"].map((comp) => x` <label>Components</label>
<label> <div>
Support ${comp} ${["VEVENT", "VTODO", "VJOURNAL"].map((comp) => x`
<input type="checkbox" value=${comp} @change=${(e2) => e2.target.checked ? this.components.add(e2.target.value) : this.components.delete(e2.target.value)} /> <label>
</label> Support ${comp}
<br> <input type="checkbox" .value=${comp} @change=${(e2) => e2.target.checked ? this.components.add(e2.target.value) : this.components.delete(e2.target.value)} .checked=${this.components.has(comp)} />
`)} </label>
<br>
`)}
</div>
<br> <br>
<button type="submit">Create</button> <button type="submit">Create</button>
<button type="submit" @click=${(event) => { <button type="submit" @click=${(event) => {
event.preventDefault(); event.preventDefault();
this.dialog.value.close(); this.dialog.value.close();
this.form.value.reset();
}} class="cancel">Cancel</button> }} class="cancel">Cancel</button>
</form> </form>
</dialog> </dialog>
`; `;
} }
async submit(e2) { async submit(e2) {
console.log(this.displayname);
e2.preventDefault(); e2.preventDefault();
if (!this.cal_id) { if (!this.cal_id) {
alert("Empty id"); alert("Empty id");
@@ -130,6 +145,10 @@ let CreateCalendarForm = class extends i {
alert("No calendar components selected"); alert("No calendar components selected");
return; return;
} }
if (this.isSubscription && !this.subscriptionUrl) {
alert("Invalid subscription url");
return;
}
let response = await fetch(`/caldav/principal/${this.principal || this.user}/${this.cal_id}`, { let response = await fetch(`/caldav/principal/${this.principal || this.user}/${this.cal_id}`, {
method: "MKCOL", method: "MKCOL",
headers: { headers: {

View File

@@ -1,5 +1,5 @@
import { i, x } from "./lit-DkXrt_Iv.mjs"; import { i, x } from "./lit-DKg0et_P.mjs";
import { n, t } from "./property-B8WoKf1Y.mjs"; import { n, t } from "./property-C8WJQOrH.mjs";
var __defProp = Object.defineProperty; var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => { var __decorateClass = (decorators, target, key, kind) => {

View File

@@ -1,7 +1,7 @@
import { i, x } from "./lit-DkXrt_Iv.mjs"; import { i, x } from "./lit-DKg0et_P.mjs";
import { n as n$1, t } from "./property-B8WoKf1Y.mjs"; import { n as n$1, t } from "./property-C8WJQOrH.mjs";
import { e, n } from "./ref-BwbQvJBB.mjs"; import { e, n } from "./ref-BivNNNRN.mjs";
import { e as escapeXml } from "./index-_IB1wMbZ.mjs"; import { e as escapeXml } from "./index-fgowJCc1.mjs";
var __defProp = Object.defineProperty; var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => { var __decorateClass = (decorators, target, key, kind) => {
@@ -33,12 +33,12 @@ let EditAddressbookForm = class extends i {
<form @submit=${this.submit} ${n(this.form)}> <form @submit=${this.submit} ${n(this.form)}>
<label> <label>
Displayname Displayname
<input type="text" name="displayname" .value=${this.displayname} @change=${(e2) => this.displayname = e2.target.value} /> <input type="text" .value=${this.displayname} @change=${(e2) => this.displayname = e2.target.value} />
</label> </label>
<br> <br>
<label> <label>
Description Description
<input type="text" name="description" .value=${this.description} @change=${(e2) => this.description = e2.target.value} /> <input type="text" .value=${this.description} @change=${(e2) => this.description = e2.target.value} />
</label> </label>
<br> <br>
<button type="submit">Submit</button> <button type="submit">Submit</button>

View File

@@ -1,7 +1,7 @@
import { i, x } from "./lit-DkXrt_Iv.mjs"; import { i, x } from "./lit-DKg0et_P.mjs";
import { n as n$1, t } from "./property-B8WoKf1Y.mjs"; import { n as n$1, t } from "./property-C8WJQOrH.mjs";
import { e, n } from "./ref-BwbQvJBB.mjs"; import { e, n } from "./ref-BivNNNRN.mjs";
import { e as escapeXml } from "./index-_IB1wMbZ.mjs"; import { e as escapeXml } from "./index-fgowJCc1.mjs";
import { g as getTimezones } from "./timezones-B0vBBzCP.mjs"; import { g as getTimezones } from "./timezones-B0vBBzCP.mjs";
var __defProp = Object.defineProperty; var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -40,12 +40,12 @@ let EditCalendarForm = class extends i {
<form @submit=${this.submit} ${n(this.form)}> <form @submit=${this.submit} ${n(this.form)}>
<label> <label>
Displayname Displayname
<input type="text" name="displayname" .value=${this.displayname} @change=${(e2) => this.displayname = e2.target.value} /> <input type="text" required .value=${this.displayname} @change=${(e2) => this.displayname = e2.target.value} />
</label> </label>
<br> <br>
<label> <label>
Timezone (optional) Timezone (optional)
<select name="timezone" .value=${this.timezone_id} @change=${(e2) => this.timezone_id = e2.target.value}> <select .value=${this.timezone_id} @change=${(e2) => this.timezone_id = e2.target.value}>
<option value="">No timezone</option> <option value="">No timezone</option>
${this.timezones.map((timezone) => x` ${this.timezones.map((timezone) => x`
<option value=${timezone} ?selected=${timezone === this.timezone_id}>${timezone}</option> <option value=${timezone} ?selected=${timezone === this.timezone_id}>${timezone}</option>
@@ -55,18 +55,18 @@ let EditCalendarForm = class extends i {
<br> <br>
<label> <label>
Description Description
<input type="text" name="description" .value=${this.description} @change=${(e2) => this.description = e2.target.value} /> <input type="text" .value=${this.description} @change=${(e2) => this.description = e2.target.value} />
</label> </label>
<br> <br>
<label> <label>
Color Color
<input type="color" name="color" .value=${this.color} @change=${(e2) => this.color = e2.target.value} /> <input type="color" .value=${this.color} @change=${(e2) => this.color = e2.target.value} />
</label> </label>
<br> <br>
${["VEVENT", "VTODO", "VJOURNAL"].map((comp) => x` ${["VEVENT", "VTODO", "VJOURNAL"].map((comp) => x`
<label> <label>
Support ${comp} Support ${comp}
<input type="checkbox" value=${comp} ?checked=${this.components.has(comp)} @change=${(e2) => e2.target.checked ? this.components.add(e2.target.value) : this.components.delete(e2.target.value)} /> <input type="checkbox" .value=${comp} ?checked=${this.components.has(comp)} @change=${(e2) => e2.target.checked ? this.components.add(e2.target.value) : this.components.delete(e2.target.value)} />
</label> </label>
<br> <br>
`)} `)}

View File

@@ -1,6 +1,6 @@
import { i, x } from "./lit-DkXrt_Iv.mjs"; import { i, x } from "./lit-DKg0et_P.mjs";
import { n as n$1, t } from "./property-B8WoKf1Y.mjs"; import { n as n$1, t } from "./property-C8WJQOrH.mjs";
import { e, n } from "./ref-BwbQvJBB.mjs"; import { e, n } from "./ref-BivNNNRN.mjs";
var __defProp = Object.defineProperty; var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => { var __decorateClass = (decorators, target, key, kind) => {
@@ -30,23 +30,25 @@ let ImportAddressbookForm = class extends i {
<form @submit=${this.submit} ${n(this.form)}> <form @submit=${this.submit} ${n(this.form)}>
<label> <label>
principal (for group addressbook) principal (for group addressbook)
<select name="principal" value=${this.user} @change=${(e2) => this.principal = e2.target.value}> <select name="principal" required .value=${this.user} @change=${(e2) => this.principal = e2.target.value}>
<option value=${this.user}>${this.user}</option> <option .value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map((membership) => x` ${window.rusticalUser.memberships.map((membership) => x`
<option value=${membership}>${membership}</option> <option .value=${membership}>${membership}</option>
`)} `)}
</select> </select>
</label> </label>
<br> <br>
<label> <label>
id id
<input type="text" name="id" value=${this.addressbook_id} @change=${(e2) => this.addressbook_id = e2.target.value} /> <input type="text" required .value=${this.addressbook_id} @change=${(e2) => this.addressbook_id = e2.target.value} />
</label> </label>
<br> <br>
<label> <label>
file file
<input type="file" accept="text/vcard" name="file" @change=${(e2) => this.file = e2.target.files[0]} /> <input type="file" accept="text/vcard" required @change=${(e2) => this.file = e2.target.files[0]} />
</label> </label>
<br>
<br>
<button type="submit">Import</button> <button type="submit">Import</button>
<button type="submit" @click=${(event) => { <button type="submit" @click=${(event) => {
event.preventDefault(); event.preventDefault();

View File

@@ -1,6 +1,6 @@
import { i, x } from "./lit-DkXrt_Iv.mjs"; import { i, x } from "./lit-DKg0et_P.mjs";
import { n as n$1, t } from "./property-B8WoKf1Y.mjs"; import { n as n$1, t } from "./property-C8WJQOrH.mjs";
import { e, n } from "./ref-BwbQvJBB.mjs"; import { e, n } from "./ref-BivNNNRN.mjs";
var __defProp = Object.defineProperty; var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => { var __decorateClass = (decorators, target, key, kind) => {
@@ -30,23 +30,25 @@ let ImportCalendarForm = class extends i {
<form @submit=${this.submit} ${n(this.form)}> <form @submit=${this.submit} ${n(this.form)}>
<label> <label>
principal (for group calendars) principal (for group calendars)
<select name="principal" value=${this.user} @change=${(e2) => this.principal = e2.target.value}> <select name="principal" required .value=${this.user} @change=${(e2) => this.principal = e2.target.value}>
<option value=${this.user}>${this.user}</option> <option .value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map((membership) => x` ${window.rusticalUser.memberships.map((membership) => x`
<option value=${membership}>${membership}</option> <option .value=${membership}>${membership}</option>
`)} `)}
</select> </select>
</label> </label>
<br> <br>
<label> <label>
id id
<input type="text" name="id" value=${this.cal_id} @change=${(e2) => this.cal_id = e2.target.value} /> <input type="text" required .value=${this.cal_id} @change=${(e2) => this.cal_id = e2.target.value} />
</label> </label>
<br> <br>
<label> <label>
file file
<input type="file" accept="text/calendar" name="file" @change=${(e2) => this.file = e2.target.files[0]} /> <input type="file" required accept="text/calendar" @change=${(e2) => this.file = e2.target.files[0]} />
</label> </label>
<br>
<br>
<button type="submit">Import</button> <button type="submit">Import</button>
<button type="submit" @click=${(event) => { <button type="submit" @click=${(event) => {
event.preventDefault(); event.preventDefault();

View File

@@ -1,6 +0,0 @@
function escapeXml(unsafe) {
return unsafe.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
export {
escapeXml as e
};

View File

@@ -0,0 +1,27 @@
import { b } from "./lit-DKg0et_P.mjs";
function escapeXml(unsafe) {
return unsafe.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
const SVG_ICON_CALENDAR = b`<!-- Adapted from https://iconoir.com/ -->
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="icon">
<path d="M15 4V2M15 4V6M15 4H10.5M3 10V19C3 20.1046 3.89543 21 5 21H19C20.1046 21 21 20.1046 21 19V10H3Z" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M3 10V6C3 4.89543 3.89543 4 5 4H7" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M7 2V6" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M21 10V6C21 4.89543 20.1046 4 19 4H18.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
`;
const SVG_ICON_INTERNET = b`<!-- Adapted from https://iconoir.com/ -->
<svg class="icon" width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M13 2.04932C13 2.04932 16 5.99994 16 11.9999" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M11 21.9506C11 21.9506 8 17.9999 8 11.9999C8 5.99994 11 2.04932 11 2.04932" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M2.62964 15.5H12" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M2.62964 8.5H21.3704" stroke-linecap="round" stroke-linejoin="round"></path>
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.8789 17.9174C22.3727 18.2211 22.3423 18.9604 21.8337 19.0181L19.2671 19.309L18.1159 21.6213C17.8878 22.0795 17.1827 21.8552 17.0661 21.2873L15.8108 15.1713C15.7123 14.6913 16.1437 14.3892 16.561 14.646L21.8789 17.9174Z"></path>
</svg>
`;
export {
SVG_ICON_CALENDAR as S,
SVG_ICON_INTERNET as a,
escapeXml as e
};

View File

@@ -66,7 +66,7 @@ const { is: i$2, defineProperty: e$1, getOwnPropertyDescriptor: h$1, getOwnPrope
} }
} }
return i2; return i2;
} }, f$1 = (t2, s2) => !i$2(t2, s2), b = { attribute: true, type: String, converter: u$1, reflect: false, useDefault: false, hasChanged: f$1 }; } }, f$1 = (t2, s2) => !i$2(t2, s2), b$1 = { attribute: true, type: String, converter: u$1, reflect: false, useDefault: false, hasChanged: f$1 };
Symbol.metadata ??= Symbol("metadata"), a$1.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); Symbol.metadata ??= Symbol("metadata"), a$1.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();
let y$1 = class y extends HTMLElement { let y$1 = class y extends HTMLElement {
static addInitializer(t2) { static addInitializer(t2) {
@@ -75,7 +75,7 @@ let y$1 = class y extends HTMLElement {
static get observedAttributes() { static get observedAttributes() {
return this.finalize(), this._$Eh && [...this._$Eh.keys()]; return this.finalize(), this._$Eh && [...this._$Eh.keys()];
} }
static createProperty(t2, s2 = b) { 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) { 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 i2 = Symbol(), h2 = this.getPropertyDescriptor(t2, i2, s2); const i2 = Symbol(), h2 = this.getPropertyDescriptor(t2, i2, s2);
void 0 !== h2 && e$1(this.prototype, t2, h2); void 0 !== h2 && e$1(this.prototype, t2, h2);
@@ -93,7 +93,7 @@ let y$1 = class y extends HTMLElement {
}, configurable: true, enumerable: true }; }, configurable: true, enumerable: true };
} }
static getPropertyOptions(t2) { static getPropertyOptions(t2) {
return this.elementProperties.get(t2) ?? b; return this.elementProperties.get(t2) ?? b$1;
} }
static _$Ei() { static _$Ei() {
if (this.hasOwnProperty(d$1("elementProperties"))) return; if (this.hasOwnProperty(d$1("elementProperties"))) return;
@@ -256,7 +256,7 @@ y$1.elementStyles = [], y$1.shadowRootOptions = { mode: "open" }, y$1[d$1("eleme
* SPDX-License-Identifier: BSD-3-Clause * SPDX-License-Identifier: BSD-3-Clause
*/ */
const t = globalThis, i$1 = t.trustedTypes, s$1 = i$1 ? i$1.createPolicy("lit-html", { createHTML: (t2) => t2 }) : void 0, e = "$lit$", h = `lit$${Math.random().toFixed(9).slice(2)}$`, o$1 = "?" + h, n2 = `<${o$1}>`, r = document, l = () => r.createComment(""), c = (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 = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, v = /-->/g, _ = />/g, m = RegExp(`>|${d}(?:([^\\s"'>=/]+)(${d}*=${d}*(?:[^ const t = globalThis, i$1 = t.trustedTypes, s$1 = i$1 ? i$1.createPolicy("lit-html", { createHTML: (t2) => t2 }) : void 0, e = "$lit$", h = `lit$${Math.random().toFixed(9).slice(2)}$`, o$1 = "?" + h, n2 = `<${o$1}>`, r = document, l = () => r.createComment(""), c = (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 = /<(?:(!--|\/[^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) => (i2, ...s2) => ({ _$litType$: t2, strings: i2, values: s2 }), x = y2(1), T = Symbol.for("lit-noChange"), E = Symbol.for("lit-nothing"), A = /* @__PURE__ */ new WeakMap(), C = r.createTreeWalker(r, 129); \f\r"'\`<>=]|("|')|))|$)`, "g"), p = /'/g, g = /"/g, $ = /^(?:script|style|textarea|title)$/i, y2 = (t2) => (i2, ...s2) => ({ _$litType$: t2, strings: i2, values: s2 }), x = y2(1), b = y2(2), T = Symbol.for("lit-noChange"), E = Symbol.for("lit-nothing"), A = /* @__PURE__ */ new WeakMap(), C = r.createTreeWalker(r, 129);
function P(t2, i2) { function P(t2, i2) {
if (!a(t2) || !t2.hasOwnProperty("raw")) throw Error("invalid template strings array"); if (!a(t2) || !t2.hasOwnProperty("raw")) throw Error("invalid template strings array");
return void 0 !== s$1 ? s$1.createHTML(i2) : i2; return void 0 !== s$1 ? s$1.createHTML(i2) : i2;
@@ -513,6 +513,7 @@ o?.({ LitElement: i });
(s.litElementVersions ??= []).push("4.2.1"); (s.litElementVersions ??= []).push("4.2.1");
export { export {
E, E,
b,
f$1 as f, f$1 as f,
i, i,
u$1 as u, u$1 as u,

View File

@@ -1,4 +1,4 @@
import { f, u } from "./lit-DkXrt_Iv.mjs"; import { f, u } from "./lit-DKg0et_P.mjs";
/** /**
* @license * @license
* Copyright 2017 Google LLC * Copyright 2017 Google LLC

View File

@@ -1,4 +1,4 @@
import { E } from "./lit-DkXrt_Iv.mjs"; import { E } from "./lit-DKg0et_P.mjs";
/** /**
* @license * @license
* Copyright 2020 Google LLC * Copyright 2020 Google LLC

File diff suppressed because it is too large Load Diff

View File

@@ -338,8 +338,10 @@ select {
form { form {
input[type="text"], input[type="text"],
input[type="url"],
input[type="password"], input[type="password"],
input[type="color"], input[type="color"],
input[type="file"],
textarea, textarea,
select { select {
width: 100%; width: 100%;
@@ -364,3 +366,57 @@ svg.icon {
border-radius: 12px; border-radius: 12px;
} }
} }
.tab-radio {
display: flex;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
background: color-mix(in srgb, var(--background-color), var(--dilute-color) 10%);
border: 2px solid var(--border-color);
border-radius: 8px;
color: var(--text-on-background-color);
margin: 2px;
label {
padding: 10px;
background: color-mix(in srgb, var(--background-color), var(--dilute-color) 10%);
border-radius: 12px;
cursor: pointer;
position: relative;
display: flex;
align-items: center;
svg {
margin-right: 4px;
}
input {
appearance: none;
position: absolute;
width: 100%;
height: 100%;
inset: 0;
pointer-events: none;
}
}
label:hover {
background: color-mix(in srgb, var(--background-color), var(--dilute-color) 15%);
}
label:has(input:focus-visible) {
outline: medium auto currentColor;
outline: medium auto invert;
outline: 5px auto -webkit-focus-ring-color;
input {
outline: none;
}
}
label:has(input:checked) {
background: color-mix(in srgb, var(--background-color), var(--dilute-color) 20%);
}
}

View File

@@ -34,7 +34,6 @@
</div> </div>
<div class="metadata"> <div class="metadata">
{{ meta.len }} ({{ meta.size | filesizeformat }}) objects, {{ meta.deleted_len }} ({{ meta.deleted_size | filesizeformat }}) deleted objects {{ meta.len }} ({{ meta.size | filesizeformat }}) objects, {{ meta.deleted_len }} ({{ meta.deleted_size | filesizeformat }}) deleted objects
{{ birthday_cal.is_some() }}
</div> </div>
</div> </div>
</li> </li>

View File

@@ -0,0 +1,9 @@
<!-- Adapted from https://iconoir.com/ -->
<svg class="icon" width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M13 2.04932C13 2.04932 16 5.99994 16 11.9999" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M11 21.9506C11 21.9506 8 17.9999 8 11.9999C8 5.99994 11 2.04932 11 2.04932" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M2.62964 15.5H12" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M2.62964 8.5H21.3704" stroke-linecap="round" stroke-linejoin="round"></path>
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.8789 17.9174C22.3727 18.2211 22.3423 18.9604 21.8337 19.0181L19.2671 19.309L18.1159 21.6213C17.8878 22.0795 17.1827 21.8552 17.0661 21.2873L15.8108 15.1713C15.7123 14.6913 16.1437 14.3892 16.561 14.646L21.8789 17.9174Z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -9,7 +9,7 @@ use rustical_store::{
}; };
use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction}; use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction};
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use tracing::{error, instrument, warn}; use tracing::{error_span, instrument, warn};
pub mod birthday_calendar; pub mod birthday_calendar;
@@ -74,7 +74,7 @@ impl SqliteAddressbookStore {
"Commiting orphaned addressbook object ({},{},{}), deleted={}", "Commiting orphaned addressbook object ({},{},{}), deleted={}",
&row.principal, &row.addressbook_id, &row.id, &row.deleted &row.principal, &row.addressbook_id, &row.id, &row.deleted
); );
log_object_operation( Self::log_object_operation(
&mut tx, &mut tx,
&row.principal, &row.principal,
&row.addressbook_id, &row.addressbook_id,
@@ -88,6 +88,57 @@ impl SqliteAddressbookStore {
Ok(()) Ok(())
} }
// Logs an operation to an address object
async fn log_object_operation(
tx: &mut Transaction<'_, Sqlite>,
principal: &str,
addressbook_id: &str,
object_id: &str,
operation: ChangeOperation,
) -> Result<String, Error> {
struct Synctoken {
synctoken: i64,
}
let Synctoken { synctoken } = sqlx::query_as!(
Synctoken,
r#"
UPDATE addressbooks
SET synctoken = synctoken + 1
WHERE (principal, id) = (?1, ?2)
RETURNING synctoken"#,
principal,
addressbook_id
)
.fetch_one(&mut **tx)
.await
.map_err(crate::Error::from)?;
sqlx::query!(
r#"
INSERT INTO addressobjectchangelog (principal, addressbook_id, object_id, "operation", synctoken)
VALUES (?1, ?2, ?3, ?4, (
SELECT synctoken FROM addressbooks WHERE (principal, id) = (?1, ?2)
))"#,
principal,
addressbook_id,
object_id,
operation
)
.execute(&mut **tx)
.await
.map_err(crate::Error::from)?;
Ok(format_synctoken(synctoken))
}
fn send_push_notification(&self, data: CollectionOperationInfo, topic: String) {
if let Err(err) = self.sender.try_send(CollectionOperation { topic, data }) {
error_span!(
"Error trying to send addressbook update notification:",
err = format!("{err:?}"),
);
}
}
async fn _get_addressbook<'e, E: Executor<'e, Database = Sqlite>>( async fn _get_addressbook<'e, E: Executor<'e, Database = Sqlite>>(
executor: E, executor: E,
principal: &str, principal: &str,
@@ -496,13 +547,8 @@ impl AddressbookStore for SqliteAddressbookStore {
Self::_delete_addressbook(&mut *tx, principal, addressbook_id, use_trashbin).await?; Self::_delete_addressbook(&mut *tx, principal, addressbook_id, use_trashbin).await?;
tx.commit().await.map_err(crate::Error::from)?; tx.commit().await.map_err(crate::Error::from)?;
if let Some(addressbook) = addressbook if let Some(addressbook) = addressbook {
&& let Err(err) = self.sender.try_send(CollectionOperation { self.send_push_notification(CollectionOperationInfo::Delete, addressbook.push_topic);
data: CollectionOperationInfo::Delete,
topic: addressbook.push_topic,
})
{
error!("Push notification about deleted addressbook failed: {err}");
} }
Ok(()) Ok(())
@@ -588,7 +634,7 @@ impl AddressbookStore for SqliteAddressbookStore {
Self::_put_object(&mut *tx, &principal, &addressbook_id, &object, overwrite).await?; Self::_put_object(&mut *tx, &principal, &addressbook_id, &object, overwrite).await?;
let sync_token = log_object_operation( let sync_token = Self::log_object_operation(
&mut tx, &mut tx,
&principal, &principal,
&addressbook_id, &addressbook_id,
@@ -600,15 +646,12 @@ impl AddressbookStore for SqliteAddressbookStore {
tx.commit().await.map_err(crate::Error::from)?; tx.commit().await.map_err(crate::Error::from)?;
if let Err(err) = self.sender.try_send(CollectionOperation { self.send_push_notification(
data: CollectionOperationInfo::Content { sync_token }, CollectionOperationInfo::Content { sync_token },
topic: self self.get_addressbook(&principal, &addressbook_id, false)
.get_addressbook(&principal, &addressbook_id, false)
.await? .await?
.push_topic, .push_topic,
}) { );
error!("Push notification about deleted addressbook failed: {err}");
}
Ok(()) Ok(())
} }
@@ -629,7 +672,7 @@ impl AddressbookStore for SqliteAddressbookStore {
Self::_delete_object(&mut *tx, principal, addressbook_id, object_id, use_trashbin).await?; Self::_delete_object(&mut *tx, principal, addressbook_id, object_id, use_trashbin).await?;
let sync_token = log_object_operation( let sync_token = Self::log_object_operation(
&mut tx, &mut tx,
principal, principal,
addressbook_id, addressbook_id,
@@ -641,15 +684,12 @@ impl AddressbookStore for SqliteAddressbookStore {
tx.commit().await.map_err(crate::Error::from)?; tx.commit().await.map_err(crate::Error::from)?;
if let Err(err) = self.sender.try_send(CollectionOperation { self.send_push_notification(
data: CollectionOperationInfo::Content { sync_token }, CollectionOperationInfo::Content { sync_token },
topic: self self.get_addressbook(principal, addressbook_id, false)
.get_addressbook(principal, addressbook_id, false)
.await? .await?
.push_topic, .push_topic,
}) { );
error!("Push notification about deleted addressbook failed: {err}");
}
Ok(()) Ok(())
} }
@@ -668,7 +708,7 @@ impl AddressbookStore for SqliteAddressbookStore {
Self::_restore_object(&mut *tx, principal, addressbook_id, object_id).await?; Self::_restore_object(&mut *tx, principal, addressbook_id, object_id).await?;
let sync_token = log_object_operation( let sync_token = Self::log_object_operation(
&mut tx, &mut tx,
principal, principal,
addressbook_id, addressbook_id,
@@ -679,15 +719,12 @@ impl AddressbookStore for SqliteAddressbookStore {
.map_err(crate::Error::from)?; .map_err(crate::Error::from)?;
tx.commit().await.map_err(crate::Error::from)?; tx.commit().await.map_err(crate::Error::from)?;
if let Err(err) = self.sender.try_send(CollectionOperation { self.send_push_notification(
data: CollectionOperationInfo::Content { sync_token }, CollectionOperationInfo::Content { sync_token },
topic: self self.get_addressbook(principal, addressbook_id, false)
.get_addressbook(principal, addressbook_id, false)
.await? .await?
.push_topic, .push_topic,
}) { );
error!("Push notification about restored addressbook object failed: {err}");
}
Ok(()) Ok(())
} }
@@ -732,7 +769,7 @@ impl AddressbookStore for SqliteAddressbookStore {
.await?; .await?;
sync_token = Some( sync_token = Some(
log_object_operation( Self::log_object_operation(
&mut tx, &mut tx,
&addressbook.principal, &addressbook.principal,
&addressbook.id, &addressbook.id,
@@ -744,59 +781,14 @@ impl AddressbookStore for SqliteAddressbookStore {
} }
tx.commit().await.map_err(crate::Error::from)?; tx.commit().await.map_err(crate::Error::from)?;
if let Some(sync_token) = sync_token if let Some(sync_token) = sync_token {
&& let Err(err) = self.sender.try_send(CollectionOperation { self.send_push_notification(
data: CollectionOperationInfo::Content { sync_token }, CollectionOperationInfo::Content { sync_token },
topic: self self.get_addressbook(&addressbook.principal, &addressbook.id, true)
.get_addressbook(&addressbook.principal, &addressbook.id, true)
.await? .await?
.push_topic, .push_topic,
}) );
{
error!("Push notification about imported addressbook failed: {err}");
} }
Ok(()) Ok(())
} }
} }
// Logs an operation to an address object
async fn log_object_operation(
tx: &mut Transaction<'_, Sqlite>,
principal: &str,
addressbook_id: &str,
object_id: &str,
operation: ChangeOperation,
) -> Result<String, Error> {
struct Synctoken {
synctoken: i64,
}
let Synctoken { synctoken } = sqlx::query_as!(
Synctoken,
r#"
UPDATE addressbooks
SET synctoken = synctoken + 1
WHERE (principal, id) = (?1, ?2)
RETURNING synctoken"#,
principal,
addressbook_id
)
.fetch_one(&mut **tx)
.await
.map_err(crate::Error::from)?;
sqlx::query!(
r#"
INSERT INTO addressobjectchangelog (principal, addressbook_id, object_id, "operation", synctoken)
VALUES (?1, ?2, ?3, ?4, (
SELECT synctoken FROM addressbooks WHERE (principal, id) = (?1, ?2)
))"#,
principal,
addressbook_id,
object_id,
operation
)
.execute(&mut **tx)
.await
.map_err(crate::Error::from)?;
Ok(format_synctoken(synctoken))
}

View File

@@ -11,7 +11,7 @@ use rustical_store::{CollectionOperation, CollectionOperationInfo};
use sqlx::types::chrono::NaiveDateTime; use sqlx::types::chrono::NaiveDateTime;
use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction}; use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction};
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use tracing::{error, instrument, warn}; use tracing::{error_span, instrument, warn};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct CalendarObjectRow { struct CalendarObjectRow {
@@ -94,6 +94,57 @@ pub struct SqliteCalendarStore {
} }
impl SqliteCalendarStore { impl SqliteCalendarStore {
// Logs an operation to the events
async fn log_object_operation(
tx: &mut Transaction<'_, Sqlite>,
principal: &str,
cal_id: &str,
object_id: &str,
operation: ChangeOperation,
) -> Result<String, Error> {
struct Synctoken {
synctoken: i64,
}
let Synctoken { synctoken } = sqlx::query_as!(
Synctoken,
r#"
UPDATE calendars
SET synctoken = synctoken + 1
WHERE (principal, id) = (?1, ?2)
RETURNING synctoken"#,
principal,
cal_id
)
.fetch_one(&mut **tx)
.await
.map_err(crate::Error::from)?;
sqlx::query!(
r#"
INSERT INTO calendarobjectchangelog (principal, cal_id, object_id, "operation", synctoken)
VALUES (?1, ?2, ?3, ?4, (
SELECT synctoken FROM calendars WHERE (principal, id) = (?1, ?2)
))"#,
principal,
cal_id,
object_id,
operation
)
.execute(&mut **tx)
.await
.map_err(crate::Error::from)?;
Ok(format_synctoken(synctoken))
}
fn send_push_notification(&self, data: CollectionOperationInfo, topic: String) {
if let Err(err) = self.sender.try_send(CollectionOperation { topic, data }) {
error_span!(
"Error trying to send calendar update notification:",
err = format!("{err:?}"),
);
}
}
// Commit "orphaned" objects to the changelog table // Commit "orphaned" objects to the changelog table
pub async fn repair_orphans(&self) -> Result<(), Error> { pub async fn repair_orphans(&self) -> Result<(), Error> {
struct Row { struct Row {
@@ -134,7 +185,8 @@ impl SqliteCalendarStore {
"Commiting orphaned calendar object ({},{},{}), deleted={}", "Commiting orphaned calendar object ({},{},{}), deleted={}",
&row.principal, &row.cal_id, &row.id, &row.deleted &row.principal, &row.cal_id, &row.id, &row.deleted
); );
log_object_operation(&mut tx, &row.principal, &row.cal_id, &row.id, operation).await?; Self::log_object_operation(&mut tx, &row.principal, &row.cal_id, &row.id, operation)
.await?;
} }
tx.commit().await.map_err(crate::Error::from)?; tx.commit().await.map_err(crate::Error::from)?;
@@ -605,13 +657,8 @@ impl CalendarStore for SqliteCalendarStore {
Self::_delete_calendar(&mut *tx, principal, id, use_trashbin).await?; Self::_delete_calendar(&mut *tx, principal, id, use_trashbin).await?;
tx.commit().await.map_err(crate::Error::from)?; tx.commit().await.map_err(crate::Error::from)?;
if let Some(cal) = cal if let Some(cal) = cal {
&& let Err(err) = self.sender.try_send(CollectionOperation { self.send_push_notification(CollectionOperationInfo::Delete, cal.push_topic);
data: CollectionOperationInfo::Delete,
topic: cal.push_topic,
})
{
error!("Push notification about deleted calendar failed: {err}");
} }
Ok(()) Ok(())
} }
@@ -652,7 +699,7 @@ impl CalendarStore for SqliteCalendarStore {
Self::_put_object(&mut *tx, &calendar.principal, &calendar.id, &object, false).await?; Self::_put_object(&mut *tx, &calendar.principal, &calendar.id, &object, false).await?;
sync_token = Some( sync_token = Some(
log_object_operation( Self::log_object_operation(
&mut tx, &mut tx,
&calendar.principal, &calendar.principal,
&calendar.id, &calendar.id,
@@ -665,16 +712,13 @@ impl CalendarStore for SqliteCalendarStore {
tx.commit().await.map_err(crate::Error::from)?; tx.commit().await.map_err(crate::Error::from)?;
if let Some(sync_token) = sync_token if let Some(sync_token) = sync_token {
&& let Err(err) = self.sender.try_send(CollectionOperation { self.send_push_notification(
data: CollectionOperationInfo::Content { sync_token }, CollectionOperationInfo::Content { sync_token },
topic: self self.get_calendar(&calendar.principal, &calendar.id, true)
.get_calendar(&calendar.principal, &calendar.id, true)
.await? .await?
.push_topic, .push_topic,
}) );
{
error!("Push notification about imported calendar failed: {err}");
} }
Ok(()) Ok(())
} }
@@ -755,7 +799,7 @@ impl CalendarStore for SqliteCalendarStore {
Self::_put_object(&mut *tx, &principal, &cal_id, &object, overwrite).await?; Self::_put_object(&mut *tx, &principal, &cal_id, &object, overwrite).await?;
let sync_token = log_object_operation( let sync_token = Self::log_object_operation(
&mut tx, &mut tx,
&principal, &principal,
&cal_id, &cal_id,
@@ -766,15 +810,12 @@ impl CalendarStore for SqliteCalendarStore {
tx.commit().await.map_err(crate::Error::from)?; tx.commit().await.map_err(crate::Error::from)?;
if let Err(err) = self.sender.try_send(CollectionOperation { self.send_push_notification(
data: CollectionOperationInfo::Content { sync_token }, CollectionOperationInfo::Content { sync_token },
topic: self self.get_calendar(&principal, &cal_id, true)
.get_calendar(&principal, &cal_id, true)
.await? .await?
.push_topic, .push_topic,
}) { );
error!("Push notification about deleted calendar failed: {err}");
}
Ok(()) Ok(())
} }
@@ -795,15 +836,15 @@ impl CalendarStore for SqliteCalendarStore {
Self::_delete_object(&mut *tx, principal, cal_id, id, use_trashbin).await?; Self::_delete_object(&mut *tx, principal, cal_id, id, use_trashbin).await?;
let sync_token = let sync_token =
log_object_operation(&mut tx, principal, cal_id, id, ChangeOperation::Delete).await?; Self::log_object_operation(&mut tx, principal, cal_id, id, ChangeOperation::Delete)
.await?;
tx.commit().await.map_err(crate::Error::from)?; tx.commit().await.map_err(crate::Error::from)?;
if let Err(err) = self.sender.try_send(CollectionOperation { self.send_push_notification(
data: CollectionOperationInfo::Content { sync_token }, CollectionOperationInfo::Content { sync_token },
topic: self.get_calendar(principal, cal_id, true).await?.push_topic, self.get_calendar(principal, cal_id, true).await?.push_topic,
}) { );
error!("Push notification about deleted calendar failed: {err}");
}
Ok(()) Ok(())
} }
@@ -823,16 +864,14 @@ impl CalendarStore for SqliteCalendarStore {
Self::_restore_object(&mut *tx, principal, cal_id, object_id).await?; Self::_restore_object(&mut *tx, principal, cal_id, object_id).await?;
let sync_token = let sync_token =
log_object_operation(&mut tx, principal, cal_id, object_id, ChangeOperation::Add) Self::log_object_operation(&mut tx, principal, cal_id, object_id, ChangeOperation::Add)
.await?; .await?;
tx.commit().await.map_err(crate::Error::from)?; tx.commit().await.map_err(crate::Error::from)?;
if let Err(err) = self.sender.try_send(CollectionOperation { self.send_push_notification(
data: CollectionOperationInfo::Content { sync_token }, CollectionOperationInfo::Content { sync_token },
topic: self.get_calendar(principal, cal_id, true).await?.push_topic, self.get_calendar(principal, cal_id, true).await?.push_topic,
}) { );
error!("Push notification about restored calendar object failed: {err}");
}
Ok(()) Ok(())
} }
@@ -850,46 +889,3 @@ impl CalendarStore for SqliteCalendarStore {
false false
} }
} }
// Logs an operation to the events
// TODO: Log multiple updates
async fn log_object_operation(
tx: &mut Transaction<'_, Sqlite>,
principal: &str,
cal_id: &str,
object_id: &str,
operation: ChangeOperation,
) -> Result<String, Error> {
struct Synctoken {
synctoken: i64,
}
let Synctoken { synctoken } = sqlx::query_as!(
Synctoken,
r#"
UPDATE calendars
SET synctoken = synctoken + 1
WHERE (principal, id) = (?1, ?2)
RETURNING synctoken"#,
principal,
cal_id
)
.fetch_one(&mut **tx)
.await
.map_err(crate::Error::from)?;
sqlx::query!(
r#"
INSERT INTO calendarobjectchangelog (principal, cal_id, object_id, "operation", synctoken)
VALUES (?1, ?2, ?3, ?4, (
SELECT synctoken FROM calendars WHERE (principal, id) = (?1, ?2)
))"#,
principal,
cal_id,
object_id,
operation
)
.execute(&mut **tx)
.await
.map_err(crate::Error::from)?;
Ok(format_synctoken(synctoken))
}

View File

@@ -37,7 +37,7 @@ impl SubscriptionStore for SqliteStore {
Err(err) => return Err(err), Err(err) => return Err(err),
}; };
sqlx::query!( sqlx::query!(
r#"INSERT OR REPLACE INTO davpush_subscriptions (id, topic, expiration, push_resource, public_key, public_key_type, auth_secret) VALUES (?, ?, ?, ?, ?, ?, ?)"#, r#"REPLACE INTO davpush_subscriptions (id, topic, expiration, push_resource, public_key, public_key_type, auth_secret) VALUES (?, ?, ?, ?, ?, ?, ?)"#,
sub.id, sub.id,
sub.topic, sub.topic,
sub.expiration, sub.expiration,

View File

@@ -31,6 +31,8 @@ mod app;
mod commands; mod commands;
mod config; mod config;
mod setup_tracing; mod setup_tracing;
#[cfg(test)]
mod tests;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
@@ -91,21 +93,22 @@ async fn get_data_stores(
async fn main() -> Result<()> { async fn main() -> Result<()> {
let args = Args::parse(); let args = Args::parse();
let parse_config = || {
Figment::new()
.merge(Toml::file(&args.config_file))
.merge(Env::prefixed("RUSTICAL_").split("__"))
.extract()
};
match args.command { match args.command {
Some(Command::GenConfig(gen_config_args)) => cmd_gen_config(gen_config_args)?, Some(Command::GenConfig(gen_config_args)) => cmd_gen_config(gen_config_args)?,
Some(Command::Principals(principals_args)) => cmd_principals(principals_args).await?, Some(Command::Principals(principals_args)) => cmd_principals(principals_args).await?,
Some(Command::Health(health_args)) => { Some(Command::Health(health_args)) => {
let config: Config = Figment::new() let config: Config = parse_config()?;
.merge(Toml::file(&args.config_file))
.merge(Env::prefixed("RUSTICAL_").split("__"))
.extract()?;
cmd_health(config.http, health_args).await?; cmd_health(config.http, health_args).await?;
} }
None => { None => {
let config: Config = Figment::new() let config: Config = parse_config()?;
.merge(Toml::file(&args.config_file))
.merge(Env::prefixed("RUSTICAL_").split("__"))
.extract()?;
setup_tracing(&config.tracing); setup_tracing(&config.tracing);

52
src/tests.rs Normal file
View File

@@ -0,0 +1,52 @@
use crate::{app::make_app, config::NextcloudLoginConfig};
use rstest::rstest;
use rustical_frontend::FrontendConfig;
use rustical_store_sqlite::{
SqliteStore,
addressbook_store::SqliteAddressbookStore,
calendar_store::SqliteCalendarStore,
principal_store::SqlitePrincipalStore,
tests::{
get_test_addressbook_store, get_test_calendar_store, get_test_principal_store,
get_test_subscription_store,
},
};
use std::sync::Arc;
#[rstest]
#[tokio::test]
async fn test_app(
#[from(get_test_calendar_store)]
#[future]
cal_store: SqliteCalendarStore,
#[from(get_test_addressbook_store)]
#[future]
addr_store: SqliteAddressbookStore,
#[from(get_test_principal_store)]
#[future]
principal_store: SqlitePrincipalStore,
#[from(get_test_subscription_store)]
#[future]
sub_store: SqliteStore,
) {
let addr_store = Arc::new(addr_store.await);
let cal_store = Arc::new(cal_store.await);
let sub_store = Arc::new(sub_store.await);
let principal_store = Arc::new(principal_store.await);
let _app = make_app(
addr_store,
cal_store,
sub_store,
principal_store,
FrontendConfig {
enabled: true,
allow_password_login: true,
},
None,
&NextcloudLoginConfig { enabled: false },
false,
true,
20,
);
}