Skip to content

Commit 0c2b392

Browse files
committed
feat: sync_all and keep_local
1 parent 8154326 commit 0c2b392

7 files changed

Lines changed: 451 additions & 61 deletions

File tree

app/page.tsx

Lines changed: 105 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,75 @@ function DeleteDialog({ prompt, onCancel, onConfirm }: DeleteDialogProps) {
395395
)
396396
}
397397

398+
interface OverlapEntry {
399+
id: number
400+
name: string
401+
ics_url: string
402+
sync_all: boolean
403+
keep_local: boolean
404+
}
405+
406+
interface OverlapWarning {
407+
overlapping: OverlapEntry[]
408+
pendingSubmit: () => void
409+
}
410+
411+
function OverlapWarningDialog({
412+
warning,
413+
onCancel,
414+
onConfirm,
415+
}: {
416+
warning: OverlapWarning | null
417+
onCancel: () => void
418+
onConfirm: () => void
419+
}) {
420+
if (!warning) return null
421+
return (
422+
<div className="app-dialog show" onClick={onCancel}>
423+
<div className="app-dialog-modal" onClick={e => e.stopPropagation()}>
424+
<div className="app-dialog-header">
425+
<h3>Duplicate Calendar Target</h3>
426+
</div>
427+
<div className="app-dialog-body">
428+
<p>Another destination already syncs to this CalDAV calendar:</p>
429+
{warning.overlapping.map(d => (
430+
<div
431+
key={d.id}
432+
style={{
433+
marginBottom: 8,
434+
padding: '8px 12px',
435+
background: 'var(--card-bg)',
436+
borderRadius: 4,
437+
border: '1px solid var(--card-border)',
438+
}}
439+
>
440+
<strong>{d.name}</strong>
441+
<br />
442+
<span style={{ fontSize: 13, opacity: 0.7 }}>{d.ics_url}</span>
443+
<br />
444+
<span style={{ fontSize: 12, opacity: 0.6 }}>
445+
sync_all: {d.sync_all ? 'on' : 'off'}, keep_local: {d.keep_local ? 'on' : 'off'}
446+
</span>
447+
</div>
448+
))}
449+
<p style={{ marginTop: 12, fontSize: 13 }}>
450+
Multiple destinations targeting the same calendar operate independently. If keep_local
451+
is disabled on any destination, it may delete events uploaded by another.
452+
</p>
453+
<div className="dialog-actions">
454+
<button className="app-btn app-btn-subtle" onClick={onCancel}>
455+
Cancel
456+
</button>
457+
<button className="app-btn app-btn-primary" onClick={onConfirm}>
458+
Continue Anyway
459+
</button>
460+
</div>
461+
</div>
462+
</div>
463+
</div>
464+
)
465+
}
466+
398467
// --- Component ---
399468

