feat: calendar connections
Some checks failed
TrafficCue CI / check (push) Failing after 1m39s
TrafficCue CI / build (push) Successful in 10m3s
TrafficCue CI / build-android (push) Successful in 25m18s

This commit is contained in:
2025-10-11 16:00:12 +02:00
parent a0ea5a83a5
commit 60e074a70f
7 changed files with 284 additions and 1 deletions

View File

@ -78,7 +78,8 @@
"settings": { "settings": {
"header": "Settings", "header": "Settings",
"general": "General", "general": "General",
"map": "Map" "map": "Map",
"connections": "Connections"
}, },
"info": { "info": {
"dropped": "Dropped Pin", "dropped": "Dropped Pin",
@ -148,6 +149,14 @@
"header": "Nearby Points of Interest", "header": "Nearby Points of Interest",
"no-poi": "No points of interest found nearby.", "no-poi": "No points of interest found nearby.",
"loading": "Loading nearby points of interest..." "loading": "Loading nearby points of interest..."
},
"calendar": {
"header": "Calendar",
"add": "Add Calendar",
"connect": "Connect Calendar",
"probing-server": "Probing server...",
"discovering-calendars": "Discovering calendars...",
"choose": "Choose calendars to add:"
} }
}, },
"unsave": "Unsave", "unsave": "Unsave",
@ -164,5 +173,17 @@
"routing": { "routing": {
"off-route": "You went off route", "off-route": "You went off route",
"back-on-route": "You are back on route" "back-on-route": "You are back on route"
},
"open": "Open",
"submit": "Submit",
"done": "Done",
"delete": "Delete",
"calendar": {
"location": "Location",
"no-location": "No location",
"start": "Start",
"end": "End",
"no-start": "No start",
"no-end": "No end"
} }
} }

View File

@ -44,6 +44,7 @@
"onboarding-vehicles": "onboarding/OnboardingVehiclesSidebar", "onboarding-vehicles": "onboarding/OnboardingVehiclesSidebar",
"nearby-poi": "NearbyPOISidebar", "nearby-poi": "NearbyPOISidebar",
licenses: "settings/LicensesSidebar", licenses: "settings/LicensesSidebar",
calendar: "settings/CalendarSidebar",
}; };
const fullscreen: Record<string, boolean> = { const fullscreen: Record<string, boolean> = {
@ -62,6 +63,7 @@
"onboarding-vehicles": true, "onboarding-vehicles": true,
"nearby-poi": false, "nearby-poi": false,
licenses: true, licenses: true,
calendar: true,
}; };
let isDragging = false; let isDragging = false;

View File

