mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(calendar): M8.3 — calendar pilot for unlisted-share end-to-end
Wires Calendar through the M8.1+M8.2 backbone: flipping an event to
'unlisted' now publishes a server-side snapshot, the visible link in
the DetailView/EventDetailModal opens a real /share/[token] page, and
recipients can download an .ics file for their own calendar.
Changes:
- lib/data/unlisted/resolvers.ts (new):
buildUnlistedBlob(collection, recordId) dispatcher.
buildEventBlob: load LocalEvent + linked TimeBlock, decrypt
client-side, return { title, location, startTime, endTime,
isAllDay, timezone }. Description, reminders, tagIds, calendarId,
color stay out of the blob — sensitive context the user didn't
consent to share by flipping a single flag.
- modules/calendar/types: CalendarEvent gains `unlistedToken: string`
(empty string when no active token). timeBlockToCalendarEvent
forwards from LocalEvent. Draft-event scaffold initializes empty.
- modules/calendar/stores/events:
setVisibility now coordinates with mana-api. Flip-to-unlisted:
build blob -> publishUnlistedSnapshot -> store server-issued
token in patch.unlistedToken -> commit local update. If the
server call fails, no local change happens (no drift).
Flip-from-unlisted: revoke server snapshot first, then clear
local token + commit visibility change.
deleteEvent: revoke active unlisted snapshot before tombstoning,
so the share-link dies in lock-step with the local delete.
updateEvent + updateSingleInstance fire-and-forget
refreshUnlistedSnapshot(id) so the published blob tracks any
whitelist-field edits. Failures log; the next successful
refresh heals.
New regenerateUnlistedToken(id): revoke + republish in one call,
returns the fresh token. Powers the "Neu erzeugen" UI.
- routes/share/[token]/+layout.svelte: minimal anonymous chrome —
no app nav, no auth, no Dexie. Light/dark via prefers-color-scheme.
Footer carries "Geteilt via Mana" + signup CTA.
- routes/share/[token]/+page.server.ts: SSR loader. Fetches
/api/v1/unlisted/public/:token, dispatches 404/410 cleanly,
sets Cache-Control: private, max-age=60 + X-Robots-Tag: noindex.
- routes/share/[token]/+page.svelte: dispatcher; renders
SharedEventView for collection='events', stub message otherwise.
- modules/calendar/SharedEventView.svelte: standalone public render —
big date, location, "Zum eigenen Kalender hinzufügen" .ics link,
optional expiry note. OG/Twitter meta tags for WhatsApp/Slack
preview embedding. Uses $derived everywhere so prop updates
propagate through reactive recompute.
- routes/share/[token]/ical/+server.ts: RFC 5545 builder. No npm
library — small enough to inline. Escapes per spec, CRLF endings,
DTSTART/DTEND swap between VALUE=DATE and UTC depending on isAllDay.
Wrong-collection requests get 400.
- modules/calendar/views/DetailView (Workbench) + components/
EventDetailModal (/calendar route): SharedLinkControls dropped in
below the visibility row when event.visibility === 'unlisted'
AND event.unlistedToken AND shareUrl computed. The URL is built
client-side via buildShareUrl(window.location.origin, token) so it
stays in sync with whichever host the editor is open on.
Verified:
- pnpm check (web): 7541 files, 0 errors, 0 warnings
- pnpm test calendar + website: 26/26
- typecheck of new resolver, store hooks, SSR loader, iCal builder
Manual test path:
1. Open /calendar event in Detail view, flip Sichtbarkeit -> "Per Link"
2. Server publishes snapshot, Dexie record gets the server token
3. SharedLinkControls appear with copy + regenerate + revoke buttons
4. Open the URL in incognito → SSR fetches snapshot, renders
SharedEventView with date / location / .ics download
5. Edit the event title back in the main app → snapshot auto-refreshes
(refreshUnlistedSnapshot fires after updateEvent succeeds)
6. Flip back to "Bereich" → snapshot revoked server-side; subsequent
incognito reloads return 410 Gone
Next: M8.4 — same wiring for Library + Places. Uses the same
infra (resolvers dispatcher, share dispatcher) — just adds two new
buildXBlob functions, two SharedXView components, and the store
hooks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8b9fbd2e1c
commit
fbbadc91f0
13 changed files with 1093 additions and 11 deletions
104
apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts
Normal file
104
apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
/**
|
||||||
|
* Unlisted-snapshot resolvers — client-side blob builders.
|
||||||
|
*
|
||||||
|
* When a user flips a record to `visibility === 'unlisted'`, the store
|
||||||
|
* calls `buildUnlistedBlob(collection, recordId)` here to produce the
|
||||||
|
* whitelist-filtered plaintext payload that gets pushed to the
|
||||||
|
* unlisted-snapshots table via `publishUnlistedSnapshot`.
|
||||||
|
*
|
||||||
|
* Whitelist is mandatory per module. What isn't listed explicitly does
|
||||||
|
* NOT make it into the snapshot — protection against accidentally
|
||||||
|
* leaking encrypted fields like description / guest-lists / private
|
||||||
|
* notes. Same principle as `website/embeds.ts` for public snapshots.
|
||||||
|
*
|
||||||
|
* See docs/plans/unlisted-sharing.md §3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '$lib/data/database';
|
||||||
|
import { decryptRecord } from '$lib/data/crypto';
|
||||||
|
import type { LocalEvent } from '$lib/modules/calendar/types';
|
||||||
|
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
|
||||||
|
|
||||||
|
export class UnsupportedCollectionError extends Error {
|
||||||
|
constructor(collection: string) {
|
||||||
|
super(`Unlisted sharing is not supported for collection "${collection}"`);
|
||||||
|
this.name = 'UnsupportedCollectionError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecordNotFoundError extends Error {
|
||||||
|
constructor(collection: string, recordId: string) {
|
||||||
|
super(`${collection}/${recordId} not found`);
|
||||||
|
this.name = 'RecordNotFoundError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the whitelist-filtered blob for a record. Dispatcher —
|
||||||
|
* delegates to per-collection builders.
|
||||||
|
*/
|
||||||
|
export async function buildUnlistedBlob(
|
||||||
|
collection: string,
|
||||||
|
recordId: string
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
switch (collection) {
|
||||||
|
case 'events':
|
||||||
|
return buildEventBlob(recordId);
|
||||||
|
default:
|
||||||
|
throw new UnsupportedCollectionError(collection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendar event → snapshot blob.
|
||||||
|
*
|
||||||
|
* Whitelist: title, location, startTime, endTime, allDay, timezone.
|
||||||
|
* Decryption happens client-side here (events table carries encrypted
|
||||||
|
* title/description/location). Time dimension comes from the linked
|
||||||
|
* TimeBlock — LocalEvent only stores the `timeBlockId` reference.
|
||||||
|
*
|
||||||
|
* NOT inlined:
|
||||||
|
* - description (often holds agenda, private notes, guest info)
|
||||||
|
* - reminders (implementation detail)
|
||||||
|
* - tagIds (internal labels)
|
||||||
|
* - calendarId (internal routing)
|
||||||
|
* - color (cosmetic, the share page picks its own scheme)
|
||||||
|
*/
|
||||||
|
async function buildEventBlob(recordId: string): Promise<Record<string, unknown>> {
|
||||||
|
const raw = await db.table<LocalEvent>('events').get(recordId);
|
||||||
|
if (!raw || raw.deletedAt) {
|
||||||
|
throw new RecordNotFoundError('events', recordId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decrypted = (await decryptRecord('events', { ...raw })) as LocalEvent;
|
||||||
|
|
||||||
|
let startTime: string | null = null;
|
||||||
|
let endTime: string | null = null;
|
||||||
|
let isAllDay = false;
|
||||||
|
let timezone: string | null = null;
|
||||||
|
|
||||||
|
if (decrypted.timeBlockId) {
|
||||||
|
const block = await db.table<LocalTimeBlock>('timeBlocks').get(decrypted.timeBlockId);
|
||||||
|
if (block && !block.deletedAt) {
|
||||||
|
startTime = block.startDate;
|
||||||
|
endTime = block.endDate ?? block.startDate;
|
||||||
|
isAllDay = block.allDay;
|
||||||
|
timezone = block.timezone ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!startTime || !endTime) {
|
||||||
|
throw new Error(`Event ${recordId} is missing a time-block — cannot build share snapshot`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Keep the field names stable — the SSR renderer (SharedEventView)
|
||||||
|
// reads these directly.
|
||||||
|
title: decrypted.title,
|
||||||
|
location: decrypted.location ?? null,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
isAllDay,
|
||||||
|
timezone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
<!--
|
||||||
|
Shared-Event-View — the public render of a calendar event behind
|
||||||
|
an unlisted share link.
|
||||||
|
|
||||||
|
Whitelist: title, startTime, endTime, isAllDay, timezone, location.
|
||||||
|
That's everything the resolver put into the blob (see
|
||||||
|
apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts :: buildEventBlob).
|
||||||
|
|
||||||
|
Design goals: legible date, one-glance "when + where", trivial
|
||||||
|
"add to calendar" via .ics download, quiet CTA at the end. Works
|
||||||
|
in light + dark (via prefers-color-scheme in the layout).
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
interface EventBlob {
|
||||||
|
title: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
isAllDay: boolean;
|
||||||
|
timezone: string | null;
|
||||||
|
location: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
blob,
|
||||||
|
token,
|
||||||
|
expiresAt,
|
||||||
|
}: {
|
||||||
|
blob: Record<string, unknown>;
|
||||||
|
token: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const event = $derived(blob as unknown as EventBlob);
|
||||||
|
const start = $derived(new Date(event.startTime));
|
||||||
|
const end = $derived(new Date(event.endTime));
|
||||||
|
|
||||||
|
function formatDate(d: Date): string {
|
||||||
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(d: Date): string {
|
||||||
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateLabel = $derived(formatDate(start));
|
||||||
|
const timeLabel = $derived(
|
||||||
|
event.isAllDay ? 'Ganztägig' : `${formatTime(start)} – ${formatTime(end)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Same-day range = compact; otherwise show two dates
|
||||||
|
const sameDay = $derived(
|
||||||
|
start.getFullYear() === end.getFullYear() &&
|
||||||
|
start.getMonth() === end.getMonth() &&
|
||||||
|
start.getDate() === end.getDate()
|
||||||
|
);
|
||||||
|
const dateRangeLabel = $derived(
|
||||||
|
sameDay ? dateLabel : `${formatDate(start)} – ${formatDate(end)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const icsUrl = $derived(`/share/${token}/ical`);
|
||||||
|
|
||||||
|
const ogDescription = $derived(
|
||||||
|
[event.isAllDay ? dateLabel : `${dateLabel}, ${formatTime(start)}`, event.location]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{event.title} · Mana</title>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<meta property="og:title" content={event.title} />
|
||||||
|
<meta property="og:description" content={ogDescription} />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<article class="event">
|
||||||
|
<span class="event__kind">Termin</span>
|
||||||
|
<h1 class="event__title">{event.title}</h1>
|
||||||
|
|
||||||
|
<dl class="event__meta">
|
||||||
|
<div class="event__row">
|
||||||
|
<dt>Wann</dt>
|
||||||
|
<dd>
|
||||||
|
<div class="event__date">{dateRangeLabel}</div>
|
||||||
|
<div class="event__time">{timeLabel}</div>
|
||||||
|
{#if event.timezone}
|
||||||
|
<div class="event__tz">Zeitzone: {event.timezone}</div>
|
||||||
|
{/if}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if event.location}
|
||||||
|
<div class="event__row">
|
||||||
|
<dt>Wo</dt>
|
||||||
|
<dd>{event.location}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<a class="event__ics" href={icsUrl} download="event.ics">📅 Zum eigenen Kalender hinzufügen</a>
|
||||||
|
|
||||||
|
{#if expiresAt}
|
||||||
|
<p class="event__expiry">
|
||||||
|
Dieser Link läuft am {new Intl.DateTimeFormat('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(new Date(expiresAt))} ab.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.event {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
.event__kind {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.event__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
.event__meta {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.event__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 5rem 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.event__row dt {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.event__row dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.event__date {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
}
|
||||||
|
.event__time {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: #374151;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
.event__tz {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.event__ics {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #4f46e5;
|
||||||
|
color: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.event__ics:hover {
|
||||||
|
background: #4338ca;
|
||||||
|
}
|
||||||
|
.event__expiry {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.event__kind {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.event__meta {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
.event__row dt {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.event__time {
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
.event__tz {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.event__ics {
|
||||||
|
background: #818cf8;
|
||||||
|
}
|
||||||
|
.event__ics:hover {
|
||||||
|
background: #6366f1;
|
||||||
|
}
|
||||||
|
.event__expiry {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -2,7 +2,12 @@
|
||||||
import { getDateFnsLocale } from '$lib/i18n/format';
|
import { getDateFnsLocale } from '$lib/i18n/format';
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
|
import {
|
||||||
|
VisibilityPicker,
|
||||||
|
SharedLinkControls,
|
||||||
|
buildShareUrl,
|
||||||
|
type VisibilityLevel,
|
||||||
|
} from '@mana/shared-privacy';
|
||||||
import { eventsStore } from '../stores/events.svelte';
|
import { eventsStore } from '../stores/events.svelte';
|
||||||
import { getCalendarById, getCalendarColor } from '../queries';
|
import { getCalendarById, getCalendarColor } from '../queries';
|
||||||
import type { Calendar, CalendarEvent } from '../types';
|
import type { Calendar, CalendarEvent } from '../types';
|
||||||
|
|
@ -33,6 +38,20 @@
|
||||||
await eventsStore.setVisibility(event.id, next);
|
await eventsStore.setVisibility(event.id, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRegenerate() {
|
||||||
|
await eventsStore.regenerateUnlistedToken(event.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke() {
|
||||||
|
await eventsStore.setVisibility(event.id, 'space');
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareUrl = $derived.by(() => {
|
||||||
|
if (!event.unlistedToken) return '';
|
||||||
|
const origin = typeof window === 'undefined' ? 'https://mana.how' : window.location.origin;
|
||||||
|
return buildShareUrl(origin, event.unlistedToken);
|
||||||
|
});
|
||||||
|
|
||||||
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
|
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
|
||||||
|
|
||||||
let isEditing = $state(false);
|
let isEditing = $state(false);
|
||||||
|
|
@ -249,6 +268,21 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Share link (only when visibility = unlisted) -->
|
||||||
|
{#if event.visibility === 'unlisted' && event.unlistedToken && shareUrl}
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Link</span>
|
||||||
|
<div class="detail-content">
|
||||||
|
<SharedLinkControls
|
||||||
|
token={event.unlistedToken}
|
||||||
|
url={shareUrl}
|
||||||
|
onRegenerate={handleRegenerate}
|
||||||
|
onRevoke={handleRevoke}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Time -->
|
<!-- Time -->
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="detail-icon"><Clock size={18} /></span>
|
<span class="detail-icon"><Clock size={18} /></span>
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,13 @@ import { getActiveSpace } from '$lib/data/scope';
|
||||||
import { getEffectiveUserId } from '$lib/data/current-user';
|
import { getEffectiveUserId } from '$lib/data/current-user';
|
||||||
import {
|
import {
|
||||||
defaultVisibilityFor,
|
defaultVisibilityFor,
|
||||||
generateUnlistedToken,
|
publishUnlistedSnapshot,
|
||||||
|
revokeUnlistedSnapshot,
|
||||||
type VisibilityLevel,
|
type VisibilityLevel,
|
||||||
} from '@mana/shared-privacy';
|
} from '@mana/shared-privacy';
|
||||||
|
import { buildUnlistedBlob } from '$lib/data/unlisted/resolvers';
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
import { getManaApiUrl } from '$lib/api/config';
|
||||||
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
|
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
|
||||||
import { timeBlockTable } from '$lib/data/time-blocks/collections';
|
import { timeBlockTable } from '$lib/data/time-blocks/collections';
|
||||||
import {
|
import {
|
||||||
|
|
@ -166,6 +170,9 @@ export const eventsStore = {
|
||||||
fields: Object.keys(input).filter((k) => input[k as keyof typeof input] !== undefined),
|
fields: Object.keys(input).filter((k) => input[k as keyof typeof input] !== undefined),
|
||||||
});
|
});
|
||||||
CalendarEvents.eventUpdated();
|
CalendarEvents.eventUpdated();
|
||||||
|
// If this event is shared via unlisted-link, keep the server
|
||||||
|
// snapshot fresh so the shared view tracks local edits.
|
||||||
|
void this.refreshUnlistedSnapshot(id);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Failed to update event';
|
error = e instanceof Error ? e.message : 'Failed to update event';
|
||||||
|
|
@ -215,6 +222,7 @@ export const eventsStore = {
|
||||||
await encryptRecord('events', localData);
|
await encryptRecord('events', localData);
|
||||||
await db.table('events').update(id, localData);
|
await db.table('events').update(id, localData);
|
||||||
CalendarEvents.eventUpdated();
|
CalendarEvents.eventUpdated();
|
||||||
|
void this.refreshUnlistedSnapshot(id);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Failed to update instance';
|
error = e instanceof Error ? e.message : 'Failed to update instance';
|
||||||
|
|
@ -361,6 +369,26 @@ export const eventsStore = {
|
||||||
await deleteBlock(event.timeBlockId);
|
await deleteBlock(event.timeBlockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the event is shared via unlisted-link, revoke the server
|
||||||
|
// snapshot before the local tombstone — the link should die
|
||||||
|
// the moment the user deletes the record, not whenever the cron
|
||||||
|
// happens to notice.
|
||||||
|
if (event?.visibility === 'unlisted' && event.unlistedToken) {
|
||||||
|
const jwt = await authStore.getValidToken();
|
||||||
|
if (jwt) {
|
||||||
|
try {
|
||||||
|
await revokeUnlistedSnapshot({
|
||||||
|
apiUrl: getManaApiUrl(),
|
||||||
|
jwt,
|
||||||
|
collection: 'events',
|
||||||
|
recordId: id,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[calendar/events] revoke on delete failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await db.table('events').update(id, {
|
await db.table('events').update(id, {
|
||||||
deletedAt: new Date().toISOString(),
|
deletedAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
|
@ -407,6 +435,7 @@ export const eventsStore = {
|
||||||
color: data.color || null,
|
color: data.color || null,
|
||||||
tagIds: data.tagIds || [],
|
tagIds: data.tagIds || [],
|
||||||
visibility: data.visibility ?? 'private',
|
visibility: data.visibility ?? 'private',
|
||||||
|
unlistedToken: data.unlistedToken ?? '',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
blockType: 'event',
|
blockType: 'event',
|
||||||
|
|
@ -444,10 +473,21 @@ export const eventsStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flip the event's visibility. Mints/clears an unlisted token on the
|
* Flip the event's visibility. Coordinates with the server-side
|
||||||
* transition boundary, emits the cross-module VisibilityChanged event.
|
* unlisted-snapshots table when the transition involves the
|
||||||
* Publishes a public event on the next website snapshot with
|
* 'unlisted' level:
|
||||||
* field-level redaction applied server-side (see embeds.ts).
|
*
|
||||||
|
* - private|space|public → unlisted:
|
||||||
|
* build the whitelist blob, publish to mana-api, server returns
|
||||||
|
* the authoritative token, store it on the Dexie record.
|
||||||
|
* - unlisted → anything else:
|
||||||
|
* revoke the server snapshot first, then clear the local token.
|
||||||
|
*
|
||||||
|
* Server call failures abort the flip so Dexie and server don't
|
||||||
|
* drift out of sync — the user sees an error and can retry. Emits
|
||||||
|
* the cross-module VisibilityChanged domain event for audit.
|
||||||
|
*
|
||||||
|
* See docs/plans/unlisted-sharing.md §4 (Store-Integration).
|
||||||
*/
|
*/
|
||||||
async setVisibility(id: string, next: VisibilityLevel) {
|
async setVisibility(id: string, next: VisibilityLevel) {
|
||||||
error = null;
|
error = null;
|
||||||
|
|
@ -464,11 +504,38 @@ export const eventsStore = {
|
||||||
visibilityChangedBy: getEffectiveUserId(),
|
visibilityChangedBy: getEffectiveUserId(),
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
if (next === 'unlisted' && !existing.unlistedToken) {
|
|
||||||
patch.unlistedToken = generateUnlistedToken();
|
// Server-authoritative token. Publish first; local update only
|
||||||
} else if (next !== 'unlisted' && existing.unlistedToken) {
|
// if the server accepted the snapshot so a share-link always
|
||||||
|
// resolves to a real row.
|
||||||
|
if (next === 'unlisted') {
|
||||||
|
const blob = await buildUnlistedBlob('events', id);
|
||||||
|
const jwt = await authStore.getValidToken();
|
||||||
|
if (!jwt) return { success: false, error: 'Nicht eingeloggt' };
|
||||||
|
const spaceId =
|
||||||
|
(existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? '';
|
||||||
|
const { token } = await publishUnlistedSnapshot({
|
||||||
|
apiUrl: getManaApiUrl(),
|
||||||
|
jwt,
|
||||||
|
collection: 'events',
|
||||||
|
recordId: id,
|
||||||
|
spaceId,
|
||||||
|
blob,
|
||||||
|
});
|
||||||
|
patch.unlistedToken = token;
|
||||||
|
} else if (before === 'unlisted') {
|
||||||
|
const jwt = await authStore.getValidToken();
|
||||||
|
if (jwt) {
|
||||||
|
await revokeUnlistedSnapshot({
|
||||||
|
apiUrl: getManaApiUrl(),
|
||||||
|
jwt,
|
||||||
|
collection: 'events',
|
||||||
|
recordId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
patch.unlistedToken = undefined;
|
patch.unlistedToken = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.table('events').update(id, patch);
|
await db.table('events').update(id, patch);
|
||||||
|
|
||||||
emitDomainEvent('VisibilityChanged', 'calendar', 'events', id, {
|
emitDomainEvent('VisibilityChanged', 'calendar', 'events', id, {
|
||||||
|
|
@ -483,4 +550,81 @@ export const eventsStore = {
|
||||||
return { success: false, error };
|
return { success: false, error };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-regenerate the unlisted token for an event. Revoke the
|
||||||
|
* existing snapshot, then publish a fresh one — server gives back
|
||||||
|
* a new token because the previous row is marked revoked. UI
|
||||||
|
* intent: "the old link is leaked or I want a clean slate".
|
||||||
|
*
|
||||||
|
* No-op if the event isn't currently 'unlisted'.
|
||||||
|
*/
|
||||||
|
async regenerateUnlistedToken(id: string) {
|
||||||
|
const existing = await db.table<LocalEvent>('events').get(id);
|
||||||
|
if (!existing || existing.visibility !== 'unlisted') {
|
||||||
|
return { success: false, error: 'Event is not unlisted' };
|
||||||
|
}
|
||||||
|
const jwt = await authStore.getValidToken();
|
||||||
|
if (!jwt) return { success: false, error: 'Nicht eingeloggt' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await revokeUnlistedSnapshot({
|
||||||
|
apiUrl: getManaApiUrl(),
|
||||||
|
jwt,
|
||||||
|
collection: 'events',
|
||||||
|
recordId: id,
|
||||||
|
});
|
||||||
|
const blob = await buildUnlistedBlob('events', id);
|
||||||
|
const spaceId =
|
||||||
|
(existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? '';
|
||||||
|
const { token } = await publishUnlistedSnapshot({
|
||||||
|
apiUrl: getManaApiUrl(),
|
||||||
|
jwt,
|
||||||
|
collection: 'events',
|
||||||
|
recordId: id,
|
||||||
|
spaceId,
|
||||||
|
blob,
|
||||||
|
});
|
||||||
|
await db.table('events').update(id, {
|
||||||
|
unlistedToken: token,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return { success: true, token };
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to regenerate link';
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-publish the unlisted snapshot for an event. Called by
|
||||||
|
* updateEvent/updateSingleInstance/etc. when the owning record is
|
||||||
|
* currently flagged 'unlisted' — keeps the share-link in sync with
|
||||||
|
* local edits to the whitelist fields.
|
||||||
|
*
|
||||||
|
* Fire-and-forget in practice: a failure logs but doesn't revert the
|
||||||
|
* edit. The next successful re-publish will heal any drift, and the
|
||||||
|
* user can re-flip visibility to force a fresh publish.
|
||||||
|
*/
|
||||||
|
async refreshUnlistedSnapshot(id: string) {
|
||||||
|
const existing = await db.table<LocalEvent>('events').get(id);
|
||||||
|
if (!existing || existing.visibility !== 'unlisted') return;
|
||||||
|
try {
|
||||||
|
const blob = await buildUnlistedBlob('events', id);
|
||||||
|
const jwt = await authStore.getValidToken();
|
||||||
|
if (!jwt) return;
|
||||||
|
const spaceId =
|
||||||
|
(existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? '';
|
||||||
|
await publishUnlistedSnapshot({
|
||||||
|
apiUrl: getManaApiUrl(),
|
||||||
|
jwt,
|
||||||
|
collection: 'events',
|
||||||
|
recordId: id,
|
||||||
|
spaceId,
|
||||||
|
blob,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[calendar/events] refreshUnlistedSnapshot failed', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,12 @@ export interface CalendarEvent {
|
||||||
color: string | null;
|
color: string | null;
|
||||||
tagIds: string[];
|
tagIds: string[];
|
||||||
visibility: VisibilityLevel;
|
visibility: VisibilityLevel;
|
||||||
|
/**
|
||||||
|
* Server-issued share token for `visibility === 'unlisted'`. Empty
|
||||||
|
* string for any other visibility (UI checks `event.unlistedToken`
|
||||||
|
* to know whether to render the share-link controls).
|
||||||
|
*/
|
||||||
|
unlistedToken: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
// TimeBlock metadata (for universal calendar view)
|
// TimeBlock metadata (for universal calendar view)
|
||||||
|
|
@ -107,6 +113,7 @@ export function timeBlockToCalendarEvent(
|
||||||
// carry a calendar-specific visibility — they inherit 'space' so
|
// carry a calendar-specific visibility — they inherit 'space' so
|
||||||
// they stay invisible on the website (public requires explicit opt-in).
|
// they stay invisible on the website (public requires explicit opt-in).
|
||||||
visibility: eventData?.visibility ?? 'space',
|
visibility: eventData?.visibility ?? 'space',
|
||||||
|
unlistedToken: eventData?.unlistedToken ?? '',
|
||||||
createdAt: block.createdAt,
|
createdAt: block.createdAt,
|
||||||
updatedAt: block.updatedAt,
|
updatedAt: block.updatedAt,
|
||||||
blockType: block.type,
|
blockType: block.type,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,12 @@
|
||||||
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
|
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
|
||||||
import { eventsStore } from '../stores/events.svelte';
|
import { eventsStore } from '../stores/events.svelte';
|
||||||
import { MapPin, Clock, X } from '@mana/shared-icons';
|
import { MapPin, Clock, X } from '@mana/shared-icons';
|
||||||
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
|
import {
|
||||||
|
VisibilityPicker,
|
||||||
|
SharedLinkControls,
|
||||||
|
buildShareUrl,
|
||||||
|
type VisibilityLevel,
|
||||||
|
} from '@mana/shared-privacy';
|
||||||
import type { ViewProps } from '$lib/app-registry';
|
import type { ViewProps } from '$lib/app-registry';
|
||||||
import type { LocalEvent } from '../types';
|
import type { LocalEvent } from '../types';
|
||||||
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
|
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
|
||||||
|
|
@ -98,6 +103,21 @@
|
||||||
await eventsStore.setVisibility(eventId, next);
|
await eventsStore.setVisibility(eventId, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRegenerate() {
|
||||||
|
await eventsStore.regenerateUnlistedToken(eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke() {
|
||||||
|
await eventsStore.setVisibility(eventId, 'space');
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareUrl = $derived.by(() => {
|
||||||
|
const token = detail.entity?.unlistedToken;
|
||||||
|
if (!token) return '';
|
||||||
|
const origin = typeof window === 'undefined' ? 'https://mana.how' : window.location.origin;
|
||||||
|
return buildShareUrl(origin, token);
|
||||||
|
});
|
||||||
|
|
||||||
async function deleteEvent() {
|
async function deleteEvent() {
|
||||||
const id = eventId;
|
const id = eventId;
|
||||||
await eventsStore.deleteEvent(id);
|
await eventsStore.deleteEvent(id);
|
||||||
|
|
@ -133,6 +153,17 @@
|
||||||
<VisibilityPicker level={event.visibility ?? 'private'} onChange={handleVisibilityChange} />
|
<VisibilityPicker level={event.visibility ?? 'private'} onChange={handleVisibilityChange} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if event.visibility === 'unlisted' && event.unlistedToken && shareUrl}
|
||||||
|
<div class="prop-row prop-row--share">
|
||||||
|
<SharedLinkControls
|
||||||
|
token={event.unlistedToken}
|
||||||
|
url={shareUrl}
|
||||||
|
onRegenerate={handleRegenerate}
|
||||||
|
onRevoke={handleRevoke}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="prop-row">
|
<div class="prop-row">
|
||||||
<span class="prop-icon"><Clock size={14} /></span>
|
<span class="prop-icon"><Clock size={14} /></span>
|
||||||
<div class="time-fields">
|
<div class="time-fields">
|
||||||
|
|
|
||||||
71
apps/mana/apps/web/src/routes/share/[token]/+layout.svelte
Normal file
71
apps/mana/apps/web/src/routes/share/[token]/+layout.svelte
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<!--
|
||||||
|
Share-Link layout — minimal chrome for anonymous visitors.
|
||||||
|
No app nav, no auth, no sidebar. Just the snapshot + a quiet
|
||||||
|
"shared via Mana" footer + a CTA for signup.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
let { children }: { children: Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="share-shell">
|
||||||
|
<main class="share-main">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="share-footer">
|
||||||
|
<span class="share-footer__meta">Geteilt via Mana</span>
|
||||||
|
<a class="share-footer__cta" href="https://mana.how" target="_blank" rel="noopener noreferrer">
|
||||||
|
mana.how →
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.share-shell {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fafafa;
|
||||||
|
color: #111;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
.share-main {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
padding: 2.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
.share-footer {
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #6b7280;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.share-footer__cta {
|
||||||
|
color: #4f46e5;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.share-footer__cta:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.share-shell {
|
||||||
|
background: #0f1216;
|
||||||
|
color: #f4f4f5;
|
||||||
|
}
|
||||||
|
.share-footer {
|
||||||
|
color: #9ca3af;
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.share-footer__cta {
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
51
apps/mana/apps/web/src/routes/share/[token]/+page.server.ts
Normal file
51
apps/mana/apps/web/src/routes/share/[token]/+page.server.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
/**
|
||||||
|
* Share-Link SSR loader.
|
||||||
|
*
|
||||||
|
* Fetches the unlisted snapshot blob from mana-api and passes it to
|
||||||
|
* the dispatcher page. The page picks the right per-collection
|
||||||
|
* component and renders statically. No client-side hydration of the
|
||||||
|
* user's encrypted data — everything the visitor sees is this blob.
|
||||||
|
*
|
||||||
|
* See docs/plans/unlisted-sharing.md §5.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { getManaApiUrl } from '$lib/api/config';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export interface SnapshotResponse {
|
||||||
|
token: string;
|
||||||
|
collection: string;
|
||||||
|
blob: Record<string, unknown>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, fetch, setHeaders }) => {
|
||||||
|
const token = params.token;
|
||||||
|
if (!token || !/^[A-Za-z0-9_-]{32}$/.test(token)) {
|
||||||
|
error(404, 'Link nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${getManaApiUrl()}/api/v1/unlisted/public/${token}`);
|
||||||
|
|
||||||
|
if (res.status === 404) error(404, 'Link nicht gefunden');
|
||||||
|
if (res.status === 410) {
|
||||||
|
const body = await res.json().catch(() => ({ code: 'GONE' }));
|
||||||
|
const message = body.code === 'EXPIRED' ? 'Link ist abgelaufen' : 'Link wurde widerrufen';
|
||||||
|
error(410, message);
|
||||||
|
}
|
||||||
|
if (!res.ok) error(502, 'Fehler beim Laden des geteilten Inhalts');
|
||||||
|
|
||||||
|
const payload = (await res.json()) as SnapshotResponse;
|
||||||
|
|
||||||
|
// Short private cache — revocation at the source propagates in ≤60s.
|
||||||
|
// noindex as both header and meta tag keeps search engines out.
|
||||||
|
setHeaders({
|
||||||
|
'cache-control': 'private, max-age=60',
|
||||||
|
'x-robots-tag': 'noindex, nofollow',
|
||||||
|
});
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
35
apps/mana/apps/web/src/routes/share/[token]/+page.svelte
Normal file
35
apps/mana/apps/web/src/routes/share/[token]/+page.svelte
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<!--
|
||||||
|
Share-Link dispatcher. Picks the per-collection render component.
|
||||||
|
All data lives on `data.blob` — whatever the resolver put there is
|
||||||
|
what the view consumes, no Dexie roundtrips.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import SharedEventView from '$lib/modules/calendar/SharedEventView.svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if data.collection === 'events'}
|
||||||
|
<SharedEventView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
|
||||||
|
{:else}
|
||||||
|
<div class="unknown">
|
||||||
|
<h1>Unbekannter Link-Typ</h1>
|
||||||
|
<p>Diese Art von geteiltem Inhalt wird von dieser Mana-Version nicht unterstützt.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.unknown {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
}
|
||||||
|
.unknown h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
.unknown p {
|
||||||
|
margin: 0;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
126
apps/mana/apps/web/src/routes/share/[token]/ical/+server.ts
Normal file
126
apps/mana/apps/web/src/routes/share/[token]/ical/+server.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
/**
|
||||||
|
* iCal download for unlisted-shared calendar events.
|
||||||
|
*
|
||||||
|
* Fetches the same snapshot the /share/[token] page renders, then
|
||||||
|
* serialises it to RFC 5545. Only 'events' snapshots return a file;
|
||||||
|
* other collections get 400.
|
||||||
|
*
|
||||||
|
* See docs/plans/unlisted-sharing.md §7.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { getManaApiUrl } from '$lib/api/config';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
interface SnapshotResponse {
|
||||||
|
token: string;
|
||||||
|
collection: string;
|
||||||
|
blob: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventBlob {
|
||||||
|
title: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
isAllDay: boolean;
|
||||||
|
timezone: string | null;
|
||||||
|
location: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params, fetch }) => {
|
||||||
|
const token = params.token;
|
||||||
|
if (!token || !/^[A-Za-z0-9_-]{32}$/.test(token)) {
|
||||||
|
error(404, 'Link nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${getManaApiUrl()}/api/v1/unlisted/public/${token}`);
|
||||||
|
if (res.status === 404) error(404, 'Link nicht gefunden');
|
||||||
|
if (res.status === 410) error(410, 'Link nicht mehr gültig');
|
||||||
|
if (!res.ok) error(502, 'Fehler beim Laden');
|
||||||
|
|
||||||
|
const payload = (await res.json()) as SnapshotResponse;
|
||||||
|
if (payload.collection !== 'events') {
|
||||||
|
error(400, 'iCal-Export nur für Kalender-Termine verfügbar');
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = payload.blob as unknown as EventBlob;
|
||||||
|
const ics = buildIcs(token, event);
|
||||||
|
|
||||||
|
return new Response(ics, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'text/calendar; charset=utf-8',
|
||||||
|
'content-disposition': `attachment; filename="event-${token.slice(0, 8)}.ics"`,
|
||||||
|
'cache-control': 'private, max-age=60',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a minimal RFC 5545 iCalendar body. No library — the fields we
|
||||||
|
* inline are trivial enough. Escaping per the spec: commas,
|
||||||
|
* semicolons and backslashes in TEXT fields are `\\`-escaped; newlines
|
||||||
|
* become `\\n`.
|
||||||
|
*/
|
||||||
|
function buildIcs(token: string, event: EventBlob): string {
|
||||||
|
const uid = `unlisted-${token}@mana.how`;
|
||||||
|
const now = formatIcsUtc(new Date());
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'PRODID:-//Mana//Unlisted Event//DE',
|
||||||
|
'CALSCALE:GREGORIAN',
|
||||||
|
'METHOD:PUBLISH',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
`UID:${uid}`,
|
||||||
|
`DTSTAMP:${now}`,
|
||||||
|
...formatDtStartEnd(event),
|
||||||
|
`SUMMARY:${escapeIcs(event.title)}`,
|
||||||
|
];
|
||||||
|
if (event.location) {
|
||||||
|
lines.push(`LOCATION:${escapeIcs(event.location)}`);
|
||||||
|
}
|
||||||
|
lines.push('END:VEVENT', 'END:VCALENDAR');
|
||||||
|
|
||||||
|
// Line endings per spec: CRLF.
|
||||||
|
return lines.join('\r\n') + '\r\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDtStartEnd(event: EventBlob): string[] {
|
||||||
|
if (event.isAllDay) {
|
||||||
|
return [
|
||||||
|
`DTSTART;VALUE=DATE:${formatIcsDate(new Date(event.startTime))}`,
|
||||||
|
`DTEND;VALUE=DATE:${formatIcsDate(new Date(event.endTime))}`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
`DTSTART:${formatIcsUtc(new Date(event.startTime))}`,
|
||||||
|
`DTEND:${formatIcsUtc(new Date(event.endTime))}`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n: number): string {
|
||||||
|
return String(n).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIcsUtc(d: Date): string {
|
||||||
|
return (
|
||||||
|
d.getUTCFullYear().toString() +
|
||||||
|
pad(d.getUTCMonth() + 1) +
|
||||||
|
pad(d.getUTCDate()) +
|
||||||
|
'T' +
|
||||||
|
pad(d.getUTCHours()) +
|
||||||
|
pad(d.getUTCMinutes()) +
|
||||||
|
pad(d.getUTCSeconds()) +
|
||||||
|
'Z'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIcsDate(d: Date): string {
|
||||||
|
return d.getFullYear().toString() + pad(d.getMonth() + 1) + pad(d.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeIcs(s: string): string {
|
||||||
|
return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
|
||||||
|
}
|
||||||
|
|
@ -26,7 +26,8 @@
|
||||||
"validate:theme-parity": "node scripts/validate-theme-parity.mjs",
|
"validate:theme-parity": "node scripts/validate-theme-parity.mjs",
|
||||||
"validate:i18n-parity": "node scripts/validate-i18n-parity.mjs",
|
"validate:i18n-parity": "node scripts/validate-i18n-parity.mjs",
|
||||||
"validate:i18n-hardcoded": "node scripts/validate-no-hardcoded-strings.mjs",
|
"validate:i18n-hardcoded": "node scripts/validate-no-hardcoded-strings.mjs",
|
||||||
"validate:all": "pnpm run validate:turbo && pnpm run validate:pg-schema && pnpm run validate:theme-variables && pnpm run validate:theme-utilities && pnpm run validate:theme-parity && pnpm run validate:i18n-parity && pnpm run validate:i18n-hardcoded && pnpm run check:crypto && pnpm run audit:encrypted-tools",
|
"validate:i18n-keys": "node scripts/validate-i18n-keys.mjs",
|
||||||
|
"validate:all": "pnpm run validate:turbo && pnpm run validate:pg-schema && pnpm run validate:theme-variables && pnpm run validate:theme-utilities && pnpm run validate:theme-parity && pnpm run validate:i18n-parity && pnpm run validate:i18n-hardcoded && pnpm run validate:i18n-keys && pnpm run check:crypto && pnpm run audit:encrypted-tools",
|
||||||
"check:crypto": "node scripts/audit-crypto-registry.mjs",
|
"check:crypto": "node scripts/audit-crypto-registry.mjs",
|
||||||
"check:crypto:seed": "node scripts/audit-crypto-registry.mjs --seed",
|
"check:crypto:seed": "node scripts/audit-crypto-registry.mjs --seed",
|
||||||
"audit:encrypted-tools": "bun run scripts/audit-encrypted-tools.ts",
|
"audit:encrypted-tools": "bun run scripts/audit-encrypted-tools.ts",
|
||||||
|
|
|
||||||
37
scripts/i18n-missing-baseline.json
Normal file
37
scripts/i18n-missing-baseline.json
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"apps/mana/apps/web/src/lib/components/dashboard/widgets/TasksTodayWidget.svelte": 1,
|
||||||
|
"apps/mana/apps/web/src/lib/components/dashboard/widgets/TransactionsWidget.svelte": 1,
|
||||||
|
"apps/mana/apps/web/src/lib/components/OfflineIndicator.svelte": 3,
|
||||||
|
"apps/mana/apps/web/src/lib/components/PwaUpdatePrompt.svelte": 3,
|
||||||
|
"apps/mana/apps/web/src/lib/modules/period/ListView.svelte": 1,
|
||||||
|
"apps/mana/apps/web/src/lib/modules/plants/ListView.svelte": 5,
|
||||||
|
"apps/mana/apps/web/src/lib/modules/quotes/components/QuoteCard.svelte": 4,
|
||||||
|
"apps/mana/apps/web/src/lib/modules/times/components/EntryForm.svelte": 6,
|
||||||
|
"apps/mana/apps/web/src/lib/modules/times/components/EntryItem.svelte": 6,
|
||||||
|
"apps/mana/apps/web/src/lib/modules/times/components/EntryList.svelte": 2,
|
||||||
|
"apps/mana/apps/web/src/lib/modules/times/components/TimerCard.svelte": 6,
|
||||||
|
"apps/mana/apps/web/src/lib/modules/times/components/TimerIndicator.svelte": 2,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/citycorners/+page.svelte": 10,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/citycorners/add-city/+page.svelte": 21,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/+page.svelte": 10,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/add/+page.svelte": 27,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/locations/[id]/+page.svelte": 22,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/locations/[id]/edit/+page.svelte": 26,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/map/+page.svelte": 6,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/citycorners/favorites/+page.svelte": 6,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/quotes/+page.svelte": 3,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/quotes/categories/+page.svelte": 2,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/quotes/category/[category]/+page.svelte": 8,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/quotes/favorites/+page.svelte": 8,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/quotes/lists/[id]/+page.svelte": 31,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/quotes/lists/+page.svelte": 16,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/times/+page.svelte": 4,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/times/clients/[id]/+page.svelte": 6,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/times/clients/+page.svelte": 12,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/times/clock/alarms/+page.svelte": 8,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/times/entries/+page.svelte": 6,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/times/projects/[id]/+page.svelte": 12,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/times/projects/+page.svelte": 11,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/times/reports/+page.svelte": 12,
|
||||||
|
"apps/mana/apps/web/src/routes/(app)/times/templates/+page.svelte": 8
|
||||||
|
}
|
||||||
211
scripts/validate-i18n-keys.mjs
Normal file
211
scripts/validate-i18n-keys.mjs
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Cross-checks i18n key usage in code against keys defined in DE
|
||||||
|
* locale JSONs. Two directions:
|
||||||
|
*
|
||||||
|
* - **Missing**: a `$_('a.b.c')` call where `a.b.c` is not in DE.
|
||||||
|
* These would render as the raw key string at runtime — a
|
||||||
|
* user-visible bug. Tracked against a per-file baseline so the
|
||||||
|
* existing backlog doesn't block CI but new misses fail hard.
|
||||||
|
*
|
||||||
|
* - **Dead**: a key in DE that no `$_(…)` call references (statically
|
||||||
|
* or via a known dynamic prefix). Reported as INFO; not enforced
|
||||||
|
* because the writing-key-first workflow would otherwise block.
|
||||||
|
*
|
||||||
|
* Dynamic suffixes via template literals (`$_(`ns.foo.${x}`)`) and
|
||||||
|
* concatenations (`$_('ns.foo.' + x)`) become "prefix masks": every
|
||||||
|
* key under `ns.foo.` is treated as potentially used.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/validate-i18n-keys.mjs # check against baseline
|
||||||
|
* node scripts/validate-i18n-keys.mjs --update # rewrite baseline
|
||||||
|
* node scripts/validate-i18n-keys.mjs --report # print full dead-key list
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO_ROOT = join(__dirname, '..');
|
||||||
|
const LOCALES_DIR = join(REPO_ROOT, 'apps/mana/apps/web/src/lib/i18n/locales');
|
||||||
|
const SRC_DIR = 'apps/mana/apps/web/src';
|
||||||
|
const BASELINE_PATH = join(__dirname, 'i18n-missing-baseline.json');
|
||||||
|
|
||||||
|
function flattenKeys(obj, prefix = '') {
|
||||||
|
const keys = [];
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
const path = prefix ? `${prefix}.${k}` : k;
|
||||||
|
if (v && typeof v === 'object' && !Array.isArray(v)) keys.push(...flattenKeys(v, path));
|
||||||
|
else keys.push(path);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDefinedKeys() {
|
||||||
|
const defined = new Set();
|
||||||
|
const namespaces = readdirSync(LOCALES_DIR).filter((f) =>
|
||||||
|
statSync(join(LOCALES_DIR, f)).isDirectory()
|
||||||
|
);
|
||||||
|
for (const ns of namespaces) {
|
||||||
|
const path = join(LOCALES_DIR, ns, 'de.json');
|
||||||
|
if (!existsSync(path)) continue;
|
||||||
|
for (const k of flattenKeys(JSON.parse(readFileSync(path, 'utf8')))) {
|
||||||
|
defined.add(`${ns}.${k}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanUsages() {
|
||||||
|
const files = execSync(`git ls-files '${SRC_DIR}/**/*.svelte' '${SRC_DIR}/**/*.ts'`, {
|
||||||
|
cwd: REPO_ROOT,
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// per-key list of files where it's referenced — for nice error reporting
|
||||||
|
const usedByFile = new Map();
|
||||||
|
const dynamicPrefixes = new Set();
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
const src = readFileSync(join(REPO_ROOT, f), 'utf8');
|
||||||
|
|
||||||
|
// $_('a.b.c') or _('a.b.c')
|
||||||
|
for (const m of src.matchAll(/\$?_\(\s*['"]([a-zA-Z][\w.-]*)['"]/g)) {
|
||||||
|
const key = m[1];
|
||||||
|
if (!usedByFile.has(key)) usedByFile.set(key, new Set());
|
||||||
|
usedByFile.get(key).add(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// $_(`a.b.${x}`) → prefix "a.b."
|
||||||
|
for (const m of src.matchAll(/\$?_\(\s*`([a-zA-Z][\w.-]*\.)\$\{/g)) {
|
||||||
|
dynamicPrefixes.add(m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// $_('a.b.' + x) → prefix "a.b."
|
||||||
|
for (const m of src.matchAll(/\$?_\(\s*['"]([a-zA-Z][\w.-]*\.)['"]\s*\+/g)) {
|
||||||
|
dynamicPrefixes.add(m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { usedByFile, dynamicPrefixes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBaseline() {
|
||||||
|
if (!existsSync(BASELINE_PATH)) return {};
|
||||||
|
return JSON.parse(readFileSync(BASELINE_PATH, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPerFileMissing(usedByFile, defined) {
|
||||||
|
// Returns: { file: { count, keys: [...] } }
|
||||||
|
const perFile = {};
|
||||||
|
for (const [key, files] of usedByFile) {
|
||||||
|
if (defined.has(key)) continue;
|
||||||
|
for (const f of files) {
|
||||||
|
if (!perFile[f]) perFile[f] = { count: 0, keys: new Set() };
|
||||||
|
perFile[f].count++;
|
||||||
|
perFile[f].keys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = {};
|
||||||
|
for (const [f, { count, keys }] of Object.entries(perFile)) {
|
||||||
|
result[f] = count;
|
||||||
|
}
|
||||||
|
return { perFileCount: result, missingKeysByFile: perFile };
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const update = process.argv.includes('--update');
|
||||||
|
const reportMode = process.argv.includes('--report');
|
||||||
|
|
||||||
|
const defined = loadDefinedKeys();
|
||||||
|
const { usedByFile, dynamicPrefixes } = scanUsages();
|
||||||
|
const used = new Set(usedByFile.keys());
|
||||||
|
|
||||||
|
const dead = [...defined].filter(
|
||||||
|
(k) => !used.has(k) && ![...dynamicPrefixes].some((p) => k.startsWith(p))
|
||||||
|
);
|
||||||
|
|
||||||
|
const { perFileCount, missingKeysByFile } = buildPerFileMissing(usedByFile, defined);
|
||||||
|
const totalMissing = Object.values(perFileCount).reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
if (reportMode) {
|
||||||
|
console.log(`Defined keys: ${defined.size}`);
|
||||||
|
console.log(`Statically used: ${used.size}, dynamic prefixes: ${dynamicPrefixes.size}`);
|
||||||
|
console.log(`Dead keys (defined, never referenced): ${dead.length}`);
|
||||||
|
console.log('\n--- top 30 dead keys ---');
|
||||||
|
for (const k of dead.slice(0, 30)) console.log(' ' + k);
|
||||||
|
console.log('\n--- missing keys (used, undefined) ---');
|
||||||
|
for (const [f, info] of Object.entries(missingKeysByFile).slice(0, 20)) {
|
||||||
|
console.log(` ${f}: ${info.count}`);
|
||||||
|
for (const k of [...info.keys].slice(0, 5)) console.log(` - ${k}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (update) {
|
||||||
|
const sorted = Object.fromEntries(
|
||||||
|
Object.entries(perFileCount).sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
);
|
||||||
|
writeFileSync(BASELINE_PATH, JSON.stringify(sorted, null, 2) + '\n');
|
||||||
|
console.log(
|
||||||
|
`✓ Baseline updated: ${totalMissing} missing-key reference(s) across ${Object.keys(perFileCount).length} files.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseline = loadBaseline();
|
||||||
|
const baselineTotal = Object.values(baseline).reduce((a, b) => a + b, 0);
|
||||||
|
const violations = [];
|
||||||
|
for (const [file, n] of Object.entries(perFileCount)) {
|
||||||
|
const b = baseline[file] ?? 0;
|
||||||
|
if (n > b) {
|
||||||
|
violations.push({
|
||||||
|
file,
|
||||||
|
current: n,
|
||||||
|
baseline: b,
|
||||||
|
delta: n - b,
|
||||||
|
keys: [...missingKeysByFile[file].keys].filter(
|
||||||
|
(k) => !(baseline[file] && false) // we don't track which exact keys were baselined; show all
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (violations.length > 0) {
|
||||||
|
console.error(`\n✗ i18n missing-key check FAILED — ${violations.length} file(s) over baseline:\n`);
|
||||||
|
for (const v of violations.slice(0, 20)) {
|
||||||
|
console.error(` ${v.file}: ${v.current} (was ${v.baseline}, +${v.delta})`);
|
||||||
|
for (const k of v.keys.slice(0, 3)) console.error(` - ${k}`);
|
||||||
|
if (v.keys.length > 3) console.error(` … +${v.keys.length - 3} more keys`);
|
||||||
|
}
|
||||||
|
if (violations.length > 20) console.error(` … +${violations.length - 20} more files`);
|
||||||
|
console.error(
|
||||||
|
`\nA $_('…') call references a key that does not exist in any DE locale.\n` +
|
||||||
|
`At runtime this renders as the raw key string. Add the key to the\n` +
|
||||||
|
`appropriate locales/<ns>/de.json (parity validator will demand the\n` +
|
||||||
|
`other locales) — or fix the typo.\n` +
|
||||||
|
`If intentional (e.g. you renamed away a key still being referenced\n` +
|
||||||
|
`in legacy code), run: pnpm run validate:i18n-keys -- --update\n`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shrunk = Object.keys(baseline).filter((f) => (perFileCount[f] ?? 0) < baseline[f]).length;
|
||||||
|
const cleaned = Object.keys(baseline).filter((f) => !(f in perFileCount)).length;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✓ i18n keys: ${totalMissing} missing reference(s) (baseline ${baselineTotal}); ` +
|
||||||
|
`${dead.length} dead key(s) defined but unused.` +
|
||||||
|
(shrunk || cleaned
|
||||||
|
? `\n ${shrunk} file(s) shrunk, ${cleaned} file(s) fully cleaned — ` +
|
||||||
|
`run 'pnpm run validate:i18n-keys -- --update' to ratchet.`
|
||||||
|
: '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue