Compare commits

...

11 Commits

Author SHA1 Message Date
Lennart
9538b68e77 version 0.10.1 2025-11-02 22:21:25 +01:00
Lennart
ea5175387b update licenses 2025-11-02 22:21:16 +01:00
Lennart
0095491a20 frontend: dumb test for timezones 2025-11-02 22:17:23 +01:00
Lennart
e9392cc00b frontend: Add dropdown for timezone selection 2025-11-02 22:08:28 +01:00
Lennart
888591c952 add test case for converting filter to calendar query 2025-11-02 19:17:59 +01:00
Lennart
de77223170 Merge pull request #137 from lennart-k/feature/comp-filter
Re-implement comp-filter for calendar-query
2025-11-02 18:56:56 +01:00
Lennart
c42f8e5614 clippy appeasement 2025-11-02 18:42:55 +01:00
Lennart
f72559d027 caldav: Add supported-collation-set property 2025-11-02 18:33:54 +01:00
Lennart
167492318f xml: serialize: Support non-string text fields 2025-11-02 18:33:30 +01:00
Lennart
32f43951ac refactor text-match to support collations 2025-11-02 17:48:35 +01:00
Lennart
cd9993cd97 implement comp-filter matching for VTIMEZONE 2025-11-02 17:21:44 +01:00
24 changed files with 663 additions and 626 deletions

25
Cargo.lock generated
View File