@ -0,0 +1,74 @@
<script lang="ts">
import Button from "$lib/components/ui/button/button.svelte";
import * as Card from "$lib/components/ui/card";
import { fetchEvents, type DAVCalendar, type DAVCredentials, type DAVEvent } from "$lib/services/CalDAV";
import { search } from "$lib/services/Search";
import { onMount } from "svelte";
import { map, pin } from "../map.svelte";
import { m } from "$lang/messages";
let events: DAVEvent[] = $state([]);
onMount(async () => {
const calendars = localStorage.getItem("calendars");
if(!calendars) return;
const parsedCalendars: (DAVCalendar & { credentials: DAVCredentials })[] = JSON.parse(calendars);
for(const calendar of parsedCalendars) {
const calendarEvents = await fetchEvents(calendar, calendar.credentials);
events.push(...calendarEvents);
}
})
</script>
<div id="events">
{#each events as event ((event.start + "" || "") + (event.end + "" || ""))}
<Card.Root>
<Card.Header>
<Card.Title>{event.summary}</Card.Title>
{#if event.description}
<Card.Description>{event.description}</Card.Description>
{/if}
</Card.Header>
<Card.Content>
<p>
<strong>{m["calendar.start"]()}:</strong> {event.start?.toLocaleString("de-DE") || m["calendar.no-start"]()}
</p>
<p>
<strong>{m["calendar.end"]()}:</strong> {event.end?.toLocaleString("de-DE") || m["calendar.no-end"]()}
</p>
<p>
{#if event.location}
<strong>{m["calendar.location"]()}:</strong> {event.location}
{:else}
<strong>{m["calendar.location"]()}:</strong> {m["calendar.no-location"]()}
{/if}
</p>
</Card.Content>
<Card.Footer>
<Button onclick={async () => {
if(!event.location) return;
const features = await search(event.location, 0, 0);
if(features.length == 0) {
return void alert("Can't find location");
}
const lat = features[0].geometry.coordinates[1];
const lng = features[0].geometry.coordinates[0];
pin.dropPin(lat, lng);
pin.showInfo();
map.value?.flyTo({
center: [lng, lat],
zoom: 19,
});
}}>{m.open()}</Button>
</Card.Footer>
</Card.Root>
{/each}
</div>
<style>
#events {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
</style>

View File

@ -9,6 +9,7 @@
import * as Card from "$lib/components/ui/card"; import * as Card from "$lib/components/ui/card";
import SavedRoutes from "../main/SavedRoutes.svelte"; import SavedRoutes from "../main/SavedRoutes.svelte";
import SavedLocations from "../main/SavedLocations.svelte"; import SavedLocations from "../main/SavedLocations.svelte";
import Calendar from "../main/Calendar.svelte";
</script> </script>
<SavedLocations /> <SavedLocations />
@ -49,6 +50,8 @@
</Button> </Button>
</div> </div>
<Calendar />
{#if !window.__TAURI__} {#if !window.__TAURI__}
<Card.Root style="margin-top: 1rem;"> <Card.Root style="margin-top: 1rem;">
<Card.Header> <Card.Header>

View File

@ -0,0 +1,132 @@
<script lang="ts">
import { m } from "$lang/messages";
import { CalendarPlusIcon } from "@lucide/svelte";
import SidebarHeader from "../SidebarHeader.svelte";
import SettingsButton from "./SettingsButton.svelte";
import * as Drawer from "$lib/components/ui/drawer";
import { Button } from "$lib/components/ui/button";
import Input from "$lib/components/ui/input/input.svelte";
import { fetchCalendars, findScheme, type AuthScheme, type DAVCalendar, type DAVCredentials } from "$lib/services/CalDAV";
import { onMount } from "svelte";
let calendars: (DAVCalendar & {credentials: DAVCredentials})[] = $state([]);
let calDavDrawerOpen = $state(false);
let calDavLoading = $state(false);
let calDavUrl = $state("");
let calDavUsername = $state("");
let calDavPassword = $state("");
let calDavCalendars: DAVCalendar[] = $state([]);
let calDavScheme: AuthScheme;
let calDavState = $state("");
async function fetchCalDav() {
calDavState = m["sidebar.calendar.probing-server"]();
calDavScheme = await findScheme(calDavUrl);
calDavState = m["sidebar.calendar.discovering-calendars"]();
calDavCalendars = await fetchCalendars(calDavUrl, { scheme: calDavScheme, username: calDavUsername, password: calDavPassword });
calDavState = "";
}
onMount(() => {
if(localStorage.getItem("calendars")) {
calendars = JSON.parse(localStorage.getItem("calendars")!);
}
})
</script>
<SidebarHeader>
{m["sidebar.calendar.header"]()}
</SidebarHeader>
<Drawer.Root bind:open={calDavDrawerOpen}>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>{m["sidebar.calendar.connect"]()}</Drawer.Title>
</Drawer.Header>
<div class="p-4 pt-0 flex flex-col gap-2">
{#if calDavCalendars}
<Input type="url" placeholder="https://my-caldav-server.com/..." disabled={calDavLoading} bind:value={calDavUrl} />
<Input type="text" placeholder="Username" disabled={calDavLoading} bind:value={calDavUsername} />
<Input type="password" placeholder="Password" disabled={calDavLoading} bind:value={calDavPassword} />
{#if calDavState}
<span>{calDavState}</span>
{/if}
{/if}
{#if calDavCalendars.length > 0}
<h3 class="font-medium pt-4">{m["sidebar.calendar.choose"]()}</h3>
<ul class="max-h-48 overflow-y-auto">
{#each calDavCalendars as calendar (calendar.url)}
<li>
<Button class="w-full" variant="secondary" onclick={() => {
if(localStorage.getItem("calendars")) {
const existing = JSON.parse(localStorage.getItem("calendars")!);
existing.push({
name: calendar.name,
url: calendar.url,
credentials: {
username: calDavUsername,
password: calDavPassword,
scheme: calDavScheme
}
});
localStorage.setItem("calendars", JSON.stringify(existing));
} else {
localStorage.setItem("calendars", JSON.stringify([{
name: calendar.name,
url: calendar.url,
credentials: {
username: calDavUsername,
password: calDavPassword,
scheme: calDavScheme
}
}]));
}
calendars.push({
name: calendar.name,
url: calendar.url,
credentials: {
username: calDavUsername,
password: calDavPassword,
scheme: calDavScheme
}
});
}}>
{calendar.name}
</Button>
</li>
{/each}
</ul>
{/if}
</div>
<Drawer.Footer>
{#if calDavCalendars.length === 0}
<Button onclick={async () => {
calDavLoading = true;
await fetchCalDav().catch((e) => {
calDavState = e;
calDavLoading = false;
})
}}>{m.submit()}</Button>
{/if}
<Drawer.Close>
{calDavCalendars.length === 0 ? m.done() : m.cancel()}
</Drawer.Close>
</Drawer.Footer>
</Drawer.Content>
</Drawer.Root>
{#each calendars as calendar (calendar.url)}
<div class="p-2 border rounded mb-2">
<h3 class="font-medium">{calendar.name}</h3>
<Button variant="destructive" size="sm" class="mt-2" onclick={() => {
calendars = calendars.filter(c => c.url !== calendar.url);
localStorage.setItem("calendars", JSON.stringify(calendars));
}}>{m.delete()}</Button>
</div>
{/each}
<SettingsButton text={m["sidebar.calendar.connect"]()} icon={CalendarPlusIcon} onclick={() => {
calDavDrawerOpen = true;
}} />

View File

@ -1,5 +1,6 @@
<script> <script>
import { import {
CalendarIcon,
CodeIcon, CodeIcon,
InfoIcon, InfoIcon,
LanguagesIcon, LanguagesIcon,
@ -25,6 +26,15 @@
view="language" view="language"
/> />
</section> </section>
<section>
<h2>{m["sidebar.settings.connections"]()}</h2>
<SettingsButton
icon={CalendarIcon}
text={m["sidebar.calendar.header"]()}
view="calendar"
/>
</section>
<section> <section>
<h2>{m["sidebar.settings.map"]()}</h2> <h2>{m["sidebar.settings.map"]()}</h2>

View File

@ -0,0 +1,41 @@
import { invoke } from "@tauri-apps/api/core";
export type AuthScheme = "Digest" | "Basic";
export interface DAVCredentials {
scheme: AuthScheme;
username: string;
password: string;
}
export interface DAVCalendar {
name: string;
url: string;
}
export interface DAVEvent {
summary: string;
start?: Date;
end?: Date;
description?: string;
location?: string;
}
export async function findScheme(url: string): Promise<AuthScheme> {
const scheme = await invoke("dav_find_scheme", { url });
return scheme as AuthScheme;
}
export async function fetchCalendars(url: string, credentials: DAVCredentials): Promise<DAVCalendar[]> {
const calendars = await invoke("dav_fetch_calendars", { url, credentials });
return calendars as DAVCalendar[];
}
export async function fetchEvents(calendar: DAVCalendar, credentials: DAVCredentials): Promise<DAVEvent[]> {
const events = await invoke("dav_fetch_events", { calendar, credentials });
return (events as DAVEvent[]).map(e => {
if (e.start) e.start = new Date(e.start);
if (e.end) e.end = new Date(e.end);
return e;
}) as DAVEvent[];
}