trails/apps/planner/app/lib/sessions.ts
Ullrich Schäfer 4b711abaec
Add split export: Export Route vs Export Plan
Split button with default "Export GPX" (track only) and dropdown:
- Export Route: clean track for use in any app
- Export Plan: track + waypoints + no-go areas in GPX extensions
  (trails:planning namespace) for reimporting into the planner

Also:
- Add no-go area serialization to generateGpx (GPX extensions)
- Parse no-go areas from GPX extensions on import
- Initialize no-go areas in Yjs session from imported GPX
- Save to Journal now exports track only (consistent with Export Route)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:52:39 +01:00

128 lines
3.4 KiB
TypeScript

import { randomUUID } from "node:crypto";
import * as Y from "yjs";
import { eq, and, desc, lt } from "drizzle-orm";
import { getOrCreateDoc, deleteDoc } from "./yjs-server.ts";
import { getDb } from "./db.ts";
import { sessions } from "@trails-cool/db/schema/planner";
export type SessionMetadata = typeof sessions.$inferSelect;
export async function createSession(options?: {
callbackUrl?: string;
callbackToken?: string;
}): Promise<SessionMetadata> {
const id = randomUUID();
const doc = getOrCreateDoc(id);
const [row] = await getDb()
.insert(sessions)
.values({
id,
yjsState: Buffer.from(Y.encodeStateAsUpdate(doc)),
callbackUrl: options?.callbackUrl,
callbackToken: options?.callbackToken,
})
.returning();
return row!;
}
export async function getSession(id: string): Promise<SessionMetadata | undefined> {
const [row] = await getDb()
.select()
.from(sessions)
.where(and(eq(sessions.id, id), eq(sessions.closed, false)));
return row;
}
export async function touchSession(id: string): Promise<void> {
await getDb()
.update(sessions)
.set({ lastActivity: new Date() })
.where(eq(sessions.id, id));
}
export async function saveSessionState(id: string): Promise<void> {
const doc = getOrCreateDoc(id);
const state = Y.encodeStateAsUpdate(doc);
await getDb()
.update(sessions)
.set({ yjsState: Buffer.from(state), lastActivity: new Date() })
.where(eq(sessions.id, id));
}
export async function loadSessionState(id: string): Promise<Uint8Array | null> {
const [row] = await getDb()
.select({ yjsState: sessions.yjsState })
.from(sessions)
.where(eq(sessions.id, id));
return row?.yjsState ? new Uint8Array(row.yjsState) : null;
}
export async function closeSession(id: string): Promise<boolean> {
const result = await getDb()
.update(sessions)
.set({ closed: true })
.where(and(eq(sessions.id, id), eq(sessions.closed, false)))
.returning({ id: sessions.id });
deleteDoc(id);
return result.length > 0;
}
export function initializeSessionWithWaypoints(
sessionId: string,
waypoints: Array<{ lat: number; lon: number; name?: string }>,
): void {
const doc = getOrCreateDoc(sessionId);
const yWaypoints = doc.getArray("waypoints");
doc.transact(() => {
for (const wp of waypoints) {
const yMap = new Y.Map();
yMap.set("lat", wp.lat);
yMap.set("lon", wp.lon);
if (wp.name) yMap.set("name", wp.name);
yWaypoints.push([yMap]);
}
});
}
export function initializeSessionWithNoGoAreas(
sessionId: string,
noGoAreas: Array<{ points: Array<{ lat: number; lon: number }> }>,
): void {
const doc = getOrCreateDoc(sessionId);
const yNoGoAreas = doc.getArray("noGoAreas");
doc.transact(() => {
for (const area of noGoAreas) {
const yMap = new Y.Map();
yMap.set("points", area.points);
yNoGoAreas.push([yMap]);
}
});
}
export async function listSessions(): Promise<SessionMetadata[]> {
return getDb()
.select()
.from(sessions)
.where(eq(sessions.closed, false))
.orderBy(desc(sessions.lastActivity));
}
export async function expireSessions(maxAgeDays: number = 7): Promise<number> {
const cutoff = new Date(Date.now() - maxAgeDays * 24 * 60 * 60 * 1000);
const result = await getDb()
.delete(sessions)
.where(lt(sessions.lastActivity, cutoff))
.returning({ id: sessions.id });
for (const row of result) {
deleteDoc(row.id);
}
return result.length;
}