feat: add cross-provider calendar sync tool
Users can now create sync rules to automatically sync events between any two connected calendar providers (Google, Apple, Outlook, Synology). - New Prisma models: CalendarSyncRule + CalendarSyncMapping - Sync engine in src/lib/calendar-cross-sync.ts: fingerprint-based change detection, anti-loop protection, one-way and two-way support - API routes: GET/POST rules, PATCH/DELETE rule by id, POST run - CalendarSyncRulesPanel component with rule list, add/edit form, toggle enable/disable, Sync Now button with result summary - New "Sync" tab in settings sidebar (between Connections and Account) - Sync rules run automatically after each forced calendar cache refresh v1.76.0
This commit is contained in:
parent
d8c30cdb1f
commit
620118e6d5
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.75.8",
|
"version": "1.76.0",
|
||||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -110,6 +110,7 @@ model User {
|
|||||||
accounts Account[]
|
accounts Account[]
|
||||||
cachedCalendarEvents CachedCalendarEvent[]
|
cachedCalendarEvents CachedCalendarEvent[]
|
||||||
calendarConnections CalendarConnection[]
|
calendarConnections CalendarConnection[]
|
||||||
|
calendarSyncRules CalendarSyncRule[]
|
||||||
projects Project[]
|
projects Project[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
weatherEnabled Boolean @default(false)
|
weatherEnabled Boolean @default(false)
|
||||||
@ -316,6 +317,47 @@ model PushSubscription {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model CalendarSyncRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
name String @default("")
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
direction String @default("one-way") // "one-way" (source→target)
|
||||||
|
sourceConnectionId String
|
||||||
|
sourceCalendarId String
|
||||||
|
targetConnectionId String
|
||||||
|
targetCalendarId String
|
||||||
|
syncDescription Boolean @default(true)
|
||||||
|
syncLocation Boolean @default(true)
|
||||||
|
syncRecurring Boolean @default(false)
|
||||||
|
titlePrefix String @default("")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
lastSyncedAt DateTime?
|
||||||
|
mappings CalendarSyncMapping[]
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CalendarSyncMapping {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
ruleId String
|
||||||
|
userId String
|
||||||
|
sourceEventId String
|
||||||
|
sourceProvider String
|
||||||
|
targetEventId String
|
||||||
|
targetProvider String
|
||||||
|
targetCalendarId String
|
||||||
|
sourceFingerprint String
|
||||||
|
lastSyncedAt DateTime @default(now())
|
||||||
|
rule CalendarSyncRule @relation(fields: [ruleId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([ruleId, sourceEventId])
|
||||||
|
@@index([userId, targetEventId, targetProvider])
|
||||||
|
@@index([ruleId])
|
||||||
|
}
|
||||||
|
|
||||||
model SentNotification {
|
model SentNotification {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
48
src/app/api/calendar/cross-sync/rules/[id]/route.ts
Normal file
48
src/app/api/calendar/cross-sync/rules/[id]/route.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
// PATCH /api/calendar/cross-sync/rules/[id] — update a rule
|
||||||
|
export async function PATCH(request: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { email: session.user.email }, select: { id: true } });
|
||||||
|
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const existing = await prisma.calendarSyncRule.findFirst({ where: { id: params.id, userId: user.id } });
|
||||||
|
if (!existing) return NextResponse.json({ error: 'Rule not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const allowed = ['name', 'enabled', 'direction', 'sourceConnectionId', 'sourceCalendarId',
|
||||||
|
'targetConnectionId', 'targetCalendarId', 'syncDescription', 'syncLocation',
|
||||||
|
'syncRecurring', 'titlePrefix'];
|
||||||
|
|
||||||
|
const data: Record<string, any> = {};
|
||||||
|
for (const key of allowed) {
|
||||||
|
if (key in body) data[key] = body[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rule = await prisma.calendarSyncRule.update({ where: { id: params.id }, data });
|
||||||
|
return NextResponse.json({ rule });
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/calendar/cross-sync/rules/[id] — delete a rule and all its mappings
|
||||||
|
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { email: session.user.email }, select: { id: true } });
|
||||||
|
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const existing = await prisma.calendarSyncRule.findFirst({ where: { id: params.id, userId: user.id } });
|
||||||
|
if (!existing) return NextResponse.json({ error: 'Rule not found' }, { status: 404 });
|
||||||
|
|
||||||
|
// Cascade delete (mappings deleted via onDelete: Cascade in schema)
|
||||||
|
await prisma.calendarSyncRule.delete({ where: { id: params.id } });
|
||||||
|
// Also clean up reverse mappings stored under the synthetic _R id
|
||||||
|
await prisma.calendarSyncMapping.deleteMany({ where: { ruleId: params.id + '_R' } });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
75
src/app/api/calendar/cross-sync/rules/route.ts
Normal file
75
src/app/api/calendar/cross-sync/rules/route.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
// GET /api/calendar/cross-sync/rules — list all rules for the current user
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { email: session.user.email }, select: { id: true } });
|
||||||
|
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const rules = await prisma.calendarSyncRule.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ rules });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/calendar/cross-sync/rules — create a new rule
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { email: session.user.email }, select: { id: true } });
|
||||||
|
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const {
|
||||||
|
name = '',
|
||||||
|
enabled = true,
|
||||||
|
direction = 'one-way',
|
||||||
|
sourceConnectionId,
|
||||||
|
sourceCalendarId,
|
||||||
|
targetConnectionId,
|
||||||
|
targetCalendarId,
|
||||||
|
syncDescription = true,
|
||||||
|
syncLocation = true,
|
||||||
|
syncRecurring = false,
|
||||||
|
titlePrefix = '',
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!sourceConnectionId || !sourceCalendarId || !targetConnectionId || !targetCalendarId) {
|
||||||
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify connections belong to this user
|
||||||
|
const connCount = await prisma.calendarConnection.count({
|
||||||
|
where: { id: { in: [sourceConnectionId, targetConnectionId] }, userId: user.id },
|
||||||
|
});
|
||||||
|
if (connCount < 2) {
|
||||||
|
return NextResponse.json({ error: 'Invalid connection IDs' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rule = await prisma.calendarSyncRule.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
name,
|
||||||
|
enabled,
|
||||||
|
direction,
|
||||||
|
sourceConnectionId,
|
||||||
|
sourceCalendarId,
|
||||||
|
targetConnectionId,
|
||||||
|
targetCalendarId,
|
||||||
|
syncDescription,
|
||||||
|
syncLocation,
|
||||||
|
syncRecurring,
|
||||||
|
titlePrefix,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ rule }, { status: 201 });
|
||||||
|
}
|
||||||
22
src/app/api/calendar/cross-sync/run/route.ts
Normal file
22
src/app/api/calendar/cross-sync/run/route.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { runSyncRules } from '@/lib/calendar-cross-sync';
|
||||||
|
|
||||||
|
// POST /api/calendar/cross-sync/run — manually trigger sync rules for the current user
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { email: session.user.email }, select: { id: true } });
|
||||||
|
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await runSyncRules(user.id);
|
||||||
|
return NextResponse.json({ results });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[cross-sync/run] Error:', err);
|
||||||
|
return NextResponse.json({ error: err?.message || 'Sync failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { getServerSession } from 'next-auth';
|
|||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { readCachedEvents, isCacheStale, refreshConnectionCache, RefreshableConnection } from '@/lib/calendar-cache';
|
import { readCachedEvents, isCacheStale, refreshConnectionCache, RefreshableConnection } from '@/lib/calendar-cache';
|
||||||
|
import { runSyncRules } from '@/lib/calendar-cross-sync';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@ -70,6 +71,8 @@ export async function POST(request: NextRequest) {
|
|||||||
if (forceRefresh) {
|
if (forceRefresh) {
|
||||||
// BLOCKING: wait for fresh data when user explicitly requests refresh
|
// BLOCKING: wait for fresh data when user explicitly requests refresh
|
||||||
await doRefresh();
|
await doRefresh();
|
||||||
|
// Run cross-sync rules after cache refresh (fire-and-forget, don't block response)
|
||||||
|
runSyncRules(user.id).catch(e => console.error('[cross-sync] Error running sync rules:', e));
|
||||||
const freshEvents = await readCachedEvents(user.id, timeMinDate, timeMaxDate);
|
const freshEvents = await readCachedEvents(user.id, timeMinDate, timeMaxDate);
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
536
src/components/CalendarSyncRulesPanel.tsx
Normal file
536
src/components/CalendarSyncRulesPanel.tsx
Normal file
@ -0,0 +1,536 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Plus, Trash2, RefreshCcw, Check, AlertCircle, ArrowRight, ArrowLeftRight, Pencil, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface CalendarSyncRule {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
direction: "one-way" | "two-way";
|
||||||
|
sourceConnectionId: string;
|
||||||
|
sourceCalendarId: string;
|
||||||
|
targetConnectionId: string;
|
||||||
|
targetCalendarId: string;
|
||||||
|
syncDescription: boolean;
|
||||||
|
syncLocation: boolean;
|
||||||
|
syncRecurring: boolean;
|
||||||
|
titlePrefix: string;
|
||||||
|
lastSyncedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CalendarInfo {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
color?: string;
|
||||||
|
editable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectionInfo {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
calendars?: CalendarInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
connections: ConnectionInfo[];
|
||||||
|
darkMode?: boolean;
|
||||||
|
t: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDER_LABELS: Record<string, string> = {
|
||||||
|
google: "Google",
|
||||||
|
apple: "Apple",
|
||||||
|
outlook: "Outlook",
|
||||||
|
synology: "Synology",
|
||||||
|
};
|
||||||
|
|
||||||
|
function calendarLabel(conn: ConnectionInfo, calId: string): string {
|
||||||
|
const cal = conn.calendars?.find((c) => c.id === calId);
|
||||||
|
return cal?.title || calId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerLabel(conn: ConnectionInfo): string {
|
||||||
|
return PROVIDER_LABELS[conn.provider] || conn.provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten all (connectionId, calendarId) pairs for picker usage
|
||||||
|
function allCalendarOptions(connections: ConnectionInfo[]) {
|
||||||
|
const opts: { connId: string; calId: string; label: string; connLabel: string; color?: string }[] = [];
|
||||||
|
for (const conn of connections) {
|
||||||
|
if (!conn.calendars) continue;
|
||||||
|
for (const cal of conn.calendars) {
|
||||||
|
opts.push({
|
||||||
|
connId: conn.id,
|
||||||
|
calId: cal.id,
|
||||||
|
label: cal.title,
|
||||||
|
connLabel: providerLabel(conn),
|
||||||
|
color: cal.color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM = {
|
||||||
|
name: "",
|
||||||
|
enabled: true,
|
||||||
|
direction: "one-way" as "one-way" | "two-way",
|
||||||
|
sourceConnectionId: "",
|
||||||
|
sourceCalendarId: "",
|
||||||
|
targetConnectionId: "",
|
||||||
|
targetCalendarId: "",
|
||||||
|
syncDescription: true,
|
||||||
|
syncLocation: true,
|
||||||
|
syncRecurring: false,
|
||||||
|
titlePrefix: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CalendarSyncRulesPanel({ connections, darkMode, t }: Props) {
|
||||||
|
const [rules, setRules] = useState<CalendarSyncRule[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [syncResults, setSyncResults] = useState<{ ruleId: string; ruleName: string; created: number; updated: number; deleted: number; error?: string }[] | null>(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState({ ...EMPTY_FORM });
|
||||||
|
const [formError, setFormError] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<{ text: string; type: "success" | "error" } | null>(null);
|
||||||
|
|
||||||
|
const calOptions = allCalendarOptions(connections);
|
||||||
|
|
||||||
|
const fetchRules = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/calendar/cross-sync/rules");
|
||||||
|
const data = await res.json();
|
||||||
|
setRules(data.rules || []);
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchRules(); }, [fetchRules]);
|
||||||
|
|
||||||
|
const openNewForm = () => {
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setEditingId(null);
|
||||||
|
setFormError("");
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditForm = (rule: CalendarSyncRule) => {
|
||||||
|
setForm({
|
||||||
|
name: rule.name,
|
||||||
|
enabled: rule.enabled,
|
||||||
|
direction: rule.direction,
|
||||||
|
sourceConnectionId: rule.sourceConnectionId,
|
||||||
|
sourceCalendarId: rule.sourceCalendarId,
|
||||||
|
targetConnectionId: rule.targetConnectionId,
|
||||||
|
targetCalendarId: rule.targetCalendarId,
|
||||||
|
syncDescription: rule.syncDescription,
|
||||||
|
syncLocation: rule.syncLocation,
|
||||||
|
syncRecurring: rule.syncRecurring,
|
||||||
|
titlePrefix: rule.titlePrefix,
|
||||||
|
});
|
||||||
|
setEditingId(rule.id);
|
||||||
|
setFormError("");
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeForm = () => { setShowForm(false); setEditingId(null); };
|
||||||
|
|
||||||
|
const saveForm = async () => {
|
||||||
|
if (!form.sourceCalendarId || !form.targetCalendarId) {
|
||||||
|
setFormError("Please select a source and target calendar.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.sourceConnectionId === form.targetConnectionId && form.sourceCalendarId === form.targetCalendarId) {
|
||||||
|
setFormError("Source and target calendar must be different.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setFormError("");
|
||||||
|
try {
|
||||||
|
const url = editingId
|
||||||
|
? `/api/calendar/cross-sync/rules/${editingId}`
|
||||||
|
: "/api/calendar/cross-sync/rules";
|
||||||
|
const method = editingId ? "PATCH" : "POST";
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || "Failed to save");
|
||||||
|
}
|
||||||
|
await fetchRules();
|
||||||
|
closeForm();
|
||||||
|
setMsg({ text: editingId ? "Rule updated." : "Rule created.", type: "success" });
|
||||||
|
setTimeout(() => setMsg(null), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setFormError(err.message || "Failed to save rule");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteRule = async (ruleId: string) => {
|
||||||
|
if (!confirm("Delete this sync rule? Existing synced events will NOT be removed.")) return;
|
||||||
|
try {
|
||||||
|
await fetch(`/api/calendar/cross-sync/rules/${ruleId}`, { method: "DELETE" });
|
||||||
|
setRules((prev) => prev.filter((r) => r.id !== ruleId));
|
||||||
|
setMsg({ text: "Rule deleted.", type: "success" });
|
||||||
|
setTimeout(() => setMsg(null), 3000);
|
||||||
|
} catch {
|
||||||
|
setMsg({ text: "Failed to delete rule.", type: "error" });
|
||||||
|
setTimeout(() => setMsg(null), 4000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEnabled = async (rule: CalendarSyncRule) => {
|
||||||
|
const newVal = !rule.enabled;
|
||||||
|
setRules((prev) => prev.map((r) => r.id === rule.id ? { ...r, enabled: newVal } : r));
|
||||||
|
await fetch(`/api/calendar/cross-sync/rules/${rule.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ enabled: newVal }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const runSync = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
setSyncResults(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/calendar/cross-sync/run", { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
setSyncResults(data.results || []);
|
||||||
|
} catch {
|
||||||
|
setMsg({ text: "Sync failed.", type: "error" });
|
||||||
|
setTimeout(() => setMsg(null), 4000);
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Derive connection for a given connectionId
|
||||||
|
const connFor = (id: string) => connections.find((c) => c.id === id);
|
||||||
|
|
||||||
|
const label = (s: string) => (
|
||||||
|
<label style={{ fontSize: "0.8rem", fontWeight: 600, color: "var(--weekly-settings-label)", display: "block", marginBottom: "4px" }}>
|
||||||
|
{s}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: "100%", padding: "7px 10px", borderRadius: "7px",
|
||||||
|
border: "1px solid var(--weekly-border, #e5e7eb)",
|
||||||
|
background: "var(--weekly-input-bg, var(--weekly-bg, #fff))",
|
||||||
|
color: "var(--weekly-text, #333)", fontSize: "0.875rem",
|
||||||
|
};
|
||||||
|
|
||||||
|
const btnStyle = (primary?: boolean): React.CSSProperties => ({
|
||||||
|
padding: "8px 16px", borderRadius: "8px",
|
||||||
|
background: primary ? "var(--weekly-text, #333)" : "var(--weekly-bg, #fff)",
|
||||||
|
color: primary ? "var(--weekly-bg, #fff)" : "var(--weekly-text, #333)",
|
||||||
|
border: primary ? "none" : "1px solid var(--weekly-border, #e5e7eb)",
|
||||||
|
fontSize: "0.875rem", fontWeight: 500, cursor: "pointer",
|
||||||
|
display: "flex", alignItems: "center", gap: "6px",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calendar picker select for the form
|
||||||
|
const CalPicker = ({ label: pickerLabel, connId, calId, onChange }: {
|
||||||
|
label: string;
|
||||||
|
connId: string;
|
||||||
|
calId: string;
|
||||||
|
onChange: (connId: string, calId: string) => void;
|
||||||
|
}) => {
|
||||||
|
const value = connId && calId ? `${connId}::${calId}` : "";
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: "12px" }}>
|
||||||
|
{label(pickerLabel)}
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const [cid, calid] = e.target.value.split("::");
|
||||||
|
onChange(cid, calid);
|
||||||
|
}}
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
<option value="">— Select calendar —</option>
|
||||||
|
{calOptions.map((opt) => (
|
||||||
|
<option key={`${opt.connId}::${opt.calId}`} value={`${opt.connId}::${opt.calId}`}>
|
||||||
|
[{opt.connLabel}] {opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <p style={{ opacity: 0.5, fontSize: "0.875rem" }}>Loading sync rules…</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||||
|
{/* Enable/Disable section header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", paddingBottom: "12px", borderBottom: "1px solid var(--weekly-border, #e5e7eb)" }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: "1rem", fontWeight: 700, marginBottom: "2px" }}>
|
||||||
|
{t.calendarSyncTitle || "Calendar Sync"}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: "0.8rem", color: "var(--weekly-text-light, #888)", margin: 0 }}>
|
||||||
|
{t.calendarSyncDesc || "Sync events between connected calendar providers."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={runSync}
|
||||||
|
disabled={syncing || rules.filter((r) => r.enabled).length === 0}
|
||||||
|
style={{
|
||||||
|
...btnStyle(),
|
||||||
|
opacity: syncing || rules.filter((r) => r.enabled).length === 0 ? 0.4 : 1,
|
||||||
|
}}
|
||||||
|
title={t.syncNow || "Sync Now"}
|
||||||
|
>
|
||||||
|
<RefreshCcw size={15} style={syncing ? { animation: "spin 1s linear infinite" } : {}} />
|
||||||
|
{syncing ? (t.syncing || "Syncing…") : (t.syncNow || "Sync Now")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status message */}
|
||||||
|
{msg && (
|
||||||
|
<div style={{
|
||||||
|
padding: "8px 12px", borderRadius: "7px", fontSize: "0.85rem",
|
||||||
|
background: msg.type === "success" ? "rgba(16,185,129,0.1)" : "rgba(239,68,68,0.1)",
|
||||||
|
color: msg.type === "success" ? "#059669" : "#dc2626",
|
||||||
|
border: `1px solid ${msg.type === "success" ? "#10b981" : "#ef4444"}`,
|
||||||
|
display: "flex", alignItems: "center", gap: "6px",
|
||||||
|
}}>
|
||||||
|
{msg.type === "success" ? <Check size={14} /> : <AlertCircle size={14} />}
|
||||||
|
{msg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sync results */}
|
||||||
|
{syncResults && (
|
||||||
|
<div style={{ padding: "10px 12px", borderRadius: "8px", border: "1px solid var(--weekly-border, #e5e7eb)", background: "var(--weekly-hover, #f9fafb)", fontSize: "0.8rem" }}>
|
||||||
|
<strong style={{ display: "block", marginBottom: "6px" }}>{t.syncResults || "Sync Results"}</strong>
|
||||||
|
{syncResults.length === 0
|
||||||
|
? <span style={{ opacity: 0.5 }}>{t.noRulesEnabled || "No enabled rules found."}</span>
|
||||||
|
: syncResults.map((r) => (
|
||||||
|
<div key={r.ruleId} style={{ marginBottom: "4px", display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>{r.ruleName || "Rule"}</span>
|
||||||
|
{r.error
|
||||||
|
? <span style={{ color: "#dc2626" }}>Error: {r.error}</span>
|
||||||
|
: <>
|
||||||
|
{r.created > 0 && <span style={{ color: "#059669" }}>+{r.created}</span>}
|
||||||
|
{r.updated > 0 && <span style={{ color: "#0ea5e9" }}>~{r.updated}</span>}
|
||||||
|
{r.deleted > 0 && <span style={{ color: "#f59e0b" }}>−{r.deleted}</span>}
|
||||||
|
{r.created === 0 && r.updated === 0 && r.deleted === 0 && <span style={{ opacity: 0.5 }}>up to date</span>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No connections warning */}
|
||||||
|
{connections.length < 2 && (
|
||||||
|
<div style={{ padding: "10px 12px", borderRadius: "8px", background: "rgba(245,158,11,0.08)", border: "1px solid #f59e0b", fontSize: "0.85rem", color: "#b45309" }}>
|
||||||
|
<AlertCircle size={14} style={{ display: "inline", marginRight: "6px" }} />
|
||||||
|
{t.syncNeedsTwo || "You need at least two connected calendars to create a sync rule."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rules list */}
|
||||||
|
{rules.length > 0 && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||||
|
{rules.map((rule) => {
|
||||||
|
const srcConn = connFor(rule.sourceConnectionId);
|
||||||
|
const tgtConn = connFor(rule.targetConnectionId);
|
||||||
|
return (
|
||||||
|
<div key={rule.id} style={{
|
||||||
|
padding: "12px 14px", borderRadius: "10px",
|
||||||
|
border: "1px solid var(--weekly-border, #e5e7eb)",
|
||||||
|
background: "var(--weekly-bg, #fff)",
|
||||||
|
display: "flex", flexDirection: "column", gap: "6px",
|
||||||
|
}}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px", flex: 1, minWidth: 0 }}>
|
||||||
|
{/* Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleEnabled(rule)}
|
||||||
|
title={rule.enabled ? "Disable" : "Enable"}
|
||||||
|
style={{
|
||||||
|
width: "34px", height: "20px", borderRadius: "10px", border: "none",
|
||||||
|
background: rule.enabled ? "#22c55e" : "#d1d5db",
|
||||||
|
position: "relative", cursor: "pointer", flexShrink: 0, transition: "background 0.2s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
position: "absolute", top: "3px",
|
||||||
|
left: rule.enabled ? "17px" : "3px",
|
||||||
|
width: "14px", height: "14px", borderRadius: "50%",
|
||||||
|
background: "#fff", transition: "left 0.2s",
|
||||||
|
}} />
|
||||||
|
</button>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: "0.875rem", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{rule.name || `${srcConn ? providerLabel(srcConn) : "?"} → ${tgtConn ? providerLabel(tgtConn) : "?"}`}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "var(--weekly-text-light, #888)", display: "flex", alignItems: "center", gap: "4px", marginTop: "2px" }}>
|
||||||
|
<span>{srcConn ? calendarLabel(srcConn, rule.sourceCalendarId) : rule.sourceCalendarId}</span>
|
||||||
|
{rule.direction === "two-way" ? <ArrowLeftRight size={11} /> : <ArrowRight size={11} />}
|
||||||
|
<span>{tgtConn ? calendarLabel(tgtConn, rule.targetCalendarId) : rule.targetCalendarId}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "4px", flexShrink: 0 }}>
|
||||||
|
<button onClick={() => openEditForm(rule)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--weekly-text-light, #888)", padding: "4px" }} title="Edit">
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteRule(rule.id)} style={{ background: "none", border: "none", cursor: "pointer", color: "#ef4444", padding: "4px" }} title="Delete">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{rule.lastSyncedAt && (
|
||||||
|
<div style={{ fontSize: "0.7rem", color: "var(--weekly-text-light, #aaa)" }}>
|
||||||
|
Last synced: {new Date(rule.lastSyncedAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rules.length === 0 && !showForm && (
|
||||||
|
<p style={{ opacity: 0.5, fontSize: "0.875rem", textAlign: "center", padding: "16px 0" }}>
|
||||||
|
{t.noSyncRules || "No sync rules yet. Add one below."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add new rule button */}
|
||||||
|
{!showForm && connections.length >= 2 && (
|
||||||
|
<button onClick={openNewForm} style={{ ...btnStyle(), justifyContent: "center", padding: "10px 16px" }}>
|
||||||
|
<Plus size={15} />
|
||||||
|
{t.addSyncRule || "Add Sync Rule"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
{showForm && (
|
||||||
|
<div style={{
|
||||||
|
padding: "16px", borderRadius: "12px",
|
||||||
|
border: "1px solid var(--weekly-border, #e5e7eb)",
|
||||||
|
background: "var(--weekly-hover, #f9fafb)",
|
||||||
|
display: "flex", flexDirection: "column", gap: "8px",
|
||||||
|
}}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "4px" }}>
|
||||||
|
<strong style={{ fontSize: "0.9rem" }}>{editingId ? (t.editSyncRule || "Edit Rule") : (t.newSyncRule || "New Sync Rule")}</strong>
|
||||||
|
<button onClick={closeForm} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--weekly-text-light, #888)" }}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rule name */}
|
||||||
|
<div>
|
||||||
|
{label(t.syncRuleName || "Rule Name (optional)")}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder={t.syncRuleNamePlaceholder || "e.g. Work → Personal"}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Direction */}
|
||||||
|
<div style={{ marginBottom: "4px" }}>
|
||||||
|
{label(t.syncDirection || "Direction")}
|
||||||
|
<div style={{ display: "flex", gap: "8px" }}>
|
||||||
|
{(["one-way", "two-way"] as const).map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
onClick={() => setForm({ ...form, direction: d })}
|
||||||
|
style={{
|
||||||
|
...btnStyle(form.direction === d),
|
||||||
|
flex: 1, justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{d === "one-way" ? <><ArrowRight size={14} /> {t.oneWay || "One-way"}</> : <><ArrowLeftRight size={14} /> {t.twoWay || "Two-way"}</>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source calendar */}
|
||||||
|
<CalPicker
|
||||||
|
label={t.sourceCalendar || "Source Calendar"}
|
||||||
|
connId={form.sourceConnectionId}
|
||||||
|
calId={form.sourceCalendarId}
|
||||||
|
onChange={(connId, calId) => setForm({ ...form, sourceConnectionId: connId, sourceCalendarId: calId })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Target calendar */}
|
||||||
|
<CalPicker
|
||||||
|
label={t.targetCalendar || "Target Calendar"}
|
||||||
|
connId={form.targetConnectionId}
|
||||||
|
calId={form.targetCalendarId}
|
||||||
|
onChange={(connId, calId) => setForm({ ...form, targetConnectionId: connId, targetCalendarId: calId })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Title prefix */}
|
||||||
|
<div>
|
||||||
|
{label(t.titlePrefix || "Title Prefix (optional)")}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.titlePrefix}
|
||||||
|
onChange={(e) => setForm({ ...form, titlePrefix: e.target.value })}
|
||||||
|
placeholder={t.titlePrefixPlaceholder || "e.g. [Work] "}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options checkboxes */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginTop: "4px" }}>
|
||||||
|
{([
|
||||||
|
{ key: "syncDescription", label: t.syncDescription || "Sync description" },
|
||||||
|
{ key: "syncLocation", label: t.syncLocation || "Sync location" },
|
||||||
|
{ key: "syncRecurring", label: t.syncRecurring || "Include recurring events" },
|
||||||
|
] as const).map(({ key, label: optLabel }) => (
|
||||||
|
<label key={key} style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "0.875rem", cursor: "pointer" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form[key]}
|
||||||
|
onChange={(e) => setForm({ ...form, [key]: e.target.checked })}
|
||||||
|
style={{ width: "15px", height: "15px" }}
|
||||||
|
/>
|
||||||
|
{optLabel}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && (
|
||||||
|
<div style={{ fontSize: "0.8rem", color: "#dc2626", display: "flex", alignItems: "center", gap: "6px" }}>
|
||||||
|
<AlertCircle size={13} /> {formError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "8px", marginTop: "4px" }}>
|
||||||
|
<button onClick={saveForm} disabled={saving} style={{ ...btnStyle(true), flex: 1, justifyContent: "center", opacity: saving ? 0.6 : 1 }}>
|
||||||
|
{saving ? (t.saving || "Saving…") : (editingId ? (t.updateRule || "Update Rule") : (t.createRule || "Create Rule"))}
|
||||||
|
</button>
|
||||||
|
<button onClick={closeForm} style={{ ...btnStyle(), flexShrink: 0 }}>
|
||||||
|
{t.cancel || "Cancel"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -81,6 +81,7 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
Pencil,
|
Pencil,
|
||||||
FileText,
|
FileText,
|
||||||
|
ArrowLeftRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const stripHtml = (html: string) => html.replace(/<[^>]*>/g, '').trim();
|
const stripHtml = (html: string) => html.replace(/<[^>]*>/g, '').trim();
|
||||||
@ -93,6 +94,7 @@ import RecurringTasksManager from "./RecurringTasksManager";
|
|||||||
export interface RecurringTaskException { id: string; taskId: string; originalDate: string; newDate?: string | null; isCancelled: boolean; createdAt: Date; updatedAt: Date; }
|
export interface RecurringTaskException { id: string; taskId: string; originalDate: string; newDate?: string | null; isCancelled: boolean; createdAt: Date; updatedAt: Date; }
|
||||||
import { ImportListModal } from "./ImportListModal";
|
import { ImportListModal } from "./ImportListModal";
|
||||||
import OnboardingWizard from "./OnboardingWizard";
|
import OnboardingWizard from "./OnboardingWizard";
|
||||||
|
import CalendarSyncRulesPanel from "./CalendarSyncRulesPanel";
|
||||||
import { getRandomLocalQuote } from "@/lib/quotes";
|
import { getRandomLocalQuote } from "@/lib/quotes";
|
||||||
|
|
||||||
// Cookie helpers for per-device settings
|
// Cookie helpers for per-device settings
|
||||||
@ -395,6 +397,32 @@ const translations: Record<string, any> = {
|
|||||||
motivation: "Motivation",
|
motivation: "Motivation",
|
||||||
about: "About",
|
about: "About",
|
||||||
setupAssistant: "Run Setup Assistant",
|
setupAssistant: "Run Setup Assistant",
|
||||||
|
calendarSync: "Sync",
|
||||||
|
calendarSyncTitle: "Calendar Sync",
|
||||||
|
calendarSyncDesc: "Sync events between your connected calendar providers.",
|
||||||
|
syncNow: "Sync Now",
|
||||||
|
syncing: "Syncing…",
|
||||||
|
syncResults: "Sync Results",
|
||||||
|
noRulesEnabled: "No enabled rules found.",
|
||||||
|
syncNeedsTwo: "You need at least two connected calendars to create a sync rule.",
|
||||||
|
noSyncRules: "No sync rules yet. Add one below.",
|
||||||
|
addSyncRule: "Add Sync Rule",
|
||||||
|
editSyncRule: "Edit Rule",
|
||||||
|
newSyncRule: "New Sync Rule",
|
||||||
|
syncRuleName: "Rule Name (optional)",
|
||||||
|
syncRuleNamePlaceholder: "e.g. Work → Personal",
|
||||||
|
syncDirection: "Direction",
|
||||||
|
oneWay: "One-way",
|
||||||
|
twoWay: "Two-way",
|
||||||
|
sourceCalendar: "Source Calendar",
|
||||||
|
targetCalendar: "Target Calendar",
|
||||||
|
titlePrefix: "Title Prefix (optional)",
|
||||||
|
titlePrefixPlaceholder: "e.g. [Work] ",
|
||||||
|
syncDescription: "Sync description",
|
||||||
|
syncLocation: "Sync location",
|
||||||
|
syncRecurring: "Include recurring events",
|
||||||
|
createRule: "Create Rule",
|
||||||
|
updateRule: "Update Rule",
|
||||||
weekStartLabel: "Start week on",
|
weekStartLabel: "Start week on",
|
||||||
startViewLabel: "Start view on",
|
startViewLabel: "Start view on",
|
||||||
monday: "Monday",
|
monday: "Monday",
|
||||||
@ -613,6 +641,31 @@ const translations: Record<string, any> = {
|
|||||||
motivation: "Motivation",
|
motivation: "Motivation",
|
||||||
about: "Über",
|
about: "Über",
|
||||||
setupAssistant: "Einrichtungsassistent starten",
|
setupAssistant: "Einrichtungsassistent starten",
|
||||||
|
calendarSync: "Sync",
|
||||||
|
calendarSyncTitle: "Kalender-Synchronisation",
|
||||||
|
calendarSyncDesc: "Ereignisse zwischen verbundenen Kalender-Anbietern synchronisieren.",
|
||||||
|
syncNow: "Jetzt synchronisieren",
|
||||||
|
syncResults: "Sync-Ergebnis",
|
||||||
|
noRulesEnabled: "Keine aktiven Regeln gefunden.",
|
||||||
|
syncNeedsTwo: "Du benötigst mindestens zwei verbundene Kalender für eine Sync-Regel.",
|
||||||
|
noSyncRules: "Noch keine Sync-Regeln. Füge eine unten hinzu.",
|
||||||
|
addSyncRule: "Regel hinzufügen",
|
||||||
|
editSyncRule: "Regel bearbeiten",
|
||||||
|
newSyncRule: "Neue Sync-Regel",
|
||||||
|
syncRuleName: "Regelname (optional)",
|
||||||
|
syncRuleNamePlaceholder: "z.B. Arbeit → Privat",
|
||||||
|
syncDirection: "Richtung",
|
||||||
|
oneWay: "Einseitig",
|
||||||
|
twoWay: "Beidseitig",
|
||||||
|
sourceCalendar: "Quellkalender",
|
||||||
|
targetCalendar: "Zielkalender",
|
||||||
|
titlePrefix: "Titel-Präfix (optional)",
|
||||||
|
titlePrefixPlaceholder: "z.B. [Arbeit] ",
|
||||||
|
syncDescription: "Beschreibung synchronisieren",
|
||||||
|
syncLocation: "Ort synchronisieren",
|
||||||
|
syncRecurring: "Wiederkehrende Ereignisse einschließen",
|
||||||
|
createRule: "Regel erstellen",
|
||||||
|
updateRule: "Regel aktualisieren",
|
||||||
weekStartLabel: "Woche beginnt am",
|
weekStartLabel: "Woche beginnt am",
|
||||||
startViewLabel: "Ansicht beginnt mit",
|
startViewLabel: "Ansicht beginnt mit",
|
||||||
monday: "Montag",
|
monday: "Montag",
|
||||||
@ -11495,7 +11548,7 @@ interface SettingsSidebarProps {
|
|||||||
fetchAvailableTaskLists: (
|
fetchAvailableTaskLists: (
|
||||||
provider: "google" | "apple" | "outlook" | "synology",
|
provider: "google" | "apple" | "outlook" | "synology",
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about";
|
initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about" | "sync";
|
||||||
projects: { id: string; name: string; icon?: string | null; color?: string | null }[];
|
projects: { id: string; name: string; icon?: string | null; color?: string | null }[];
|
||||||
onProjectsChanged: () => void;
|
onProjectsChanged: () => void;
|
||||||
kanbanStages: KanbanStage[];
|
kanbanStages: KanbanStage[];
|
||||||
@ -11768,7 +11821,7 @@ function SettingsSidebar({
|
|||||||
onRunSetupAssistant,
|
onRunSetupAssistant,
|
||||||
}: SettingsSidebarProps) {
|
}: SettingsSidebarProps) {
|
||||||
const [activeTab, setActiveTab] = useState<
|
const [activeTab, setActiveTab] = useState<
|
||||||
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation"
|
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation" | "sync"
|
||||||
>(initialTab || "general");
|
>(initialTab || "general");
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
@ -12269,6 +12322,7 @@ function SettingsSidebar({
|
|||||||
{ key: "general", icon: <Settings size={18} />, label: t.general },
|
{ key: "general", icon: <Settings size={18} />, label: t.general },
|
||||||
{ key: "localisation", icon: <Globe size={18} />, label: t.localisation },
|
{ key: "localisation", icon: <Globe size={18} />, label: t.localisation },
|
||||||
{ key: "calendar", icon: <Link size={18} />, label: t.calendar },
|
{ key: "calendar", icon: <Link size={18} />, label: t.calendar },
|
||||||
|
{ key: "sync", icon: <ArrowLeftRight size={18} />, label: t.calendarSync || "Sync" },
|
||||||
{ key: "account", icon: <User size={18} />, label: t.account },
|
{ key: "account", icon: <User size={18} />, label: t.account },
|
||||||
{ key: "styling", icon: <Palette size={18} />, label: t.styling },
|
{ key: "styling", icon: <Palette size={18} />, label: t.styling },
|
||||||
{ key: "motivation", icon: <Sparkles size={18} />, label: t.motivation },
|
{ key: "motivation", icon: <Sparkles size={18} />, label: t.motivation },
|
||||||
@ -15240,6 +15294,11 @@ function SettingsSidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : activeTab === "sync" ? (
|
||||||
|
<CalendarSyncRulesPanel
|
||||||
|
connections={connections}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
) : activeTab === "about" ? (
|
) : activeTab === "about" ? (
|
||||||
<div
|
<div
|
||||||
style={{ display: "flex", flexDirection: "column", gap: "20px" }}
|
style={{ display: "flex", flexDirection: "column", gap: "20px" }}
|
||||||
|
|||||||
402
src/lib/calendar-cross-sync.ts
Normal file
402
src/lib/calendar-cross-sync.ts
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
/**
|
||||||
|
* Calendar Cross-Sync Engine
|
||||||
|
*
|
||||||
|
* Syncs events between calendar providers according to user-defined rules.
|
||||||
|
* Supports one-way and two-way sync with fingerprint-based change detection
|
||||||
|
* and anti-loop protection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { CalendarEvent, CalendarConnection, createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from './calendar-events';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fingerprinting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a stable fingerprint for a calendar event so we can detect changes.
|
||||||
|
* Only title + start + end + description + location are included — we skip
|
||||||
|
* provider-specific metadata that legitimately differs between copies.
|
||||||
|
*/
|
||||||
|
export function computeFingerprint(event: {
|
||||||
|
title: string;
|
||||||
|
startDateTime?: string | null;
|
||||||
|
startDate?: string | null;
|
||||||
|
endDateTime?: string | null;
|
||||||
|
endDate?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
}): string {
|
||||||
|
const raw = [
|
||||||
|
(event.title || '').trim(),
|
||||||
|
event.startDateTime || event.startDate || '',
|
||||||
|
event.endDateTime || event.endDate || '',
|
||||||
|
(event.description || '').trim(),
|
||||||
|
(event.location || '').trim(),
|
||||||
|
].join('|');
|
||||||
|
return createHash('sha1').update(raw).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Anti-loop protection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if this externalId was written by our sync engine
|
||||||
|
* (i.e. it is a target event in some mapping). Prevents syncing events
|
||||||
|
* that were themselves created by a sync rule, which would cause a loop.
|
||||||
|
*/
|
||||||
|
async function isAntiLoopTarget(
|
||||||
|
userId: string,
|
||||||
|
externalId: string,
|
||||||
|
provider: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const mapping = await prisma.calendarSyncMapping.findFirst({
|
||||||
|
where: { userId, targetEventId: externalId, targetProvider: provider },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return mapping !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provider-connection helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Load a CalendarConnection from the DB and cast it to the shape calendar-events.ts expects */
|
||||||
|
async function loadConnection(connectionId: string): Promise<CalendarConnection | null> {
|
||||||
|
const row = await prisma.calendarConnection.findUnique({
|
||||||
|
where: { id: connectionId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
provider: true,
|
||||||
|
accessToken: true,
|
||||||
|
refreshToken: true,
|
||||||
|
expiresAt: true,
|
||||||
|
calendars: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!row) return null;
|
||||||
|
return row as unknown as CalendarConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Event payload builder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a CalendarEvent payload suitable for createCalendarEvent / updateCalendarEvent
|
||||||
|
* from a cached event row + rule options.
|
||||||
|
*/
|
||||||
|
function buildPayload(
|
||||||
|
cachedEvent: {
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
startDateTime?: Date | null;
|
||||||
|
startDate?: string | null;
|
||||||
|
endDateTime?: Date | null;
|
||||||
|
endDate?: string | null;
|
||||||
|
isRecurring: boolean;
|
||||||
|
},
|
||||||
|
rule: {
|
||||||
|
syncDescription: boolean;
|
||||||
|
syncLocation: boolean;
|
||||||
|
syncRecurring: boolean;
|
||||||
|
titlePrefix: string;
|
||||||
|
targetCalendarId: string;
|
||||||
|
},
|
||||||
|
targetProvider: string
|
||||||
|
): Partial<CalendarEvent> {
|
||||||
|
const allDay = !cachedEvent.startDateTime;
|
||||||
|
const start = allDay
|
||||||
|
? { date: cachedEvent.startDate || '' }
|
||||||
|
: { dateTime: cachedEvent.startDateTime!.toISOString() };
|
||||||
|
const end = allDay
|
||||||
|
? { date: cachedEvent.endDate || '' }
|
||||||
|
: { dateTime: cachedEvent.endDateTime!.toISOString() };
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${rule.titlePrefix}${cachedEvent.title}`,
|
||||||
|
description: rule.syncDescription ? (cachedEvent.description ?? undefined) : undefined,
|
||||||
|
location: rule.syncLocation ? (cachedEvent.location ?? undefined) : undefined,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
allDay,
|
||||||
|
source: targetProvider as CalendarEvent['source'],
|
||||||
|
calendarId: rule.targetCalendarId,
|
||||||
|
calendarTitle: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core sync logic for a single rule
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function syncOneDirection(params: {
|
||||||
|
userId: string;
|
||||||
|
rule: {
|
||||||
|
id: string;
|
||||||
|
sourceConnectionId: string;
|
||||||
|
sourceCalendarId: string;
|
||||||
|
targetConnectionId: string;
|
||||||
|
targetCalendarId: string;
|
||||||
|
syncDescription: boolean;
|
||||||
|
syncLocation: boolean;
|
||||||
|
syncRecurring: boolean;
|
||||||
|
titlePrefix: string;
|
||||||
|
};
|
||||||
|
sourceConnection: CalendarConnection;
|
||||||
|
targetConnection: CalendarConnection;
|
||||||
|
weekStart: Date;
|
||||||
|
weekEnd: Date;
|
||||||
|
}): Promise<{ created: number; updated: number; deleted: number; skipped: number }> {
|
||||||
|
const { userId, rule, sourceConnection, targetConnection, weekStart, weekEnd } = params;
|
||||||
|
let created = 0, updated = 0, deleted = 0, skipped = 0;
|
||||||
|
|
||||||
|
// Fetch cached source events for the sync window
|
||||||
|
const sourceEvents = await prisma.cachedCalendarEvent.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
connectionId: rule.sourceConnectionId,
|
||||||
|
calendarId: rule.sourceCalendarId,
|
||||||
|
OR: [
|
||||||
|
{ startDateTime: { gte: weekStart, lte: weekEnd } },
|
||||||
|
{ startDate: { gte: weekStart.toISOString().slice(0, 10), lte: weekEnd.toISOString().slice(0, 10) } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load existing mappings for this rule
|
||||||
|
const existingMappings = await prisma.calendarSyncMapping.findMany({
|
||||||
|
where: { ruleId: rule.id },
|
||||||
|
});
|
||||||
|
const mappingBySource = new Map(existingMappings.map(m => [m.sourceEventId, m]));
|
||||||
|
|
||||||
|
// Track which sourceEventIds we processed (to detect deletions)
|
||||||
|
const processedSourceIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const srcEvent of sourceEvents) {
|
||||||
|
// Skip recurring events if rule says so
|
||||||
|
if (srcEvent.isRecurring && !rule.syncRecurring) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anti-loop: skip events that were themselves created by a sync rule
|
||||||
|
if (await isAntiLoopTarget(userId, srcEvent.externalId, srcEvent.provider)) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
processedSourceIds.add(srcEvent.externalId);
|
||||||
|
|
||||||
|
const currentFingerprint = computeFingerprint({
|
||||||
|
title: srcEvent.title,
|
||||||
|
startDateTime: srcEvent.startDateTime?.toISOString(),
|
||||||
|
startDate: srcEvent.startDate,
|
||||||
|
endDateTime: srcEvent.endDateTime?.toISOString(),
|
||||||
|
endDate: srcEvent.endDate,
|
||||||
|
description: srcEvent.description,
|
||||||
|
location: srcEvent.location,
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingMapping = mappingBySource.get(srcEvent.externalId);
|
||||||
|
|
||||||
|
if (!existingMapping) {
|
||||||
|
// New event — create on target
|
||||||
|
try {
|
||||||
|
const payload = buildPayload(srcEvent, rule, targetConnection.provider);
|
||||||
|
const createdEvent = await createCalendarEvent(targetConnection, rule.targetCalendarId, payload);
|
||||||
|
await prisma.calendarSyncMapping.create({
|
||||||
|
data: {
|
||||||
|
ruleId: rule.id,
|
||||||
|
userId,
|
||||||
|
sourceEventId: srcEvent.externalId,
|
||||||
|
sourceProvider: srcEvent.provider,
|
||||||
|
targetEventId: createdEvent.id,
|
||||||
|
targetProvider: targetConnection.provider,
|
||||||
|
targetCalendarId: rule.targetCalendarId,
|
||||||
|
sourceFingerprint: currentFingerprint,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[cross-sync] Failed to create event ${srcEvent.externalId}:`, err);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
} else if (existingMapping.sourceFingerprint !== currentFingerprint) {
|
||||||
|
// Changed event — update on target
|
||||||
|
try {
|
||||||
|
const payload = buildPayload(srcEvent, rule, targetConnection.provider);
|
||||||
|
await updateCalendarEvent(targetConnection, rule.targetCalendarId, existingMapping.targetEventId, payload);
|
||||||
|
await prisma.calendarSyncMapping.update({
|
||||||
|
where: { id: existingMapping.id },
|
||||||
|
data: { sourceFingerprint: currentFingerprint, lastSyncedAt: new Date() },
|
||||||
|
});
|
||||||
|
updated++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[cross-sync] Failed to update event ${srcEvent.externalId}:`, err);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// else: unchanged, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle deletions: source events that have mappings but are no longer in cache
|
||||||
|
for (const mapping of existingMappings) {
|
||||||
|
if (!processedSourceIds.has(mapping.sourceEventId)) {
|
||||||
|
// Source event no longer exists in the synced window → delete target
|
||||||
|
try {
|
||||||
|
await deleteCalendarEvent(targetConnection, rule.targetCalendarId, mapping.targetEventId);
|
||||||
|
await prisma.calendarSyncMapping.delete({ where: { id: mapping.id } });
|
||||||
|
deleted++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[cross-sync] Failed to delete target event ${mapping.targetEventId}:`, err);
|
||||||
|
// Remove stale mapping anyway so it doesn't keep retrying
|
||||||
|
await prisma.calendarSyncMapping.delete({ where: { id: mapping.id } }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created, updated, deleted, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface SyncRuleResult {
|
||||||
|
ruleId: string;
|
||||||
|
ruleName: string;
|
||||||
|
direction: string;
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
deleted: number;
|
||||||
|
skipped: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run all enabled cross-sync rules for a user.
|
||||||
|
* Called after the regular calendar cache refresh so we always work
|
||||||
|
* with fresh data from the cache.
|
||||||
|
*/
|
||||||
|
export async function runSyncRules(userId: string): Promise<SyncRuleResult[]> {
|
||||||
|
const rules = await prisma.calendarSyncRule.findMany({
|
||||||
|
where: { userId, enabled: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rules.length === 0) return [];
|
||||||
|
|
||||||
|
// Define a rolling 8-week window (past 1 week + next 7 weeks)
|
||||||
|
const now = new Date();
|
||||||
|
const weekStart = new Date(now);
|
||||||
|
weekStart.setDate(now.getDate() - 7);
|
||||||
|
weekStart.setHours(0, 0, 0, 0);
|
||||||
|
const weekEnd = new Date(now);
|
||||||
|
weekEnd.setDate(now.getDate() + 7 * 7);
|
||||||
|
weekEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const results: SyncRuleResult[] = [];
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
const result: SyncRuleResult = {
|
||||||
|
ruleId: rule.id,
|
||||||
|
ruleName: rule.name,
|
||||||
|
direction: rule.direction,
|
||||||
|
created: 0,
|
||||||
|
updated: 0,
|
||||||
|
deleted: 0,
|
||||||
|
skipped: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sourceConn = await loadConnection(rule.sourceConnectionId);
|
||||||
|
const targetConn = await loadConnection(rule.targetConnectionId);
|
||||||
|
if (!sourceConn || !targetConn) {
|
||||||
|
result.error = 'Connection not found';
|
||||||
|
results.push(result);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ruleData = {
|
||||||
|
id: rule.id,
|
||||||
|
sourceConnectionId: rule.sourceConnectionId,
|
||||||
|
sourceCalendarId: rule.sourceCalendarId,
|
||||||
|
targetConnectionId: rule.targetConnectionId,
|
||||||
|
targetCalendarId: rule.targetCalendarId,
|
||||||
|
syncDescription: rule.syncDescription,
|
||||||
|
syncLocation: rule.syncLocation,
|
||||||
|
syncRecurring: rule.syncRecurring,
|
||||||
|
titlePrefix: rule.titlePrefix,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Source → Target (always for both one-way and two-way)
|
||||||
|
const fwd = await syncOneDirection({
|
||||||
|
userId,
|
||||||
|
rule: ruleData,
|
||||||
|
sourceConnection: sourceConn,
|
||||||
|
targetConnection: targetConn,
|
||||||
|
weekStart,
|
||||||
|
weekEnd,
|
||||||
|
});
|
||||||
|
result.created += fwd.created;
|
||||||
|
result.updated += fwd.updated;
|
||||||
|
result.deleted += fwd.deleted;
|
||||||
|
result.skipped += fwd.skipped;
|
||||||
|
|
||||||
|
// Target → Source (only for two-way)
|
||||||
|
if (rule.direction === 'two-way') {
|
||||||
|
const reverseRuleData = {
|
||||||
|
...ruleData,
|
||||||
|
id: rule.id + '_reverse', // virtual ID to keep mappings separate; we create real mappings per direction
|
||||||
|
sourceConnectionId: rule.targetConnectionId,
|
||||||
|
sourceCalendarId: rule.targetCalendarId,
|
||||||
|
targetConnectionId: rule.sourceConnectionId,
|
||||||
|
targetCalendarId: rule.sourceCalendarId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// For two-way we use a special mapping key (rule.id + ':reverse')
|
||||||
|
// We'll re-use the same rule.id but swap source/target in a clean way by
|
||||||
|
// just calling syncOneDirection again — mappings are keyed by [ruleId, sourceEventId]
|
||||||
|
// so 'source' for reverse is the original target calendar.
|
||||||
|
// NOTE: to avoid collision we store reverse mappings under ruleId+'_R'
|
||||||
|
const reverseMappingsExist = await prisma.calendarSyncMapping.findFirst({
|
||||||
|
where: { ruleId: rule.id + '_R' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
// We need a real ruleId to store under — we'll use a synthetic one-time id pattern
|
||||||
|
// by creating a lightweight virtual rule inline. To keep things simple we just
|
||||||
|
// pass the reverse as a separate call using a virtual rule id.
|
||||||
|
const bwd = await syncOneDirection({
|
||||||
|
userId,
|
||||||
|
rule: { ...reverseRuleData, id: rule.id + '_R' },
|
||||||
|
sourceConnection: targetConn,
|
||||||
|
targetConnection: sourceConn,
|
||||||
|
weekStart,
|
||||||
|
weekEnd,
|
||||||
|
});
|
||||||
|
result.created += bwd.created;
|
||||||
|
result.updated += bwd.updated;
|
||||||
|
result.deleted += bwd.deleted;
|
||||||
|
result.skipped += bwd.skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lastSyncedAt
|
||||||
|
await prisma.calendarSyncRule.update({
|
||||||
|
where: { id: rule.id },
|
||||||
|
data: { lastSyncedAt: new Date() },
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
result.error = err?.message || 'Unknown error';
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user