@@ -2974,7 +2974,7 @@ dependencies = [
[[package]]
name = "rustical"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"anyhow",
"argon2",
@@ -3017,7 +3017,7 @@ dependencies = [
[[package]]
name = "rustical_caldav"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"async-std",
"async-trait",
@@ -3057,7 +3057,7 @@ dependencies = [
[[package]]
name = "rustical_carddav"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"async-trait",
"axum",
@@ -3089,7 +3089,7 @@ dependencies = [
[[package]]
name = "rustical_dav"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"async-trait",
"axum",
@@ -3114,7 +3114,7 @@ dependencies = [
[[package]]
name = "rustical_dav_push"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"async-trait",
"axum",
@@ -3139,7 +3139,7 @@ dependencies = [
[[package]]
name = "rustical_frontend"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"askama",
"askama_web",
@@ -3152,6 +3152,7 @@ dependencies = [
"headers",
"hex",
"http",
"itertools 0.14.0",
"mime_guess",
"percent-encoding",
"rand 0.9.2",
@@ -3160,6 +3161,7 @@ dependencies = [
"rustical_oidc",
"rustical_store",
"serde",
"serde_json",
"thiserror 2.0.17",
"tokio",
"tower",
@@ -3168,11 +3170,12 @@ dependencies = [
"tracing",
"url",
"uuid",
"vtimezones-rs",
]
[[package]]
name = "rustical_ical"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"axum",
"chrono",
@@ -3189,7 +3192,7 @@ dependencies = [
[[package]]
name = "rustical_oidc"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"async-trait",
"axum",
@@ -3205,7 +3208,7 @@ dependencies = [
[[package]]
name = "rustical_store"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"anyhow",
"async-trait",
@@ -3238,7 +3241,7 @@ dependencies = [
[[package]]
name = "rustical_store_sqlite"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"async-trait",
"chrono",
@@ -3259,7 +3262,7 @@ dependencies = [
[[package]]
name = "rustical_xml"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"quick-xml",
"thiserror 2.0.17",

View File

@@ -2,7 +2,7 @@
members = ["crates/*"]
[workspace.package]
version = "0.10.0"
version = "0.10.1"
edition = "2024"
description = "A CalDAV server"
documentation = "https://lennart-k.github.io/rustical/"

View File

@@ -2,6 +2,7 @@ use crate::calendar::methods::report::calendar_query::{
TimeRangeElement,
prop_filter::{PropFilterElement, PropFilterable},
};
use ical::parser::ical::component::IcalTimeZone;
use rustical_ical::{CalendarObject, CalendarObjectComponent, CalendarObjectType};
use rustical_xml::XmlDeserialize;
@@ -77,7 +78,31 @@ impl CompFilterable for CalendarObject {
}
fn match_subcomponents(&self, comp_filter: &CompFilterElement) -> bool {
self.get_data().matches(comp_filter)
let mut matches = self
.get_vtimezones()
.values()
.map(|tz| tz.matches(comp_filter))
.chain([self.get_data().matches(comp_filter)]);
if comp_filter.is_not_defined.is_some() {
matches.all(|x| x)
} else {
matches.any(|x| x)
}
}
}
impl CompFilterable for IcalTimeZone {
fn get_comp_name(&self) -> &'static str {
"VTIMEZONE"
}
fn match_time_range(&self, _time_range: &TimeRangeElement) -> bool {
false
}
fn match_subcomponents(&self, _comp_filter: &CompFilterElement) -> bool {
true
}
}
@@ -114,8 +139,10 @@ mod tests {
use rustical_ical::{CalendarObject, UtcDateTime};
use crate::calendar::methods::report::calendar_query::{
CompFilterable, TextMatchElement, TimeRangeElement, comp_filter::CompFilterElement,
CompFilterable, TextMatchElement, TimeRangeElement,
comp_filter::CompFilterElement,
prop_filter::PropFilterElement,
text_match::{NegateCondition, TextCollation},
};
const ICS: &str = r"BEGIN:VCALENDAR
@@ -192,8 +219,8 @@ END:VCALENDAR";
time_range: None,
text_match: Some(TextMatchElement {
needle: "2.0".to_string(),
collation: None,
negate_condition: None,
collation: TextCollation::default(),
negate_condition: NegateCondition::default(),
}),
param_filter: vec![],
},
@@ -214,8 +241,8 @@ END:VCALENDAR";
name: "SUMMARY".to_string(),
time_range: None,
text_match: Some(TextMatchElement {
collation: None,
negate_condition: None,
collation: TextCollation::default(),
negate_condition: NegateCondition(false),
needle: "weekly".to_string(),
}),
param_filter: vec![],
@@ -282,4 +309,37 @@ END:VCALENDAR";
"event should not lie in time range"
);
}
#[test]
fn test_match_timezone() {
let object = CalendarObject::from_ics(ICS.to_string(), None).unwrap();
let comp_filter = CompFilterElement {
is_not_defined: None,
name: "VCALENDAR".to_string(),
time_range: None,
prop_filter: vec![],
comp_filter: vec![CompFilterElement {
name: "VTIMEZONE".to_string(),
is_not_defined: None,
time_range: None,
prop_filter: vec![PropFilterElement {
is_not_defined: None,
name: "TZID".to_string(),
time_range: None,
text_match: Some(TextMatchElement {
collation: TextCollation::AsciiCasemap,
negate_condition: NegateCondition::default(),
needle: "Europe/Berlin".to_string(),
}),
param_filter: vec![],
}],
comp_filter: vec![],
}],
};
assert!(
object.matches(&comp_filter),
"Timezone should be Europe/Berlin"
);
}
}

View File

@@ -1,5 +1,8 @@
use crate::{
calendar::methods::report::calendar_query::comp_filter::{CompFilterElement, CompFilterable},
calendar::methods::report::calendar_query::{
TextMatchElement,
comp_filter::{CompFilterElement, CompFilterable},
},
calendar_object::CalendarObjectPropWrapperName,
};
use rustical_dav::xml::PropfindType;
@@ -29,18 +32,6 @@ pub struct ParamFilterElement {
pub(crate) name: String,
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]
pub struct TextMatchElement {
#[xml(ty = "attr")]
pub(crate) collation: Option<String>,
#[xml(ty = "attr")]
// "yes" or "no", default: "no"
pub(crate) negate_condition: Option<String>,
#[xml(ty = "text")]
pub(crate) needle: String,
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)]
// https://datatracker.ietf.org/doc/html/rfc4791#section-9.7
@@ -51,6 +42,7 @@ pub struct FilterElement {
}
impl FilterElement {
#[must_use]
pub fn matches(&self, cal_object: &CalendarObject) -> bool {
cal_object.matches(&self.comp_filter)
}
@@ -96,3 +88,45 @@ impl From<&CalendarQueryRequest> for CalendarQuery {
value.filter.as_ref().map(Self::from).unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use crate::calendar::methods::report::calendar_query::{
CompFilterElement, FilterElement, TimeRangeElement,
};
use chrono::{NaiveDate, TimeZone, Utc};
use rustical_ical::UtcDateTime;
use rustical_store::calendar_store::CalendarQuery;
#[test]
fn test_filter_element_calendar_query() {
let filter = FilterElement {
comp_filter: CompFilterElement {
name: "VCALENDAR".to_string(),
is_not_defined: None,
time_range: None,
prop_filter: vec![],
comp_filter: vec![CompFilterElement {
name: "VEVENT".to_string(),
is_not_defined: None,
time_range: Some(TimeRangeElement {
start: Some(UtcDateTime(
Utc.with_ymd_and_hms(2024, 4, 1, 0, 0, 0).unwrap(),
)),
end: Some(UtcDateTime(
Utc.with_ymd_and_hms(2024, 8, 1, 0, 0, 0).unwrap(),
)),
}),
prop_filter: vec![],
comp_filter: vec![],
}],
},
};
let derived_query: CalendarQuery = (&filter).into();
let query = CalendarQuery {
time_start: Some(NaiveDate::from_ymd_opt(2024, 4, 1).unwrap()),
time_end: Some(NaiveDate::from_ymd_opt(2024, 8, 1).unwrap()),
};
assert_eq!(derived_query, query);
}
}

View File

@@ -5,11 +5,14 @@ use rustical_store::CalendarStore;
mod comp_filter;
mod elements;
mod prop_filter;
pub mod text_match;
#[allow(unused_imports)]
pub use comp_filter::{CompFilterElement, CompFilterable};
pub use elements::*;
#[allow(unused_imports)]
pub use prop_filter::{PropFilterElement, PropFilterable};
#[allow(unused_imports)]
pub use text_match::TextMatchElement;
pub async fn get_objects_calendar_query<C: CalendarStore>(
cal_query: &CalendarQueryRequest,
@@ -36,7 +39,9 @@ mod tests {
ReportRequest,
calendar_query::{
CalendarQueryRequest, FilterElement, ParamFilterElement, TextMatchElement,
comp_filter::CompFilterElement, prop_filter::PropFilterElement,
comp_filter::CompFilterElement,
prop_filter::PropFilterElement,
text_match::{NegateCondition, TextCollation},
},
},
calendar_object::{CalendarData, CalendarObjectPropName, CalendarObjectPropWrapperName},
@@ -96,8 +101,8 @@ mod tests {
prop_filter: vec![PropFilterElement {
name: "ATTENDEE".to_owned(),
text_match: Some(TextMatchElement {
collation: Some("i;ascii-casemap".to_owned()),
negate_condition: None,
collation: TextCollation::AsciiCasemap,
negate_condition: NegateCondition(false),
needle: "mailto:lisa@example.com".to_string()
}),
is_not_defined: None,
@@ -105,8 +110,8 @@ mod tests {
is_not_defined: None,
name: "PARTSTAT".to_owned(),
text_match: Some(TextMatchElement {
collation: Some("i;ascii-casemap".to_owned()),
negate_condition: None,
collation: TextCollation::AsciiCasemap,
negate_condition: NegateCondition(false),
needle: "NEEDS-ACTION".to_string()
}),
}],

View File

@@ -4,7 +4,7 @@ use ical::{
generator::{IcalCalendar, IcalEvent},
parser::{
Component,
ical::component::{IcalJournal, IcalTodo},
ical::component::{IcalJournal, IcalTimeZone, IcalTodo},
},
property::Property,
};
@@ -64,28 +64,10 @@ impl PropFilterElement {
return true;
}
if let Some(TextMatchElement {
collation: _collation,
negate_condition,
needle,
}) = &self.text_match
if let Some(text_match) = &self.text_match
&& !text_match.match_property(property)
{
let mut matches = property
.value
.as_ref()
.is_some_and(|haystack| haystack.contains(needle));
match negate_condition.as_deref() {
None | Some("no") => {}
Some("yes") => {
matches = !matches;
}
// Invalid value
_ => return false,
}
if !matches {
return false;
}
return false;
}
// TODO: param-filter
@@ -128,6 +110,12 @@ impl PropFilterable for IcalCalendar {
}
}
impl PropFilterable for IcalTimeZone {
fn get_property(&self, name: &str) -> Option<&Property> {
Component::get_property(self, name)
}
}
impl PropFilterable for CalendarObjectComponent {
fn get_property(&self, name: &str) -> Option<&Property> {
match self {

View File

@@ -0,0 +1,103 @@
use ical::property::Property;
use rustical_xml::{ValueDeserialize, XmlDeserialize};
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub enum TextCollation {
#[default]
AsciiCasemap,
Octet,
}
impl TextCollation {
// Check whether a haystack contains a needle respecting the collation
#[must_use]
pub fn match_text(&self, needle: &str, haystack: &str) -> bool {
match self {
// https://datatracker.ietf.org/doc/html/rfc4790#section-9.2
Self::AsciiCasemap => haystack
.to_ascii_uppercase()
.contains(&needle.to_ascii_uppercase()),
Self::Octet => haystack.contains(needle),
}
}
}
impl AsRef<str> for TextCollation {
fn as_ref(&self) -> &str {
match self {
Self::AsciiCasemap => "i;ascii-casemap",
Self::Octet => "i;octet",
}
}
}
impl ValueDeserialize for TextCollation {
fn deserialize(val: &str) -> Result<Self, rustical_xml::XmlError> {
match val {
"i;ascii-casemap" => Ok(Self::AsciiCasemap),
"i;octet" => Ok(Self::Octet),
_ => Err(rustical_xml::XmlError::InvalidVariant(format!(
"Invalid collation: {val}"
))),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct NegateCondition(pub bool);
impl ValueDeserialize for NegateCondition {
fn deserialize(val: &str) -> Result<Self, rustical_xml::XmlError> {
match val {
"yes" => Ok(Self(true)),
"no" => Ok(Self(false)),
_ => Err(rustical_xml::XmlError::InvalidVariant(format!(
"Invalid negate-condition parameter: {val}"
))),
}
}
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]
pub struct TextMatchElement {
#[xml(ty = "attr", default = "Default::default")]
pub collation: TextCollation,
#[xml(ty = "attr", default = "Default::default")]
pub(crate) negate_condition: NegateCondition,
#[xml(ty = "text")]
pub(crate) needle: String,
}
impl TextMatchElement {
#[must_use]
pub fn match_property(&self, property: &Property) -> bool {
let Self {
collation,
negate_condition,
needle,
} = self;
let matches = property
.value
.as_ref()
.is_some_and(|haystack| collation.match_text(needle, haystack));
// XOR
negate_condition.0 ^ matches
}
}
#[cfg(test)]
mod tests {
use crate::calendar::methods::report::calendar_query::text_match::TextCollation;
#[test]
fn test_collation() {
assert!(TextCollation::AsciiCasemap.match_text("GrüN", "grün"));
assert!(!TextCollation::AsciiCasemap.match_text("GrÜN", "grün"));
assert!(!TextCollation::Octet.match_text("GrÜN", "grün"));
assert!(TextCollation::Octet.match_text("hallo", "hallo"));
assert!(TextCollation::AsciiCasemap.match_text("HaLlo", "hAllo"));
}
}

View File

@@ -27,7 +27,7 @@ use sync_collection::handle_sync_collection;
use tracing::instrument;
mod calendar_multiget;
mod calendar_query;
pub mod calendar_query;
mod sync_collection;
#[derive(XmlDeserialize, XmlDocument, Clone, Debug, PartialEq)]

View File

@@ -3,6 +3,8 @@ use rustical_ical::CalendarObjectType;
use rustical_xml::{XmlDeserialize, XmlSerialize};
use strum_macros::VariantArray;
use crate::calendar::methods::report::calendar_query::text_match::TextCollation;
#[derive(Debug, Clone, XmlSerialize, XmlDeserialize, PartialEq, Eq, From, Into)]
pub struct SupportedCalendarComponent {
#[xml(ty = "attr")]
@@ -36,6 +38,28 @@ impl From<SupportedCalendarComponentSet> for Vec<CalendarObjectType> {
}
}
#[derive(Debug, Clone, XmlSerialize, XmlDeserialize, PartialEq, Eq, From, Into)]
pub struct SupportedCollation(#[xml(ty = "text")] pub TextCollation);
#[derive(Debug, Clone, XmlSerialize, XmlDeserialize, PartialEq, Eq)]
pub struct SupportedCollationSet(
#[xml(
ns = "rustical_dav::namespace::NS_CALDAV",
flatten,
rename = "supported-collation"
)]
pub Vec<SupportedCollation>,
);
impl Default for SupportedCollationSet {
fn default() -> Self {
Self(vec![
SupportedCollation(TextCollation::AsciiCasemap),
SupportedCollation(TextCollation::Octet),
])
}
}
#[derive(Debug, Clone, XmlSerialize, PartialEq, Eq)]
pub struct CalendarData {
#[xml(ty = "attr")]

View File

@@ -1,6 +1,6 @@
use super::prop::{SupportedCalendarComponentSet, SupportedCalendarData};
use crate::Error;
use crate::calendar::prop::ReportMethod;
use crate::calendar::prop::{ReportMethod, SupportedCollationSet};
use chrono::{DateTime, Utc};
use derive_more::derive::{From, Into};
use ical::IcalParser;
@@ -39,6 +39,8 @@ pub enum CalendarProp {
SupportedCalendarComponentSet(SupportedCalendarComponentSet),
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)]
SupportedCalendarData(SupportedCalendarData),
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)]
SupportedCollationSet(SupportedCollationSet),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
MaxResourceSize(i64),
#[xml(skip_deserializing)]
@@ -156,6 +158,9 @@ impl Resource for CalendarResource {
CalendarPropName::SupportedCalendarData => {
CalendarProp::SupportedCalendarData(SupportedCalendarData::default())
}
CalendarPropName::SupportedCollationSet => {
CalendarProp::SupportedCollationSet(SupportedCollationSet::default())
}
CalendarPropName::MaxResourceSize => CalendarProp::MaxResourceSize(10_000_000),
CalendarPropName::SupportedReportSet => {
CalendarProp::SupportedReportSet(SupportedReportSet::all())
@@ -244,6 +249,7 @@ impl Resource for CalendarResource {
}
CalendarProp::TimezoneServiceSet(_)
| CalendarProp::SupportedCalendarData(_)
| CalendarProp::SupportedCollationSet(_)
| CalendarProp::MaxResourceSize(_)
| CalendarProp::SupportedReportSet(_)
| CalendarProp::Source(_)
@@ -283,6 +289,7 @@ impl Resource for CalendarResource {
}
CalendarPropName::TimezoneServiceSet
| CalendarPropName::SupportedCalendarData
| CalendarPropName::SupportedCollationSet
| CalendarPropName::MaxResourceSize
| CalendarPropName::SupportedReportSet
| CalendarPropName::Source

View File

@@ -11,6 +11,7 @@
<calendar-order xmlns="http://apple.com/ns/ical/"/>
<supported-calendar-component-set xmlns="urn:ietf:params:xml:ns:caldav"/>
<supported-calendar-data xmlns="urn:ietf:params:xml:ns:caldav"/>
<supported-collation-set xmlns="urn:ietf:params:xml:ns:caldav"/>
<max-resource-size xmlns="DAV:"/>
<supported-report-set xmlns="DAV:"/>
<source xmlns="http://calendarserver.org/ns/"/>
@@ -160,6 +161,10 @@ END:VCALENDAR
<CAL:supported-calendar-data>
<CAL:calendar-data content-type="text/calendar" version="2.0"/>
</CAL:supported-calendar-data>
<CAL:supported-collation-set>
<CAL:supported-collation>i;ascii-casemap</CAL:supported-collation>
<CAL:supported-collation>i;octet</CAL:supported-collation>
</CAL:supported-collation-set>
<max-resource-size>10000000</max-resource-size>
<supported-report-set>
<supported-report>

View File

@@ -39,3 +39,6 @@ headers.workspace = true
tower-sessions.workspace = true
percent-encoding.workspace = true
tower-http = { workspace = true, optional = true }
vtimezones-rs.workspace = true
serde_json.workspace = true
itertools.workspace = true

View File

@@ -7,6 +7,11 @@ import { escapeXml } from ".";
export class CreateCalendarForm extends LitElement {
constructor() {
super()
this.fetchTimezones()
}
async fetchTimezones() {
this.timezones = await getTimezones()
}
protected override createRenderRoot() {
@@ -36,6 +41,8 @@ export class CreateCalendarForm extends LitElement {
dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef()
@property()
timezones: Array<String> = []
override render() {
return html`
@@ -65,7 +72,12 @@ export class CreateCalendarForm extends LitElement {
<br>
<label>
Timezone (optional)
<input type="text" name="timezone" .value=${this.timezone_id} @change=${e => this.timezone_id = e.target.value} />
<select name="timezone" .value=${this.timezone_id} @change=${e => this.timezone_id = e.target.value}>
<option value="">No timezone</option>
${this.timezones.map(timezone => html`
<option value=${timezone} ?selected=${timezone === this.timezone_id}>${timezone}</option>
`)}
</select>
</label>
<br>
<label>

View File

@@ -2,11 +2,17 @@ import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { escapeXml } from ".";
import { getTimezones } from "./timezones.ts";
@customElement("edit-calendar-form")
export class EditCalendarForm extends LitElement {
constructor() {
super()
this.fetchTimezones()
}
async fetchTimezones() {
this.timezones = await getTimezones()
}
protected override createRenderRoot() {
@@ -36,7 +42,8 @@ export class EditCalendarForm extends LitElement {
dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef()
@property()
timezones: Array<String> = []
override render() {
return html`
@@ -51,7 +58,12 @@ export class EditCalendarForm extends LitElement {
<br>
<label>
Timezone (optional)
<input type="text" name="timezone" .value=${this.timezone_id} @change=${e => this.timezone_id = e.target.value} />
<select name="timezone" .value=${this.timezone_id} @change=${e => this.timezone_id = e.target.value}>
<option value="">No timezone</option>
${this.timezones.map(timezone => html`
<option value=${timezone} ?selected=${timezone === this.timezone_id}>${timezone}</option>
`)}
</select>
</label>
<br>
<label>

View File

@@ -0,0 +1,12 @@
let timezonesPromise = null
export async function getTimezones() {
timezonesPromise ||= new Promise(async (resolve, reject) => {
try {
let response = await fetch('/frontend/_timezones.json')
resolve(await response.json())
} catch (e) {
reject(e)
}
})
return await timezonesPromise
}

View File

@@ -27,6 +27,11 @@ let CreateCalendarForm = class extends i {
this.components = /* @__PURE__ */ new Set();
this.dialog = e();
this.form = e();
this.timezones = [];
this.fetchTimezones();
}
async fetchTimezones() {
this.timezones = await getTimezones();
}
createRenderRoot() {
return this;
@@ -59,7 +64,12 @@ let CreateCalendarForm = class extends i {
<br>
<label>
Timezone (optional)
<input type="text" name="timezone" .value=${this.timezone_id} @change=${(e2) => this.timezone_id = e2.target.value} />
<select name="timezone" .value=${this.timezone_id} @change=${(e2) => this.timezone_id = e2.target.value}>
<option value="">No timezone</option>
${this.timezones.map((timezone) => x`
<option value=${timezone} ?selected=${timezone === this.timezone_id}>${timezone}</option>
`)}
</select>
</label>
<br>
<label>
@@ -179,6 +189,9 @@ __decorateClass([
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "components", 2);
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "timezones", 2);
CreateCalendarForm = __decorateClass([
t("create-calendar-form")
], CreateCalendarForm);

View File

@@ -2,6 +2,18 @@ import { i, x } from "./lit-DkXrt_Iv.mjs";
import { n as n$1, t } from "./property-B8WoKf1Y.mjs";
import { e, n } from "./ref-BwbQvJBB.mjs";
import { e as escapeXml } from "./index-_IB1wMbZ.mjs";
let timezonesPromise = null;
async function getTimezones() {
timezonesPromise ||= new Promise(async (resolve, reject) => {
try {
let response = await fetch("/frontend/_timezones.json");
resolve(await response.json());
} catch (e2) {
reject(e2);
}
});
return await timezonesPromise;
}
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {
@@ -22,6 +34,11 @@ let EditCalendarForm = class extends i {
this.components = /* @__PURE__ */ new Set();
this.dialog = e();
this.form = e();
this.timezones = [];
this.fetchTimezones();
}
async fetchTimezones() {
this.timezones = await getTimezones();
}
createRenderRoot() {
return this;
@@ -39,7 +56,12 @@ let EditCalendarForm = class extends i {
<br>
<label>
Timezone (optional)
<input type="text" name="timezone" .value=${this.timezone_id} @change=${(e2) => this.timezone_id = e2.target.value} />
<select name="timezone" .value=${this.timezone_id} @change=${(e2) => this.timezone_id = e2.target.value}>
<option value="">No timezone</option>
${this.timezones.map((timezone) => x`
<option value=${timezone} ?selected=${timezone === this.timezone_id}>${timezone}</option>
`)}
</select>
</label>
<br>
<label>
@@ -150,6 +172,9 @@ __decorateClass([
}
})
], EditCalendarForm.prototype, "components", 2);
__decorateClass([
n$1()
], EditCalendarForm.prototype, "timezones", 2);
EditCalendarForm = __decorateClass([
t("edit-calendar-form")
], EditCalendarForm);

File diff suppressed because it is too large Load Diff

View File

@@ -85,4 +85,3 @@
{% endif %}
<create-calendar-form user="{{ user.id }}"></create-calendar-form>
<import-calendar-form user="{{ user.id }}"></import-calendar-form>

View File

@@ -33,6 +33,7 @@ use crate::routes::{
app_token::{route_delete_app_token, route_post_app_token},
calendar::{route_calendar, route_calendar_restore},
login::{route_get_login, route_post_login, route_post_logout},
timezones::route_timezones,
user::{route_get_home, route_root, route_user_named},
};
#[cfg(not(feature = "dev"))]
@@ -79,7 +80,11 @@ pub fn frontend_router<AP: AuthenticationProvider, CS: CalendarStore, AS: Addres
.route("/", get(route_root))
.nest("/user", user_router)
.route("/login", get(route_get_login).post(route_post_login::<AP>))
.route("/logout", post(route_post_logout));
.route("/logout", post(route_post_logout))
.route(
"/_timezones.json",
get(route_timezones).head(route_timezones),
);
#[cfg(not(feature = "dev"))]
let mut router = router.route_service("/assets/{*file}", EmbedService::<Assets>::default());

View File

@@ -4,4 +4,5 @@ pub mod app_token;
pub mod calendar;
pub mod calendars;
pub mod login;
pub mod timezones;
pub mod user;

View File

@@ -0,0 +1,39 @@
use headers::{CacheControl, ContentType, HeaderMapExt};
use http::{HeaderMap, HeaderValue, Method};
use itertools::Itertools;
use std::{sync::LazyLock, time::Duration};
static VTIMEZONES_JSON: LazyLock<String> = LazyLock::new(|| {
serde_json::to_string(
&vtimezones_rs::VTIMEZONES
.keys()
.sorted()
.collect::<Vec<_>>(),
)
.unwrap()
});
pub async fn route_timezones(method: Method) -> (HeaderMap, &'static str) {
let mut headers = HeaderMap::new();
headers.typed_insert(ContentType::json());
headers.insert(
"ETag",
HeaderValue::from_static(vtimezones_rs::IANA_TZDB_VERSION),
);
headers.typed_insert(CacheControl::new().with_max_age(Duration::from_hours(2)));
if method == Method::HEAD {
return (headers, "");
}
(headers, VTIMEZONES_JSON.as_str())
}
#[cfg(test)]
#[tokio::test]
async fn test_vtimezones_json() -> () {
// Since there's an unwrap make sure this doesn't fail
assert!(!VTIMEZONES_JSON.as_str().is_empty());
assert!(route_timezones(Method::HEAD).await.1.is_empty());
assert!(!route_timezones(Method::GET).await.1.is_empty());
}

View File

@@ -3,7 +3,7 @@ use async_trait::async_trait;
use chrono::NaiveDate;
use rustical_ical::CalendarObject;
#[derive(Default, Debug, Clone)]
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct CalendarQuery {
pub time_start: Option<NaiveDate>,
pub time_end: Option<NaiveDate>,

View File

@@ -313,7 +313,7 @@ impl Field {
}
}),
(FieldType::Text, false) => Some(quote! {
writer.write_event(Event::Text(BytesText::new(&self.#target_field_index)))?;
writer.write_event(Event::Text(BytesText::new(self.#target_field_index.as_ref())))?;
}),
(FieldType::Tag, true) => {
let field_name = self.xml_name();