diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8238e46..c25ed27 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -33,7 +33,8 @@ "Bash(export:*)", "Bash(curl:*)", "Bash(rm -f 'C:\\\\work\\\\chrka\\\\myNote\\\\work\\\\_tmp_test_silent.ps1' && cd /c/work/chrka/myNote && git add ka-note/scripts/get-token.ps1 && git commit -m \"fix: persistent token cache in get-token.ps1\n\n- Fast path: reads from ~/.ka-note/token.txt if not expired \\(no MSAL needed\\)\n- On expiry: MSAL silent refresh with login hint\n- Fallback: interactive browser login \\(~once per 90 days\\)\n- Auto-saves every acquired token back to cache file\n\nCo-Authored-By: Claude Sonnet 4.6 \" 2>&1)", - "Bash(cd /c/work/chrka/myNote && git rm -r --cached work/ 2>&1)" + "Bash(cd /c/work/chrka/myNote && git rm -r --cached work/ 2>&1)", + "Bash(sqlite3:*)" ] } } diff --git a/ka-note/VERSION b/ka-note/VERSION index e324560..470dcd8 100644 --- a/ka-note/VERSION +++ b/ka-note/VERSION @@ -1 +1 @@ -1.0.34 \ No newline at end of file +1.0.38 \ No newline at end of file diff --git a/ka-note/client/src/lib/sync/syncService.ts b/ka-note/client/src/lib/sync/syncService.ts index 4301473..5081fa6 100644 --- a/ka-note/client/src/lib/sync/syncService.ts +++ b/ka-note/client/src/lib/sync/syncService.ts @@ -132,6 +132,30 @@ async function pushAll(): Promise { if (!res.ok) throw new Error(`Push failed: ${res.status}`); } +export interface TableStatus { + total: number; + active: number; + deleted: number; + lastUpdated: string | null; +} + +export interface SyncStatusResult { + serverTimestamp: string; + userId: string; + tables: { + contexts: TableStatus; + topics: TableStatus; + historyEntries: TableStatus; + ratings: TableStatus; + }; +} + +export async function fetchSyncStatus(): Promise { + const res = await apiFetch('/api/sync/status', { method: 'GET' }); + if (!res.ok) throw new Error(`Status fetch failed: ${res.status}`); + return res.json(); +} + export async function fullSync(): Promise { await doSync(null); } @@ -146,7 +170,8 @@ async function doSync(since: Date | null): Promise { syncStatus.set('syncing'); try { await pushAll(); - const serverTimestamp = await pullAndMerge(since); + const sinceWithBuffer = since ? new Date(since.getTime() - 60_000) : null; + const serverTimestamp = await pullAndMerge(sinceWithBuffer); const syncTime = new Date(serverTimestamp); lastSyncAt.set(syncTime); localStorage.setItem('lastSyncAt', syncTime.toISOString()); diff --git a/ka-note/client/src/routes/settings/+page.svelte b/ka-note/client/src/routes/settings/+page.svelte index c95816d..0cda819 100644 --- a/ka-note/client/src/routes/settings/+page.svelte +++ b/ka-note/client/src/routes/settings/+page.svelte @@ -1,3 +1,145 @@ -

Einstellungen

+ + +
+

Einstellungen

+ + +
+

Lokale Datenbank

+ + {#if $counts$} + {@const [ctxCount, topicCount, histCount, ratingCount] = $counts$} +
+
Contexts
{ctxCount}
+
Topics
{topicCount}
+
History
{histCount}
+
Ratings
{ratingCount}
+
Letzter Sync
{lastSync ? formatDate(lastSync.toISOString()) : '—'}
+
+ {:else} +

Lade...

+ {/if} + + {#if !confirmReset} + + {:else} +
+

Lokale DB und Sync-State loeschen und neu laden?

+
+ + +
+
+ {/if} +
+ + +
+
+

Server Status

+ +
+ + {#if serverError} +

{serverError}

+ {:else if serverStatus} +
+
User ID
+
{serverStatus.userId}
+
Server Time
+
{formatDate(serverStatus.serverTimestamp)}
+
+ + + + + + + + + + + + + {#each Object.entries(serverStatus.tables) as [name, s]} + + + + + + + + {/each} + +
TabelleGesamtAktivGeloeschtZuletzt
{name}{s.total}{s.active}{s.deleted}{s.lastUpdated ? s.lastUpdated.slice(0, 10) : '—'}
+ {:else if !serverLoading} +

Nicht geladen.

+ {/if} +
+
diff --git a/ka-note/client/src/routes/trash/+page.svelte b/ka-note/client/src/routes/trash/+page.svelte index 40280eb..9c6265f 100644 --- a/ka-note/client/src/routes/trash/+page.svelte +++ b/ka-note/client/src/routes/trash/+page.svelte @@ -86,10 +86,14 @@ if (!entities.length) return; deleteError = null; try { + // Push soft-deletes to server before removing from local DB. + // Without this, items removed locally before the next auto-sync + // would never be pushed as soft-deleted, so other clients would + // never learn of the deletion. + await sync(); await hardDeleteEntities(entities); selected = new Set(); confirming = false; - sync(); } catch (e) { deleteError = e instanceof Error ? e.message : String(e); confirming = false; diff --git a/ka-note/server/ka-note.db-shm b/ka-note/server/ka-note.db-shm deleted file mode 100644 index 4b7c8ec..0000000 Binary files a/ka-note/server/ka-note.db-shm and /dev/null differ diff --git a/ka-note/server/ka-note.db-wal b/ka-note/server/ka-note.db-wal deleted file mode 100644 index c4d4ae2..0000000 Binary files a/ka-note/server/ka-note.db-wal and /dev/null differ diff --git a/ka-note/server/src/lib/sync-service.ts b/ka-note/server/src/lib/sync-service.ts index 0383730..50086b2 100644 --- a/ka-note/server/src/lib/sync-service.ts +++ b/ka-note/server/src/lib/sync-service.ts @@ -248,6 +248,60 @@ export async function pullChanges(request: SyncPullRequest, userId: string): Pro }; } +export interface TableStatus { + total: number; + active: number; + deleted: number; + lastUpdated: string | null; +} + +export interface SyncStatusResult { + serverTimestamp: string; + userId: string; + tables: { + contexts: TableStatus; + topics: TableStatus; + historyEntries: TableStatus; + ratings: TableStatus; + }; +} + +async function tableStatus(table: TableDef, userId: string): Promise { + const rows = await db.select({ + total: sql`COUNT(*)`, + deleted: sql`SUM(CASE WHEN ${table.deletedAt} IS NOT NULL THEN 1 ELSE 0 END)`, + lastUpdated: sql`MAX(${table.updatedAt})`, + }).from(table).where(sql`${table.userId} = ${userId}`).get(); + + const total = rows?.total ?? 0; + const deleted = rows?.deleted ?? 0; + return { + total, + active: total - deleted, + deleted, + lastUpdated: rows?.lastUpdated ?? null, + }; +} + +export async function getStatus(userId: string): Promise { + const [ctxStatus, topicStatus, heStatus, ratingStatus] = await Promise.all([ + tableStatus(contexts, userId), + tableStatus(topics, userId), + tableStatus(historyEntries, userId), + tableStatus(ratings, userId), + ]); + return { + serverTimestamp: now(), + userId, + tables: { + contexts: ctxStatus, + topics: topicStatus, + historyEntries: heStatus, + ratings: ratingStatus, + }, + }; +} + export async function ensureDailyLog(userId: string): Promise { const existing = await db.select().from(contexts) .where(and(sql`${contexts.id} = 'daily-log'`, eq(contexts.userId, userId))) diff --git a/ka-note/server/src/routes/sync.ts b/ka-note/server/src/routes/sync.ts index 862369f..6fdc31e 100644 --- a/ka-note/server/src/routes/sync.ts +++ b/ka-note/server/src/routes/sync.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { pushChanges, pullChanges, ensureDailyLog } from '../lib/sync-service.js'; +import { pushChanges, pullChanges, ensureDailyLog, getStatus } from '../lib/sync-service.js'; import { isLocked } from '../lib/ai-export-service.js'; import type { SyncPushRequest, SyncPullRequest } from '@ka-note/shared'; import type { AuthEnv } from '../middleware/auth.js'; @@ -24,4 +24,10 @@ sync.get('/pull', async (c) => { return c.json(result); }); +sync.get('/status', async (c) => { + const { userId } = c.get('auth'); + const result = await getStatus(userId); + return c.json(result); +}); + export default sync; diff --git a/ka-note/server/src/routes/trash.ts b/ka-note/server/src/routes/trash.ts index fbf47bd..d09d9c5 100644 --- a/ka-note/server/src/routes/trash.ts +++ b/ka-note/server/src/routes/trash.ts @@ -1,8 +1,9 @@ import { Hono } from 'hono'; import { db } from '../db/connection.js'; import { contexts, topics, historyEntries, ratings } from '../db/schema.js'; -import { and, eq, inArray } from 'drizzle-orm'; +import { and, eq, inArray, isNull } from 'drizzle-orm'; import type { AuthEnv } from '../middleware/auth.js'; +import { sql } from 'drizzle-orm'; const trash = new Hono(); @@ -11,7 +12,7 @@ interface EntityRef { id: string; } -/** Resolves all dependent IDs that must be deleted alongside the given entities. */ +/** Resolves all dependent IDs that must be soft-deleted alongside the given entities. */ async function resolveCascade(entities: EntityRef[], userId: string) { const contextIds = new Set(entities.filter(e => e.type === 'context').map(e => e.id)); const topicIds = new Set(entities.filter(e => e.type === 'topic').map(e => e.id)); @@ -48,6 +49,9 @@ async function resolveCascade(entities: EntityRef[], userId: string) { return { contextIds, topicIds, historyIds, ratingIds }; } +// Ensure rows are soft-deleted on server (tombstone for sync). +// Hard-deleting would break incremental pull on other clients — deleted rows +// would simply vanish with no tombstone, so other clients never learn of the deletion. trash.delete('/', async (c) => { const { userId } = c.get('auth'); const body = await c.req.json<{ entities: EntityRef[] }>(); @@ -58,19 +62,24 @@ trash.delete('/', async (c) => { } const { contextIds, topicIds, historyIds, ratingIds } = await resolveCascade(entities, userId); + const deletedAt = new Date().toISOString(); - // Delete in FK-safe order: ratings → history → topics → contexts + // Soft-delete any rows that don't already have deletedAt set if (ratingIds.size > 0) { - await db.delete(ratings).where(and(eq(ratings.userId, userId), inArray(ratings.id, [...ratingIds]))); + await db.update(ratings).set({ deletedAt, updatedAt: deletedAt, version: sql`${ratings.version} + 1` }) + .where(and(eq(ratings.userId, userId), inArray(ratings.id, [...ratingIds]), isNull(ratings.deletedAt))); } if (historyIds.size > 0) { - await db.delete(historyEntries).where(and(eq(historyEntries.userId, userId), inArray(historyEntries.id, [...historyIds]))); + await db.update(historyEntries).set({ deletedAt, updatedAt: deletedAt, version: sql`${historyEntries.version} + 1` }) + .where(and(eq(historyEntries.userId, userId), inArray(historyEntries.id, [...historyIds]), isNull(historyEntries.deletedAt))); } if (topicIds.size > 0) { - await db.delete(topics).where(and(eq(topics.userId, userId), inArray(topics.id, [...topicIds]))); + await db.update(topics).set({ deletedAt, updatedAt: deletedAt, version: sql`${topics.version} + 1` }) + .where(and(eq(topics.userId, userId), inArray(topics.id, [...topicIds]), isNull(topics.deletedAt))); } if (contextIds.size > 0) { - await db.delete(contexts).where(and(eq(contexts.userId, userId), inArray(contexts.id, [...contextIds]))); + await db.update(contexts).set({ deletedAt, updatedAt: deletedAt, version: sql`${contexts.version} + 1` }) + .where(and(eq(contexts.userId, userId), inArray(contexts.id, [...contextIds]), isNull(contexts.deletedAt))); } const total = ratingIds.size + historyIds.size + topicIds.size + contextIds.size; diff --git a/vorlagen/3.png b/vorlagen/3.png new file mode 100644 index 0000000..e8babfc Binary files /dev/null and b/vorlagen/3.png differ diff --git a/vorlagen/test1.png b/vorlagen/test1.png new file mode 100644 index 0000000..7d148d6 Binary files /dev/null and b/vorlagen/test1.png differ