cal #735
@ -17,7 +17,8 @@ CREATE TABLE IF NOT EXISTS "user" (
|
|||||||
"phone" text,
|
"phone" text,
|
||||||
"address" text,
|
"address" text,
|
||||||
"family_id" INTEGER REFERENCES family(id),
|
"family_id" INTEGER REFERENCES family(id),
|
||||||
"membership_pdf" BLOB
|
"membership_pdf" BLOB,
|
||||||
|
"user_token" TEXT NOT NULL DEFAULT (lower(hex(randomblob(16))))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "family" (
|
CREATE TABLE IF NOT EXISTS "family" (
|
||||||
|
@ -183,6 +183,17 @@ INNER JOIN trip_details ON planned_event.trip_details_id = trip_details.id",
|
|||||||
.unwrap() //TODO: fixme
|
.unwrap() //TODO: fixme
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn all_with_user(db: &SqlitePool, user: &User) -> Vec<Event> {
|
||||||
|
let mut ret = Vec::new();
|
||||||
|
let events = Self::all(db).await;
|
||||||
|
for event in events {
|
||||||
|
if event.is_rower_registered(db, user).await {
|
||||||
|
ret.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ret
|
||||||
|
}
|
||||||
|
|
||||||
//TODO: add tests
|
//TODO: add tests
|
||||||
pub async fn is_rower_registered(&self, db: &SqlitePool, user: &User) -> bool {
|
pub async fn is_rower_registered(&self, db: &SqlitePool, user: &User) -> bool {
|
||||||
let is_rower = sqlx::query!(
|
let is_rower = sqlx::query!(
|
||||||
@ -394,16 +405,23 @@ WHERE trip_details.id=?
|
|||||||
|
|
||||||
let events = Event::all(db).await;
|
let events = Event::all(db).await;
|
||||||
for event in events {
|
for event in events {
|
||||||
let mut vevent =
|
calendar.add_event(event.get_vevent(db).await);
|
||||||
ics::Event::new(format!("{}@rudernlinz.at", event.id), "19900101T180000");
|
}
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
write!(&mut buf, "{}", calendar).unwrap();
|
||||||
|
String::from_utf8(buf).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get_vevent(self, db: &SqlitePool) -> ics::Event {
|
||||||
|
let mut vevent = ics::Event::new(format!("{}@rudernlinz.at", self.id), "19900101T180000");
|
||||||
vevent.push(DtStart::new(format!(
|
vevent.push(DtStart::new(format!(
|
||||||
"{}T{}00",
|
"{}T{}00",
|
||||||
event.day.replace('-', ""),
|
self.day.replace('-', ""),
|
||||||
event.planned_starting_time.replace(':', "")
|
self.planned_starting_time.replace(':', "")
|
||||||
)));
|
)));
|
||||||
let tripdetails = event.trip_details(db).await;
|
let tripdetails = self.trip_details(db).await;
|
||||||
let mut name = String::new();
|
let mut name = String::new();
|
||||||
if event.is_cancelled() {
|
if self.is_cancelled() {
|
||||||
name.push_str("ABGESAGT");
|
name.push_str("ABGESAGT");
|
||||||
if let Some(notes) = &tripdetails.notes {
|
if let Some(notes) = &tripdetails.notes {
|
||||||
if !notes.is_empty() {
|
if !notes.is_empty() {
|
||||||
@ -413,17 +431,13 @@ WHERE trip_details.id=?
|
|||||||
|
|
||||||
name.push_str("! :-( ");
|
name.push_str("! :-( ");
|
||||||
}
|
}
|
||||||
name.push_str(&format!("{} ", event.name));
|
name.push_str(&format!("{} ", self.name));
|
||||||
|
|
||||||
if let Some(triptype) = tripdetails.triptype(db).await {
|
if let Some(triptype) = tripdetails.triptype(db).await {
|
||||||
name.push_str(&format!("• {} ", triptype.name))
|
name.push_str(&format!("• {} ", triptype.name))
|
||||||
}
|
}
|
||||||
vevent.push(Summary::new(name));
|
vevent.push(Summary::new(name));
|
||||||
calendar.add_event(vevent);
|
vevent
|
||||||
}
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
write!(&mut buf, "{}", calendar).unwrap();
|
|
||||||
String::from_utf8(buf).unwrap()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn trip_details(&self, db: &SqlitePool) -> TripDetails {
|
pub async fn trip_details(&self, db: &SqlitePool) -> TripDetails {
|
||||||
|
@ -75,7 +75,7 @@ GROUP BY family.id;"
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn members(&self, db: &SqlitePool) -> Vec<User> {
|
pub async fn members(&self, db: &SqlitePool) -> Vec<User> {
|
||||||
sqlx::query_as!(User, "SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id FROM user WHERE family_id = ?", self.id)
|
sqlx::query_as!(User, "SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id, user_token FROM user WHERE family_id = ?", self.id)
|
||||||
.fetch_all(db)
|
.fetch_all(db)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
27
src/model/personal/cal.rs
Normal file
27
src/model/personal/cal.rs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use ics::{components::Property, ICalendar};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::model::{event::Event, trip::Trip, user::User};
|
||||||
|
|
||||||
|
pub(crate) async fn get_personal_cal(db: &SqlitePool, user: &User) -> String {
|
||||||
|
let mut calendar = ICalendar::new("2.0", "ics-rs");
|
||||||
|
calendar.push(Property::new(
|
||||||
|
"X-WR-CALNAME",
|
||||||
|
"Donau Linz - Deine Ausfahrten",
|
||||||
|
));
|
||||||
|
|
||||||
|
let events = Event::all_with_user(db, user).await;
|
||||||
|
for event in events {
|
||||||
|
calendar.add_event(event.get_vevent(db).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
let trips = Trip::all_with_user(db, user).await;
|
||||||
|
for trip in trips {
|
||||||
|
calendar.add_event(trip.get_vevent(db).await);
|
||||||
|
}
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
write!(&mut buf, "{}", calendar).unwrap();
|
||||||
|
String::from_utf8(buf).unwrap()
|
||||||
|
}
|
@ -5,6 +5,7 @@ use sqlx::SqlitePool;
|
|||||||
|
|
||||||
use super::{logbook::Logbook, stat::Stat, user::User};
|
use super::{logbook::Logbook, stat::Stat, user::User};
|
||||||
|
|
||||||
|
pub(crate) mod cal;
|
||||||
pub(crate) mod equatorprice;
|
pub(crate) mod equatorprice;
|
||||||
pub(crate) mod rowingbadge;
|
pub(crate) mod rowingbadge;
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@ impl Rower {
|
|||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
User,
|
User,
|
||||||
"
|
"
|
||||||
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id
|
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id, user_token
|
||||||
FROM user
|
FROM user
|
||||||
WHERE id in (SELECT rower_id FROM rower WHERE logbook_id=?)
|
WHERE id in (SELECT rower_id FROM rower WHERE logbook_id=?)
|
||||||
",
|
",
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
use chrono::{Local, NaiveDate};
|
use chrono::{Local, NaiveDate};
|
||||||
|
use ics::properties::{DtStart, Summary};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
@ -9,6 +10,7 @@ use super::{
|
|||||||
tripdetails::TripDetails,
|
tripdetails::TripDetails,
|
||||||
triptype::TripType,
|
triptype::TripType,
|
||||||
user::{CoxUser, User},
|
user::{CoxUser, User},
|
||||||
|
usertrip::UserTrip,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Serialize, Clone, Debug)]
|
#[derive(Serialize, Clone, Debug)]
|
||||||
@ -123,6 +125,61 @@ WHERE trip_details.id=?
|
|||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get_vevent(self, db: &SqlitePool) -> ics::Event {
|
||||||
|
let mut vevent = ics::Event::new(format!("{}@rudernlinz.at", self.id), "19900101T180000");
|
||||||
|
vevent.push(DtStart::new(format!(
|
||||||
|
"{}T{}00",
|
||||||
|
self.day.replace('-', ""),
|
||||||
|
self.planned_starting_time.replace(':', "")
|
||||||
|
)));
|
||||||
|
let mut name = String::new();
|
||||||
|
if self.is_cancelled() {
|
||||||
|
name.push_str("ABGESAGT");
|
||||||
|
if let Some(notes) = &self.notes {
|
||||||
|
if !notes.is_empty() {
|
||||||
|
name.push_str(&format!(" (Grund: {notes})"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
name.push_str("! :-( ");
|
||||||
|
}
|
||||||
|
name.push_str(&format!("Ruderausfahrt mit {} ", self.cox_name));
|
||||||
|
|
||||||
|
vevent.push(Summary::new(name));
|
||||||
|
vevent
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn all(db: &SqlitePool) -> Vec<Self> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
Self,
|
||||||
|
"
|
||||||
|
SELECT trip.id, cox_id, user.name as cox_name, trip_details_id, planned_starting_time, max_people, day, trip_details.notes, allow_guests, trip_type_id, always_show, is_locked
|
||||||
|
FROM trip
|
||||||
|
INNER JOIN trip_details ON trip.trip_details_id = trip_details.id
|
||||||
|
INNER JOIN user ON trip.cox_id = user.id
|
||||||
|
",
|
||||||
|
)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
.unwrap() //TODO: fixme
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn all_with_user(db: &SqlitePool, user: &User) -> Vec<Self> {
|
||||||
|
let mut ret = Vec::new();
|
||||||
|
let trips = Self::all(db).await;
|
||||||
|
for trip in trips {
|
||||||
|
if let Some(trip_details_id) = trip.trip_details_id {
|
||||||
|
if UserTrip::find_by_userid_and_trip_detail_id(db, user.id, trip_details_id)
|
||||||
|
.await
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
ret.push(trip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ret
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option<Self> {
|
pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option<Self> {
|
||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
Self,
|
Self,
|
||||||
@ -370,6 +427,10 @@ WHERE day=?
|
|||||||
trips.retain(|e| e.trip.always_show);
|
trips.retain(|e| e.trip.always_show);
|
||||||
trips
|
trips
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_cancelled(&self) -> bool {
|
||||||
|
self.max_people == 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
@ -42,6 +42,7 @@ pub struct User {
|
|||||||
pub phone: Option<String>,
|
pub phone: Option<String>,
|
||||||
pub address: Option<String>,
|
pub address: Option<String>,
|
||||||
pub family_id: Option<i64>,
|
pub family_id: Option<i64>,
|
||||||
|
pub user_token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@ -493,7 +494,7 @@ ASKÖ Ruderverein Donau Linz", self.name),
|
|||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
Self,
|
Self,
|
||||||
"
|
"
|
||||||
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id
|
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id, user_token
|
||||||
FROM user
|
FROM user
|
||||||
WHERE id like ?
|
WHERE id like ?
|
||||||
",
|
",
|
||||||
@ -508,7 +509,7 @@ WHERE id like ?
|
|||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
Self,
|
Self,
|
||||||
"
|
"
|
||||||
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id
|
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id, user_token
|
||||||
FROM user
|
FROM user
|
||||||
WHERE id like ?
|
WHERE id like ?
|
||||||
",
|
",
|
||||||
@ -525,7 +526,7 @@ WHERE id like ?
|
|||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
Self,
|
Self,
|
||||||
"
|
"
|
||||||
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id
|
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id, user_token
|
||||||
FROM user
|
FROM user
|
||||||
WHERE lower(name)=?
|
WHERE lower(name)=?
|
||||||
",
|
",
|
||||||
@ -567,7 +568,7 @@ WHERE lower(name)=?
|
|||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
Self,
|
Self,
|
||||||
"
|
"
|
||||||
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id
|
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id, user_token
|
||||||
FROM user
|
FROM user
|
||||||
WHERE deleted = 0
|
WHERE deleted = 0
|
||||||
ORDER BY last_access DESC
|
ORDER BY last_access DESC
|
||||||
@ -589,7 +590,7 @@ ORDER BY last_access DESC
|
|||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
Self,
|
Self,
|
||||||
"
|
"
|
||||||
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id
|
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id, user_token
|
||||||
FROM user u
|
FROM user u
|
||||||
JOIN user_role ur ON u.id = ur.user_id
|
JOIN user_role ur ON u.id = ur.user_id
|
||||||
WHERE ur.role_id = ? AND deleted = 0
|
WHERE ur.role_id = ? AND deleted = 0
|
||||||
@ -605,14 +606,14 @@ ORDER BY name;
|
|||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
Self,
|
Self,
|
||||||
"
|
"
|
||||||
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id FROM user
|
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id, user_token FROM user
|
||||||
WHERE family_id IS NOT NULL
|
WHERE family_id IS NOT NULL
|
||||||
GROUP BY family_id
|
GROUP BY family_id
|
||||||
|
|
||||||
UNION
|
UNION
|
||||||
|
|
||||||
-- Select users with a null family_id, without grouping
|
-- Select users with a null family_id, without grouping
|
||||||
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id FROM user
|
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id, user_token FROM user
|
||||||
WHERE family_id IS NULL;
|
WHERE family_id IS NULL;
|
||||||
"
|
"
|
||||||
)
|
)
|
||||||
@ -625,7 +626,7 @@ WHERE family_id IS NULL;
|
|||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
Self,
|
Self,
|
||||||
"
|
"
|
||||||
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id
|
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id, user_token
|
||||||
FROM user
|
FROM user
|
||||||
WHERE deleted = 0 AND dob != '' and weight != '' and sex != ''
|
WHERE deleted = 0 AND dob != '' and weight != '' and sex != ''
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
@ -640,7 +641,7 @@ ORDER BY name
|
|||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
Self,
|
Self,
|
||||||
"
|
"
|
||||||
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id
|
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id, user_token
|
||||||
FROM user
|
FROM user
|
||||||
WHERE deleted = 0 AND (SELECT COUNT(*) FROM user_role WHERE user_id=user.id AND role_id = (SELECT id FROM role WHERE name = 'cox')) > 0
|
WHERE deleted = 0 AND (SELECT COUNT(*) FROM user_role WHERE user_id=user.id AND role_id = (SELECT id FROM role WHERE name = 'cox')) > 0
|
||||||
ORDER BY last_access DESC
|
ORDER BY last_access DESC
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
use rocket::{get, http::ContentType, routes, Route, State};
|
use rocket::{get, http::ContentType, routes, Route, State};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use crate::model::event::Event;
|
use crate::model::{event::Event, personal::cal::get_personal_cal, user::User};
|
||||||
|
|
||||||
#[get("/cal")]
|
#[get("/cal")]
|
||||||
async fn cal(db: &State<SqlitePool>) -> (ContentType, String) {
|
async fn cal(db: &State<SqlitePool>) -> (ContentType, String) {
|
||||||
@ -9,8 +9,25 @@ async fn cal(db: &State<SqlitePool>) -> (ContentType, String) {
|
|||||||
(ContentType::Calendar, Event::get_ics_feed(db).await)
|
(ContentType::Calendar, Event::get_ics_feed(db).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[get("/cal/personal/<user_id>/<uuid>")]
|
||||||
|
async fn cal_registered(
|
||||||
|
db: &State<SqlitePool>,
|
||||||
|
user_id: i32,
|
||||||
|
uuid: &str,
|
||||||
|
) -> Result<(ContentType, String), String> {
|
||||||
|
let Some(user) = User::find_by_id(db, user_id).await else {
|
||||||
|
return Err("Invalid".into());
|
||||||
|
};
|
||||||
|
|
||||||
|
if &user.user_token != uuid {
|
||||||
|
return Err("Invalid".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((ContentType::Calendar, get_personal_cal(db, &user).await))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
routes![cal]
|
routes![cal, cal_registered]
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
@ -213,6 +213,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="py-3">
|
||||||
|
<p>
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
<span class="text-xl"> 📅 </span> Kalender
|
||||||
|
</summary>
|
||||||
|
<p class="mt-3">
|
||||||
|
Du möchtest immer up-to-date mit den Events und Ausfahrten bleiben? Wir bieten 3 verschiedene Arten von Kalender an:
|
||||||
|
</p>
|
||||||
|
<ol class="list-decimal ml-5 my-3">
|
||||||
|
<li>
|
||||||
|
<strong>Alle Events und Ausfahrten</strong>, zu denen du dich angemeldet hast: <a class="underline"
|
||||||
|
href="https://app.rudernlinz.at/cal/personal/{{ loggedin_user.id }}/{{ loggedin_user.user_token }}">https://app.rudernlinz.at/cal/personal/{{ loggedin_user.id }}/{{ loggedin_user.user_token }}</a>
|
||||||
|
<br />
|
||||||
|
<small>Dieser Link enthält einen zufällig generierten Teil, damit nur du (und jene, denen du diesen Link weitergibst) Zugang zu diesen Daten hast.</small>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Allgemeiner Kalender</strong>, zB save-the-dates (Wanderfahrten, ...): <a href="https://rudernlinz.at/cal" class="underline">https://rudernlinz.at/cal</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Alle Events</strong>: <a class="underline" href="https://app.rudernlinz.at/cal">https://app.rudernlinz.at/cal</a>
|
||||||
|
<br />
|
||||||
|
<small>Beachte, dass dieser Kalender keine Ausfahrten enthält, die von einzelnen Steuerpersonen augeschrieben werden. Dieser Kalender wird zB auf <a href="https://rudernlinz.at/termine" class="underline">https://rudernlinz.at/termine</a> verwendet und wir möchten keine persönlichen Daten (Namen etc.) leaken.</small>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
Du kannst die Kalender einfach in deinen Kalender als "externen Kalender" synchronisieren. Die genauen Schritte hängen von deiner verwendeten Software ab.
|
||||||
|
</details>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
|
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
|
||||||
|
Loading…
Reference in New Issue
Block a user