mirror of
				https://github.com/jhbruhn/notes2ics.git
				synced 2025-10-31 16:25:59 +00:00 
			
		
		
		
	initial commit
This commit is contained in:
		
						commit
						497f7027d2
					
				
					 8 changed files with 2627 additions and 0 deletions
				
			
		
							
								
								
									
										122
									
								
								.github/workflows/release.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								.github/workflows/release.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,122 @@ | |||
| name: Create and publish a Docker image | ||||
| 
 | ||||
| on: | ||||
|   push: | ||||
|     branches: ['main'] | ||||
| 
 | ||||
| env: | ||||
|   REGISTRY: ghcr.io | ||||
|   IMAGE_NAME: ${{ github.repository }} | ||||
| 
 | ||||
| jobs: | ||||
|   build: | ||||
|     runs-on: ubuntu-latest | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         platform: | ||||
|           - linux/amd64 | ||||
|           - linux/arm/v7 | ||||
|           - linux/arm64 | ||||
|     steps: | ||||
|       - | ||||
|         name: Checkout | ||||
|         uses: actions/checkout@v3 | ||||
|       - | ||||
|         name: Docker meta | ||||
|         id: meta | ||||
|         uses: docker/metadata-action@v4 | ||||
|         with: | ||||
|           images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} | ||||
| 
 | ||||
