blob: 3486cac24d08892b7d46971de6b56a45def9cdd8 [file]
<script lang="ts">
import type { Calendars, Category, Visibility } from "../lib/types";
import type { DisplayZone } from "../lib/timezone";
import type { ImportCandidate, ImportPreview, ImportSettings } from "../lib/importing";
import {
emptySettings,
importReadiness,
importableCategories,
importableProjects,
importableVisibilities,
looksLikeCalendar,
reconcile,
resolveSettings,
} from "../lib/importing";
import { api, ApiError } from "../lib/api";
import { formatDate, formatTime } from "../lib/dates";
import { categoryLabel } from "../lib/events";
import { shortZoneLabel, toWall } from "../lib/timezone";
interface Props {
calendars: Calendars | null;
/** The clock the reader is using, so the preview matches the calendar. */
zone?: DisplayZone;
onclose: () => void;
/** Called with the number of events created, so the calendar can reload. */
onimported: (count: number) => void;
}
let { calendars, zone = "local", onclose, onimported }: Props = $props();
let file = $state<File | null>(null);
let preview = $state<ImportPreview | null>(null);
let settings = $state<ImportSettings>(emptySettings());
let reading = $state(false);
let saving = $state(false);
let error = $state<string | null>(null);
let dragging = $state(false);
let categories = $derived(importableCategories(calendars));
let visibilities = $derived(importableVisibilities(settings.category, calendars));
let projects = $derived(importableProjects(calendars, settings.visibility));
let readiness = $derived(importReadiness(settings, calendars));
let canSave = $derived(preview !== null && preview.count > 0 && readiness.ready && !saving);
function update(change: Partial<ImportSettings>) {
settings = reconcile({ ...settings, ...change }, calendars);
}
async function choose(chosen: File | null | undefined) {
if (!chosen) return;
file = chosen;
preview = null;
error = null;
reading = true;
try {
preview = await api.previewImport(chosen);
if (!looksLikeCalendar(chosen)) {
// It parsed, so the name was just unhelpful. Worth knowing, not worth
// refusing.
error = null;
}
} catch (failure) {
error = failure instanceof ApiError ? failure.message : "That file could not be read.";
file = null;
} finally {
reading = false;
}
}
async function save() {
if (!file || !canSave) return;
saving = true;
error = null;
try {
const result = await api.runImport(file, resolveSettings(settings));
onimported(result.count);
} catch (failure) {
error = failure instanceof ApiError ? failure.message : "Those events could not be saved.";
} finally {
saving = false;
}
}
function onDrop(event: DragEvent) {
event.preventDefault();
dragging = false;
void choose(event.dataTransfer?.files?.[0]);
}
function keydown(event: KeyboardEvent) {
if (event.key === "Escape") onclose();
}
function when(candidate: ImportCandidate): string {
const start = toWall(new Date(candidate.start), zone);
if (candidate.all_day) {
const last = toWall(new Date(new Date(candidate.end).getTime() - 1), zone);
return last.toDateString() === start.toDateString()
? `${formatDate(start)} (all day)`
: `${formatDate(start)} - ${formatDate(last)} (all day)`;
}
const end = toWall(new Date(candidate.end), zone);
return `${formatDate(start)}, ${formatTime(start)} - ${formatTime(end)} ${shortZoneLabel(zone)}`;
}
let warningCount = $derived(
(preview?.warnings.length ?? 0) +
(preview?.events ?? []).reduce((total, event) => total + event.warnings.length, 0),
);
</script>
<svelte:window onkeydown={keydown} />
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="backdrop" onclick={onclose}>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div
class="dialog"
role="dialog"
tabindex="-1"
aria-modal="true"
aria-label="Import an iCalendar file"
onclick={(event) => event.stopPropagation()}
>
<h2>Import events from a calendar file</h2>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="dropzone"
class:dragging
ondragover={(event) => {
event.preventDefault();
dragging = true;
}}
ondragleave={() => (dragging = false)}
ondrop={onDrop}
>
<label class="btn">
Choose a file
<input
type="file"
accept=".ics,text/calendar"
onchange={(event) => choose(event.currentTarget.files?.[0])}
/>
</label>
<span class="muted">
{#if file}{file.name}{:else}or drop an .ics file here{/if}
</span>
</div>
{#if reading}
<p class="note">Reading the file...</p>
{/if}
{#if preview}
<section class="preview">
<h3>
{preview.count}
{preview.count === 1 ? "event" : "events"} found
{#if warningCount > 0}
<span class="muted">- {warningCount} thing{warningCount === 1 ? "" : "s"} to note</span>
{/if}
</h3>
{#each preview.warnings as warning}
<p class="warning">{warning}</p>
{/each}
<!--
These three lists are unkeyed on purpose. A preview is replaced whole
or not at all, so there is no identity to preserve across a change,
and nothing in a file is unique enough to key on: a repeating event
exported from Google or Outlook repeats its UID on every modified
occurrence, and two entries can raise word-for-word the same warning.
-->
<ul>
{#each preview.events as candidate}
<li>
<span class="title">{candidate.title}</span>
<span class="when">{when(candidate)}</span>
{#if candidate.location}<span class="where muted">{candidate.location}</span>{/if}
{#each candidate.warnings as warning}
<span class="warning inline">{warning}</span>
{/each}
</li>
{/each}
</ul>
</section>
<section class="settings">
<h3>Where should they go?</h3>
<div class="pair">
<label class="row">
<span>Calendar</span>
<select
value={settings.category ?? ""}
onchange={(event) =>
update({ category: (event.currentTarget.value || null) as Category | null })}
>
<option value="">Choose...</option>
{#each categories as category (category)}
<option value={category}>{categoryLabel(category)}</option>
{/each}
</select>
</label>
{#if settings.category !== null && settings.category !== "personal"}
<label class="row">
<span>Visibility</span>
<select
value={settings.visibility ?? ""}
onchange={(event) =>
update({ visibility: (event.currentTarget.value || null) as Visibility | null })}
>
<option value="">Choose...</option>
{#each visibilities as visibility (visibility)}
<option value={visibility}>{visibility}</option>
{/each}
</select>
</label>
{/if}
{#if settings.category === "project"}
<label class="row">
<span>Project</span>
<select
value={settings.project ?? ""}
onchange={(event) => update({ project: event.currentTarget.value || null })}
>
<option value="">Choose...</option>
{#each projects as project (project)}
<option value={project}>{project}</option>
{/each}
</select>
</label>
{/if}
</div>
{#if settings.category === "personal"}
<p class="muted small">Personal events are always private to you.</p>
{/if}
{#if !readiness.ready}
<p class="missing" role="status">
Still to choose: {readiness.missing.join(", ")}.
</p>
{/if}
</section>
{/if}
<footer>
<span class="spacer"></span>
<button type="button" class="btn" onclick={onclose} disabled={saving}>Cancel</button>
<button type="button" class="btn primary" onclick={save} disabled={!canSave}>
{#if saving}
Importing...
{:else if preview}
Import {preview.count} {preview.count === 1 ? "event" : "events"}
{:else}
Import
{/if}
</button>
</footer>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(10, 14, 20, 0.45);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 3rem 1rem;
overflow-y: auto;
z-index: 50;
}
.dialog {
width: min(42rem, 100%);
background: var(--bg-panel);
border-radius: 10px;
border-top: 4px solid var(--asf-red);
box-shadow: var(--shadow-lg);
padding: 1.2rem 1.4rem 1rem;
}
h2 {
margin: 0 0 0.8rem;
font-size: 19px;
}
h3 {
margin: 0 0 0.4rem;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.error {
margin: 0 0 0.8rem;
padding: 0.5rem 0.7rem;
border-radius: var(--radius);
background: hsl(0 70% 95%);
border: 1px solid hsl(0 60% 75%);
color: hsl(0 65% 30%);
}
.dropzone {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 1rem;
border: 2px dashed var(--border-strong);
border-radius: var(--radius);
background: var(--bg-subtle);
}
.dropzone.dragging {
border-color: var(--asf-red);
background: var(--bg-hover);
}
.dropzone input[type="file"] {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.dropzone label {
cursor: pointer;
}
.note {
margin: 0.7rem 0 0;
color: var(--text-muted);
}
.preview,
.settings {
margin-top: 1.1rem;
}
.preview ul {
list-style: none;
margin: 0;
padding: 0;
max-height: 16rem;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.preview li {
display: flex;
flex-direction: column;
gap: 1px;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--border);
}
.preview li:last-child {
border-bottom: 0;
}
.title {
font-weight: 600;
}
.when {
font-size: 12px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.where {
font-size: 12px;
}
.warning {
margin: 0 0 0.4rem;
font-size: 12px;
color: hsl(28 70% 30%);
background: hsl(38 92% 92%);
border: 1px solid hsl(38 60% 78%);
border-radius: var(--radius);
padding: 0.3rem 0.5rem;
}
.warning.inline {
margin: 0.2rem 0 0;
display: block;
}
.pair {
display: flex;
gap: 0.7rem;
flex-wrap: wrap;
}
.row {
display: flex;
flex-direction: column;
gap: 0.2rem;
flex: 1 1 10rem;
}
.row > span {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.missing {
margin: 0.6rem 0 0;
font-size: 13px;
color: var(--asf-red);
}
.small {
font-size: 12px;
margin: 0.5rem 0 0;
}
footer {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1.2rem;
padding-top: 0.8rem;
border-top: 1px solid var(--border);
}
.spacer {
flex: 1;
}
@media (prefers-color-scheme: dark) {
.error {
background: hsl(0 40% 20%);
border-color: hsl(0 40% 40%);
color: hsl(0 70% 85%);
}
.warning {
background: hsl(38 40% 20%);
border-color: hsl(38 40% 35%);
color: hsl(38 80% 82%);
}
}
</style>