400469
export default function Home() {
@@ -431,6 +500,9 @@ export default function Home() {
431500
// Delete confirmation
432501
const [deletePrompt, setDeletePrompt] = useState<DeletePrompt | null>(null)
433502

503+
// Overlap warning
504+
const [overlapWarning, setOverlapWarning] = useState<OverlapWarning | null>(null)
505+
434506
// ── Data fetching ──────────────────────────────────────────────
435507

436508
const fetchSources = useCallback(async () => {
@@ -606,17 +678,39 @@ export default function Home() {
606678

607679
async function submitDest(e: React.FormEvent) {
608680
e.preventDefault()
609-
const url = editingDest ? `/api/destinations/${editingDest.id}` : '/api/destinations'
610-
const method = editingDest ? 'PUT' : 'POST'
611681
const { sync_interval_hours, sync_interval_minutes, sync_interval_seconds, ...rest } = destForm
612682
const body = {
613683
...rest,
614684
sync_interval_secs: toSecs(sync_interval_hours, sync_interval_minutes, sync_interval_seconds),
615685
}
616-
await apiSubmit(url, method, body, () => {
617-
closeDestDialog()
618-
fetchDestinations()
619-
})
686+
687+
const doSubmit = async () => {
688+
const url = editingDest ? `/api/destinations/${editingDest.id}` : '/api/destinations'
689+
const method = editingDest ? 'PUT' : 'POST'
690+
await apiSubmit(url, method, body, () => {
691+
closeDestDialog()
692+
fetchDestinations()
693+
})
694+
setOverlapWarning(null)
695+
}
696+
697+
try {
698+
const params = new URLSearchParams({
699+
caldav_url: destForm.caldav_url,
700+
calendar_name: destForm.calendar_name,
701+
})
702+
if (editingDest) params.set('exclude_id', String(editingDest.id))
703+
const res = await api.get(`/api/destinations/check-overlap?${params}`)
704+
const data = res.data as { overlapping: OverlapEntry[] }
705+
if (data.overlapping?.length) {
706+
setOverlapWarning({ overlapping: data.overlapping, pendingSubmit: doSubmit })
707+
return
708+
}
709+
} catch {
710+
// If overlap check fails, proceed without warning
711+
}
712+
713+
await doSubmit()
620714
}
621715

622716
// ── Delete handler ─────────────────────────────────────────────
@@ -1088,6 +1182,11 @@ export default function Home() {
10881182
onCancel={() => setDeletePrompt(null)}
10891183
onConfirm={handleDeleteConfirm}
10901184
/>
1185+
<OverlapWarningDialog
1186+
warning={overlapWarning}
1187+
onCancel={() => setOverlapWarning(null)}
1188+
onConfirm={() => overlapWarning?.pendingSubmit()}
1189+
/>
10911190
</div>
10921191
)
10931192
}

src/api/destinations.rs

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use axum::{
55
response::IntoResponse,
66
routing::{delete, get, post, put},
77
};
8-
use serde::Serialize;
8+
use serde::{Deserialize, Serialize};
99
use utoipa::ToSchema;
1010

1111
use super::AppState;
@@ -31,13 +31,15 @@ pub struct ReverseSyncResult {
3131
message: String,
3232
uploaded: usize,
3333
skipped: usize,
34+
deleted: usize,
3435
total: usize,
3536
}
3637

3738
pub fn routes() -> Router<AppState> {
3839
Router::new()
3940
.route("/destinations", get(list_destinations))
4041
.route("/destinations", post(create_destination))
42+
.route("/destinations/check-overlap", get(check_overlap))
4143
.route("/destinations/{id}", put(update_destination))
4244
.route("/destinations/{id}", delete(delete_destination))
4345
.route("/destinations/{id}/sync", post(sync_destination))
@@ -224,6 +226,7 @@ pub async fn sync_destination(
224226
message: "Destination not found".into(),
225227
uploaded: 0,
226228
skipped: 0,
229+
deleted: 0,
227230
total: 0,
228231
}),
229232
)
@@ -237,6 +240,7 @@ pub async fn sync_destination(
237240
message: e.to_string(),
238241
uploaded: 0,
239242
skipped: 0,
243+
deleted: 0,
240244
total: 0,
241245
}),
242246
)
@@ -256,20 +260,21 @@ pub async fn sync_destination(
256260
)
257261
.await
258262
{
259-
Ok((uploaded, skipped, total)) => {
263+
Ok(stats) => {
260264
let db = state.db.lock().unwrap();
261265
let _ = db::update_destination_sync_status(&db, id, "ok", None);
262266
(
263267
StatusCode::OK,
264268
Json(ReverseSyncResult {
265269
status: "success".into(),
266270
message: format!(
267-
"Uploaded {} of {} events ({} unchanged, skipped)",
268-
uploaded, total, skipped
271+
"Uploaded {} of {} events ({} unchanged, {} deleted)",
272+
stats.uploaded, stats.total, stats.skipped, stats.deleted
269273
),
270-
uploaded,
271-
skipped,
272-
total,
274+
uploaded: stats.uploaded,
275+
skipped: stats.skipped,
276+
deleted: stats.deleted,
277+
total: stats.total,
273278
}),
274279
)
275280
.into_response()
@@ -285,10 +290,66 @@ pub async fn sync_destination(
285290
message: e.to_string(),
286291
uploaded: 0,
287292
skipped: 0,
293+
deleted: 0,
288294
total: 0,
289295
}),
290296
)
291297
.into_response()
292298
}
293299
}
294300
}
301+
302+
#[derive(Deserialize)]
303+
pub struct OverlapQuery {
304+
caldav_url: String,
305+
calendar_name: String,
306+
exclude_id: Option<i64>,
307+
}
308+
309+
#[derive(Serialize)]
310+
struct OverlapEntry {
311+
id: i64,
312+
name: String,
313+
ics_url: String,
314+
sync_all: bool,
315+
keep_local: bool,
316+
}
317+
318+
#[derive(Serialize)]
319+
struct OverlapResponse {
320+
overlapping: Vec<OverlapEntry>,
321+
}
322+
323+
pub async fn check_overlap(
324+
State(state): State<AppState>,
325+
axum::extract::Query(q): axum::extract::Query<OverlapQuery>,
326+
) -> impl IntoResponse {
327+
let db = state.db.lock().unwrap();
328+
match db::find_overlapping_destinations(&db, &q.caldav_url, &q.calendar_name, q.exclude_id) {
329+
Ok(dests) => (
330+
StatusCode::OK,
331+
Json(OverlapResponse {
332+
overlapping: dests
333+
.into_iter()
334+
.map(|d| OverlapEntry {
335+
id: d.id,
336+
name: d.name,
337+
ics_url: d.ics_url,
338+
sync_all: d.sync_all,
339+
keep_local: d.keep_local,
340+
})
341+
.collect(),
342+
}),
343+
)
344+
.into_response(),
345+
Err(e) => (
346+
StatusCode::INTERNAL_SERVER_ERROR,
347+
Json(DestinationResponse {
348+
status: "error".into(),
349+
message: e.to_string(),
350+
destination: None,
351+
}),
352+
)
353+
.into_response(),
354+
}
355+
}

0 commit comments

Comments
 (0)