|       - | ||||
|         name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v2 | ||||
|       - | ||||
|         name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v2 | ||||
|       - | ||||
|         name: Login to Docker Hub | ||||
|         uses: docker/login-action@v2 | ||||
|         with: | ||||
|           registry: ${{ env.REGISTRY }} | ||||
|           username: ${{ github.actor }} | ||||
|           password: ${{ secrets.GITHUB_TOKEN }} | ||||
|       - | ||||
|         name: Cache Docker layers | ||||
|         uses: actions/cache@v3 | ||||
|         with: | ||||
|           path: /tmp/.buildx-cache | ||||
|           key: ${{ runner.os }}-multi-${{ matrix.platform }}-buildx-${{ github.sha }} | ||||
|           restore-keys: | | ||||
|             ${{ runner.os }}-multi-${{ matrix.platform }}-buildx | ||||
|       - | ||||
|         name: Build and push by digest | ||||
|         id: build | ||||
|         uses: docker/build-push-action@v4 | ||||
|         with: | ||||
|           context: . | ||||
|           platforms: ${{ matrix.platform }} | ||||
|             #tags: ${{ steps.meta.outputs.tags }} | ||||
|           labels: ${{ steps.meta.outputs.labels }} | ||||
|           outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true | ||||
|           cache-from: type=local,src=/tmp/.buildx-cache | ||||
|           # Note the mode=max here | ||||
|           # More: https://github.com/moby/buildkit#--export-cache-options | ||||
|           # And: https://github.com/docker/buildx#--cache-tonametypetypekeyvalue | ||||
|           cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new | ||||
|       - | ||||
|         name: Export digest | ||||
|         run: | | ||||
|           mkdir -p /tmp/digests | ||||
|           digest="${{ steps.build.outputs.digest }}" | ||||
|           touch "/tmp/digests/${digest#sha256:}" | ||||
|       - | ||||
|         name: Upload digest | ||||
|         uses: actions/upload-artifact@v3 | ||||
|         with: | ||||
|           name: digests | ||||
|           path: /tmp/digests/* | ||||
|           if-no-files-found: error | ||||
|           retention-days: 1 | ||||
|       - name: Move cache | ||||
|         run: | | ||||
|           rm -rf /tmp/.buildx-cache | ||||
|           mv /tmp/.buildx-cache-new /tmp/.buildx-cache | ||||
|   merge: | ||||
|     runs-on: ubuntu-latest | ||||
|     needs: | ||||
|       - build | ||||
|     steps: | ||||
|       - | ||||
|         name: Download digests | ||||
|         uses: actions/download-artifact@v3 | ||||
|         with: | ||||
|           name: digests | ||||
|           path: /tmp/digests | ||||
|       - | ||||
|         name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v2 | ||||
|       - | ||||
|         name: Docker meta | ||||
|         id: meta | ||||
|         uses: docker/metadata-action@v4 | ||||
|         with: | ||||
|           images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} | ||||
|       - | ||||
|         name: Login to Docker Hub | ||||
|         uses: docker/login-action@v2 | ||||
|         with: | ||||
|           registry: ${{ env.REGISTRY }} | ||||
|           username: ${{ github.actor }} | ||||
|           password: ${{ secrets.GITHUB_TOKEN }} | ||||
|       - | ||||
|         name: Create manifest list and push | ||||
|         working-directory: /tmp/digests | ||||
|         run: | | ||||
|           docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ | ||||
|             $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) | ||||
|       - | ||||
|         name: Inspect image | ||||
|         run: | | ||||
|           docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} | ||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | |||
| /target | ||||
							
								
								
									
										2065
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										2065
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										20
									
								
								Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								Cargo.toml
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,20 @@ | |||
| [package] | ||||
| name = "notes2ics" | ||||
| version = "0.1.0" | ||||
| edition = "2021" | ||||
| 
 | ||||
| # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||||
| 
 | ||||
| [dependencies] | ||||
| tokio = { version = "1", features = ["full"] } | ||||
| warp = "0.3" | ||||
| reqwest = { version = "0.11", features = ["native-tls-vendored", "cookies"] } | ||||
| anyhow = "1" | ||||
| icalendar = { version = "0.15", features = ["chrono-tz"] } | ||||
| chrono = "0" | ||||
| dyn-fmt = "0.4.0" | ||||
| quick-xml = { version = "0.31", features = ["serialize"] } | ||||
| serde = { version = "1.0", features = ["derive"] } | ||||
| tracing-subscriber = { version = "0.3", features = ["env-filter"] } | ||||
| tracing = "0.1" | ||||
| envconfig = "0.10" | ||||
							
								
								
									
										37
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,37 @@ | |||
| FROM --platform=$BUILDPLATFORM rust:1.70 as cross | ||||
| ARG TARGETARCH | ||||
| COPY docker/platform.sh . | ||||
| RUN ./platform.sh # should write /.platform and /.compiler | ||||
| RUN rustup target add $(cat /.platform) | ||||
| RUN apt update && apt-get install -y unzip $(cat /.compiler) | ||||
| 
 | ||||
| WORKDIR ./ics-adapter | ||||
| ADD . ./ | ||||
| RUN cargo build --release --target $(cat /.platform) | ||||
| RUN cp ./target/$(cat /.platform)/release/ics-adapter /ics-adapter.bin # Get rid of this when build --out is stable | ||||
| 
 | ||||
| 
 | ||||
| FROM debian:buster-slim | ||||
| ARG APP=/usr/src/app | ||||
| 
 | ||||
| RUN apt-get update \ | ||||
|     && apt-get install -y ca-certificates tzdata \ | ||||
|     && rm -rf /var/lib/apt/lists/* | ||||
| 
 | ||||
| EXPOSE 3000 | ||||
| 
 | ||||
| ENV TZ=Etc/UTC \ | ||||
|     APP_USER=appuser | ||||
| 
 | ||||
| RUN groupadd $APP_USER \ | ||||
|     && useradd -g $APP_USER $APP_USER \ | ||||
|     && mkdir -p ${APP} | ||||
| 
 | ||||
| COPY --from=cross /ics-adapter.bin ${APP}/ics-adapter | ||||
| 
 | ||||
| RUN chown -R $APP_USER:$APP_USER ${APP} | ||||
| 
 | ||||
| USER $APP_USER | ||||
| WORKDIR ${APP} | ||||
| 
 | ||||
| CMD ["./ics-adapter"] | ||||
							
								
								
									
										12
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,12 @@ | |||
| services: | ||||
|   notes2ics: | ||||
|     image: ghcr.io/jbruhn/notes2ics:main | ||||
|     environment: | ||||
|       NOTES_USERNAME: <username> | ||||
|       NOTES_PASSWORD: <password> | ||||
|       NOTES_HOST: domino2.something.de | ||||
|       HOST: 0.0.0.0 | ||||
|       PORT: 3000 | ||||
|     ports: | ||||
|       - 3000:3000 | ||||
|     # http://localhost:3000/calendar/<username>?startDays=-7&endDays=31&filterInvites=true | ||||
							
								
								
									
										19
									
								
								docker/platform.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										19
									
								
								docker/platform.sh
									
									
									
									
									
										Executable file
									
								
							|  | @ -0,0 +1,19 @@ | |||
| #!/bin/bash | ||||
| 
 | ||||
| # Used in Docker build to set platform dependent variables | ||||
| 
 | ||||
| case $TARGETARCH in | ||||
| 
 | ||||
|     "amd64") | ||||
| 	echo "x86_64-unknown-linux-gnu" > /.platform | ||||
| 	echo "" > /.compiler  | ||||
| 	;; | ||||
|     "arm64")  | ||||
| 	echo "aarch64-unknown-linux-gnu" > /.platform | ||||
| 	echo "gcc-aarch64-linux-gnu" > /.compiler | ||||
| 	;; | ||||
|     "arm") | ||||
| 	echo "armv7-unknown-linux-gnueabihf" > /.platform | ||||
| 	echo "gcc-arm-linux-gnueabihf" > /.compiler | ||||
| 	;; | ||||
| esac | ||||
							
								
								
									
										351
									
								
								src/main.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										351
									
								
								src/main.rs
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,351 @@ | |||
| use dyn_fmt::AsStrFormatExt; | ||||
| use reqwest::ClientBuilder; | ||||
| use serde::Deserialize; | ||||
| use std::collections::HashMap; | ||||
| use std::convert::Infallible; | ||||
| use std::sync::Arc; | ||||
| use tracing_subscriber::fmt::format::FmtSpan; | ||||
| use warp::{Filter, Rejection, Reply}; | ||||
| use envconfig::Envconfig; | ||||
| 
 | ||||
| const DATETIME_FORMAT: &str = "%Y%m%dT%H%M%S,00%#z"; | ||||
| 
 | ||||
| const LOGIN_URL: &str = "names.nsf?Login"; | ||||
| const CALENDAR_URL: &str = "mail/{}.nsf/($Calendar)?ReadViewEntries&Count=-1&KeyType=time"; | ||||
| // StartKey
 | ||||
| // UntilKey
 | ||||
| 
 | ||||
| #[derive(Envconfig, Clone)] | ||||
| struct Config { | ||||
|     #[envconfig(from = "NOTES_USERNAME")] | ||||
|     pub notes_username: String, | ||||
| 
 | ||||
|     #[envconfig(from = "NOTES_PASSWORD")] | ||||
|     pub notes_password: String, | ||||
|     
 | ||||
|     #[envconfig(from = "NOTES_HOST")] | ||||
|     pub notes_host: String, | ||||
| 
 | ||||
|     #[envconfig(from = "PORT", default = "3000")] | ||||
|     pub port: u16, | ||||
| 
 | ||||
|     #[envconfig(from = "HOST", default = "0.0.0.0")] | ||||
|     pub host: String, | ||||
| } | ||||
| 
 | ||||
| async fn login(client: &reqwest::Client, host: &str, username: &str, password: &str) -> anyhow::Result<()> { | ||||
|     let mut params = HashMap::new(); | ||||
|     params.insert("Username", username); | ||||
|     params.insert("Password", password); | ||||
|     let target = format!("/{}", CALENDAR_URL.format(&[username])); | ||||
|     //let target = format!("https://{}{}", host, &target.as_str());
 | ||||
|     params.insert("RedirectTo", &target.as_str()); | ||||
|     let response = client | ||||
|         .post(format!("https://{}/{}", host, LOGIN_URL)) | ||||
|         .form(¶ms) | ||||
|         .send() | ||||
|         .await; | ||||
| 
 | ||||
|     let logged_in = response?.status() == 302; // redirecting to target ressource
 | ||||
|     if logged_in { | ||||
|         Ok(()) | ||||
|     } else { | ||||
|         anyhow::bail!("Invalid credentials!") | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| async fn load_calendar( | ||||
|     client: &reqwest::Client, | ||||
|     host: &str, | ||||
|     username: &str, | ||||
|     days_back: i64, | ||||
|     days_ahead: i64, | ||||
| ) -> anyhow::Result<String> { | ||||
|     let start = | ||||
|         (chrono::Utc::now() + chrono::Duration::days(days_back)).format("%Y%m%dT000001,00Z"); | ||||
|     let end = (chrono::Utc::now() + chrono::Duration::days(days_ahead)).format("%Y%m%dT000001,00Z"); | ||||
|     let target = CALENDAR_URL.format(&[username]); | ||||
|     let target = format!( | ||||
|         "https://{}/{}&StartKey={}&UntilKey={}", | ||||
|         host, | ||||
|         &target.as_str(), | ||||
|         start, | ||||
|         end | ||||
|     ); | ||||
|     let response = client.get(target).send().await?; | ||||
|     let text = response.text().await?; | ||||
|     let logged_in = text.contains("viewentries"); | ||||
|     if logged_in { | ||||
|         Ok(text) | ||||
|     } else { | ||||
|         anyhow::bail!("Not logged in perhaps?") | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug)] | ||||
| struct CustomReject(anyhow::Error); | ||||
| 
 | ||||
| impl warp::reject::Reject for CustomReject {} | ||||
| 
 | ||||
| pub(crate) fn custom_reject(error: impl Into<anyhow::Error>) -> warp::Rejection { | ||||
|     warp::reject::custom(CustomReject(error.into())) | ||||
| } | ||||
| 
 | ||||
| async fn handler( | ||||
|     calendar_user: String, | ||||
|     client: Arc<reqwest::Client>, | ||||
|     config: Config, | ||||
|     params: HashMap<String, String>, | ||||
| ) -> anyhow::Result<impl Reply, Rejection> { | ||||
|     let days_back = params | ||||
|         .get("startDays") | ||||
|         .and_then(|x| x.parse().ok()) | ||||
|         .unwrap_or(-14); | ||||
|     let days_ahead = params | ||||
|         .get("endDays") | ||||
|         .and_then(|x| x.parse().ok()) | ||||
|         .unwrap_or(31); | ||||
|     let filter_invites = params | ||||
|         .get("filterInvites") | ||||
|         .and_then(|x| x.parse().ok()) | ||||
|         .unwrap_or(true); | ||||
|     let calendar_str = load_calendar(&client, &config.notes_host, &calendar_user, days_back, days_ahead).await; | ||||
|     if let Ok(calendar_str) = calendar_str { | ||||
|         tracing::info!("Got calendar for {}", calendar_user); | ||||
|         Ok(parse_calendar(&calendar_str, filter_invites) | ||||
|             .map_err(custom_reject)? | ||||
|             .to_string()) | ||||
|     } else { | ||||
|         tracing::info!("Logging in again"); | ||||
|         login(&client, &config.notes_host, &config.notes_username, &config.notes_password) | ||||
|             .await | ||||
|             .map_err(custom_reject)?; | ||||
|         tracing::info!("Logged in, loading calendar"); | ||||
|         Ok(parse_calendar( | ||||
|             &load_calendar(&client, &config.notes_host, &calendar_user, days_back, days_ahead) | ||||
|                 .await | ||||
|                 .map_err(custom_reject)?, | ||||
|             filter_invites, | ||||
|         ) | ||||
|         .map_err(custom_reject)? | ||||
|         .to_string()) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| fn with_client( | ||||
|     client: Arc<reqwest::Client>, | ||||
| ) -> impl Filter<Extract = (Arc<reqwest::Client>,), Error = Infallible> + Clone { | ||||
|     warp::any().map(move || client.clone()) | ||||
| } | ||||
| 
 | ||||
| fn with_config( | ||||
|     config: Config | ||||
| ) -> impl Filter<Extract = (Config,), Error = Infallible> + Clone { | ||||
|     warp::any().map(move || config.clone()) | ||||
| } | ||||
| 
 | ||||
| #[tokio::main] | ||||
| async fn main() { | ||||
|     let filter = std::env::var("RUST_LOG") | ||||
|         .unwrap_or_else(|_| "notes2ics=debug,tracing=info,warp=debug".to_owned()); | ||||
|     tracing_subscriber::fmt() | ||||
|         .with_env_filter(filter) | ||||
|         .with_span_events(FmtSpan::CLOSE) | ||||
|         .init(); | ||||
| 
 | ||||
|     let config = Config::init_from_env().unwrap(); | ||||
| 
 | ||||
|     let client = ClientBuilder::new() | ||||
|         .cookie_store(true) | ||||
|         .redirect(reqwest::redirect::Policy::none()) | ||||
|         .build() | ||||
|         .unwrap(); | ||||
|     let client = Arc::new(client); | ||||
|     let get_calendar_route = warp::path!("calendar" / String) | ||||
|         .and(with_client(client)) | ||||
|         .and(with_config(config.clone())) | ||||
|         .and(warp::query::<HashMap<String, String>>()) | ||||
|         .and_then(handler); | ||||
|     warp::serve( | ||||
|         warp::get() | ||||
|             .and(get_calendar_route) | ||||
|             .with(warp::trace::request()), | ||||
|     ) | ||||
|     .run((config.host.parse::<std::net::Ipv4Addr>().unwrap(), config.port)) | ||||
|     .await | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize, Debug)] | ||||
| struct CalendarDocument { | ||||
|     #[serde(rename = "@timestamp")] | ||||
|     _timestamp: String, | ||||
|     #[serde(rename = "$value", default)] | ||||
|     viewentries: Vec<ViewEntry>, | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize, Debug)] | ||||
| struct ViewEntry { | ||||
|     #[serde(rename = "$value", default)] | ||||
|     entry_datas: Vec<EntryData>, | ||||
|     #[serde(rename = "@unid", default)] | ||||
|     uid: String, | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize, Debug)] | ||||
| struct EntryData { | ||||
|     #[serde(rename = "@name")] | ||||
|     name: String, | ||||
|     #[serde(rename = "$value")] | ||||
|     data: DataList, | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize, Debug)] | ||||
| enum DataList { | ||||
|     #[serde(rename = "datetimelist")] | ||||
|     DateTimeList { | ||||
|         #[serde(rename = "$value")] | ||||
|         datetimes: Vec<DataList>, | ||||
|     }, | ||||
|     #[serde(rename = "numberlist")] | ||||
|     NumberList { | ||||
|         #[serde(rename = "$value")] | ||||
|         _number: Vec<DataList>, | ||||
|     }, | ||||
|     #[serde(rename = "textlist")] | ||||
|     TextList { | ||||
|         #[serde(rename = "$value")] | ||||
|         texts: Vec<DataList>, | ||||
|     }, | ||||
|     #[serde(rename = "datetime")] | ||||
|     DateTime(String), | ||||
|     #[serde(rename = "number")] | ||||
|     Number(i32), | ||||
|     #[serde(rename = "text")] | ||||
|     Text(String), | ||||
|     #[serde(rename = "$text")] | ||||
|     Other(String), | ||||
| } | ||||
| 
 | ||||
| impl DataList { | ||||
|     fn try_first_text(&self, skip: usize) -> anyhow::Result<String> { | ||||
|         Ok(match self { | ||||
|             Self::Text(t) if skip == 0 => t.to_string(), | ||||
|             Self::TextList { texts } => texts | ||||
|                 .iter() | ||||
|                 .skip(skip) | ||||
|                 .next() | ||||
|                 .ok_or(anyhow::anyhow!("empty"))? | ||||
|                 .try_first_text(0)?, | ||||
|             _ => anyhow::bail!("Wrong format"), | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     fn try_first_datetime(&self, skip: usize) -> anyhow::Result<String> { | ||||
|         Ok(match self { | ||||
|             Self::DateTime(v) if skip == 0 => v.to_string(), | ||||
|             Self::DateTimeList { datetimes } => datetimes | ||||
|                 .iter() | ||||
|                 .skip(skip) | ||||
|                 .next() | ||||
|                 .ok_or(anyhow::anyhow!("empty"))? | ||||
|                 .try_first_datetime(0)?, | ||||
|             _ => anyhow::bail!("Wrong format"), | ||||
|         }) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| fn parse_calendar(text: &str, filter_invites: bool) -> anyhow::Result<icalendar::Calendar> { | ||||
|     let doc: CalendarDocument = quick_xml::de::from_str(text)?; | ||||
| 
 | ||||
|     let mut calendar = icalendar::Calendar::default(); | ||||
| 
 | ||||
|     for view_entry in doc.viewentries { | ||||
|         let name = view_entry | ||||
|             .entry_datas | ||||
|             .iter() | ||||
|             .filter(|x| x.name == "$147") | ||||
|             .next() | ||||
|             .ok_or(anyhow::anyhow!("No summary")) | ||||
|             .and_then(|x| x.data.try_first_text(0)); | ||||
|         let location = view_entry | ||||
|             .entry_datas | ||||
|             .iter() | ||||
|             .filter(|x| x.name == "$147") | ||||
|             .next() | ||||
|             .ok_or(anyhow::anyhow!("No summary")) | ||||
|             .and_then(|x| x.data.try_first_text(1)); | ||||
|         let start = view_entry | ||||
|             .entry_datas | ||||
|             .iter() | ||||
|             .filter(|x| x.name == "$144") | ||||
|             .next() | ||||
|             .ok_or(anyhow::anyhow!("No Start")) | ||||
|             .and_then(|x| x.data.try_first_datetime(0)); | ||||
|         let end = view_entry | ||||
|             .entry_datas | ||||
|             .iter() | ||||
|             .filter(|x| x.name == "$146") | ||||
|             .next() | ||||
|             .ok_or(anyhow::anyhow!("No End")) | ||||
|             .and_then(|x| x.data.try_first_datetime(0)); | ||||
|         let day = view_entry | ||||
|             .entry_datas | ||||
|             .iter().filter(|x| x.name == "$134") | ||||
|             .next() | ||||
|             .ok_or(anyhow::anyhow!("No Day")) | ||||
|             .and_then(|x| x.data.try_first_datetime(0)); | ||||
| 
 | ||||
|         use icalendar::Component; | ||||
|         use icalendar::EventLike; | ||||
|         let entry: anyhow::Result<icalendar::Event> = (|| { | ||||
|             let day = day?; | ||||
|             let mut new_entry = icalendar::Event::new(); | ||||
|             new_entry.uid(&format!("{}-{}", &view_entry.uid, &day)); // Repeat events
 | ||||
|                                                                      // get the same uid,
 | ||||
|                                                                      // but we don't know
 | ||||
|                                                                      // about the repeats here
 | ||||
|                                                                      // so every date is unique.
 | ||||
|             let summary = name?; | ||||
|             if filter_invites && summary.starts_with("Einladung: ") { | ||||
|                 anyhow::bail!("Not accepted"); | ||||
|             } | ||||
|             new_entry.summary(&summary); | ||||
|             if let Ok(start) = start { | ||||
|                 let end = end?; | ||||
|                 new_entry.starts::<icalendar::CalendarDateTime>( | ||||
|                     chrono::DateTime::parse_from_str(&start, DATETIME_FORMAT)? | ||||
|                         .with_timezone(&chrono::Utc) | ||||
|                         .into(), | ||||
|                 ); | ||||
|                 new_entry.ends::<icalendar::CalendarDateTime>( | ||||
|                     chrono::DateTime::parse_from_str(&end, DATETIME_FORMAT)? | ||||
|                         .with_timezone(&chrono::Utc) | ||||
|                         .into(), | ||||
|                 ); | ||||
|             } else { | ||||
|                 // Seems to be all day
 | ||||
| 
 | ||||
|                 new_entry.starts::<chrono::NaiveDate>( | ||||
|                         chrono::DateTime::parse_from_str(&day, DATETIME_FORMAT)? | ||||
|                         .with_timezone(&chrono::Utc) | ||||
|                         .date_naive() | ||||
|                         .into(), | ||||
|                     ); | ||||
| 
 | ||||
|             } | ||||
| 
 | ||||
|             if let Ok(location) = location { | ||||
|                 new_entry.location(&location); | ||||
|             } | ||||
| 
 | ||||
|             Ok(new_entry.done()) | ||||
|         })(); | ||||
|         if let Ok(entry) = entry { | ||||
|             calendar.push(entry); | ||||
|         } else { | ||||
|             tracing::warn!("Invalid ViewEntry: {:?}", entry); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     Ok(calendar) | ||||
| } | ||||
		Loading…
	
		Reference in a new issue