import { get } from 'svelte/store'; import { db } from './schema'; import { newId, now, today } from './helpers'; import { getAccessToken } from '$lib/auth/authStore'; import { normalizeTitleAndBody } from '$lib/utils/titleUtils'; import { scopeSettings } from '$lib/stores/scopeContext'; import type { AgendaContext, Topic, HistoryEntry, Rating, ContextType, TopicStatus, ProjectMeta, PersonMeta, CompanyMeta, Page, Notebook, PageNotebook } from '@ka-note/shared'; // --- Contexts --- export async function getAllContexts(): Promise { return db.contexts.where('deletedAt').equals('').or('deletedAt').equals(null as any).sortBy('sortOrder'); } export async function getContextsByType(type: ContextType): Promise { const all = await getAllContexts(); return all.filter(c => c.type === type); } export async function getContext(id: string): Promise { return db.contexts.get(id); } export async function upsertContext(ctx: Partial & { id: string }): Promise { const existing = await db.contexts.get(ctx.id); if (existing) { await db.contexts.update(ctx.id, { ...ctx, updatedAt: now(), version: existing.version + 1 }); } else { await db.contexts.put({ name: '', type: 'meeting', sortOrder: 0, meta: null, archivedAt: null, isFavorite: false, deletedAt: null, updatedAt: now(), version: 1, ...ctx }); } } export async function softDeleteContext(id: string): Promise { const ctx = await db.contexts.get(id); if (ctx) { await db.contexts.update(id, { deletedAt: now(), updatedAt: now(), version: ctx.version + 1 }); } } export async function archiveContext(id: string): Promise { const ctx = await db.contexts.get(id); if (ctx) { await db.contexts.update(id, { archivedAt: now(), updatedAt: now(), version: ctx.version + 1 }); } } export async function unarchiveContext(id: string): Promise { const ctx = await db.contexts.get(id); if (ctx) { await db.contexts.update(id, { archivedAt: null, updatedAt: now(), version: ctx.version + 1 }); } } export async function toggleFavorite(id: string): Promise { const ctx = await db.contexts.get(id); if (ctx) { await db.contexts.update(id, { isFavorite: !ctx.isFavorite, updatedAt: now(), version: ctx.version + 1 }); } } export async function reorderContext(id: string, direction: 'up' | 'down'): Promise { const ctx = await db.contexts.get(id); if (!ctx) return; const siblings = await db.contexts .filter(c => !c.deletedAt && c.type === ctx.type) .sortBy('sortOrder'); const idx = siblings.findIndex(c => c.id === id); const swapIdx = direction === 'up' ? idx - 1 : idx + 1; if (swapIdx < 0 || swapIdx >= siblings.length) return; // Swap positions in the array, then write clean sequential sortOrder values [siblings[idx], siblings[swapIdx]] = [siblings[swapIdx], siblings[idx]]; const ts = now(); for (let i = 0; i < siblings.length; i++) { const s = siblings[i]; await db.contexts.update(s.id, { sortOrder: i, updatedAt: ts, version: s.version + 1 }); } } export async function findContextByMentionName(name: string, type: 'person' | 'project' | 'company'): Promise { const q = name.toLowerCase(); return db.contexts .filter(c => !c.deletedAt && c.type === type && c.name.replace(/^(Person|Project|Firma)\s+/, '').toLowerCase() === q) .first(); } // --- Topics --- export async function getTopicsByContext(contextId: string): Promise { return db.topics .where('contextId').equals(contextId) .filter(t => !t.deletedAt) .sortBy('sortOrder'); } export async function getTopic(id: string): Promise { return db.topics.get(id); } export async function createTopic(contextId: string, rawTitle: string, isPrivate = false): Promise { const existing = await getTopicsByContext(contextId); const { maxTitleLength } = get(scopeSettings); const { title, body } = normalizeTitleAndBody(rawTitle, '', maxTitleLength); const topic: Topic = { id: newId(), contextId, title, status: 'active', snoozeUntil: null, sortOrder: 0, isNew: true, updatedAt: now(), deletedAt: null, version: 1 }; // Push existing topics down for (const t of existing) { await db.topics.update(t.id, { sortOrder: t.sortOrder + 1 }); } await db.topics.put(topic); if (body) { await createHistoryEntry(topic.id, today(), body, null, false, isPrivate); } return topic; } export async function updateTopic(id: string, changes: Partial): Promise { const topic = await db.topics.get(id); if (topic) { await db.topics.update(id, { ...changes, updatedAt: now(), version: topic.version + 1 }); } } export async function softDeleteTopic(id: string): Promise { const topic = await db.topics.get(id); if (topic) { await db.topics.update(id, { deletedAt: now(), status: 'done' as TopicStatus, updatedAt: now(), version: topic.version + 1 }); } } export async function archiveTopic(id: string): Promise { const topic = await db.topics.get(id); if (topic) { await db.topics.update(id, { status: 'archived' as TopicStatus, updatedAt: now(), version: topic.version + 1 }); } } export async function updateTopicSortOrder(contextId: string, orderedIds: string[]): Promise { await db.transaction('rw', db.topics, async () => { for (let i = 0; i < orderedIds.length; i++) { await db.topics.update(orderedIds[i], { sortOrder: i, updatedAt: now() }); } }); } // --- Journal (daily-log) --- const JOURNAL_TOPIC_ID = 'daily-log-journal'; export async function getOrCreateJournalTopic(): Promise { const existing = await db.topics.get(JOURNAL_TOPIC_ID); if (existing && !existing.deletedAt) return existing; const topic: Topic = { id: JOURNAL_TOPIC_ID, contextId: 'daily-log', title: '__journal__', status: 'active', snoozeUntil: null, sortOrder: -1, isNew: false, updatedAt: now(), deletedAt: null, version: 1 }; await db.topics.put(topic); return topic; } export { JOURNAL_TOPIC_ID }; // --- Notes (project/person) --- export function notesTopicId(contextId: string): string { return `${contextId}-notes`; } export async function getOrCreateNotesTopic(contextId: string): Promise { const id = notesTopicId(contextId); const existing = await db.topics.get(id); if (existing && !existing.deletedAt) return existing; const topic: Topic = { id, contextId, title: '__notes__', status: 'active', snoozeUntil: null, sortOrder: -1, isNew: false, updatedAt: now(), deletedAt: null, version: 1 }; await db.topics.put(topic); return topic; } // --- History Entries --- export async function getHistoryByTopic(topicId: string): Promise { return db.historyEntries .where('topicId').equals(topicId) .filter(h => !h.deletedAt) .sortBy('sortOrder'); } export async function getAllHistoryByContext(contextId: string): Promise<(HistoryEntry & { topicTitle: string })[]> { const topics = await getTopicsByContext(contextId); const topicMap = new Map(topics.map(t => [t.id, t])); const allHistory: (HistoryEntry & { topicTitle: string })[] = []; for (const topic of topics) { const entries = await getHistoryByTopic(topic.id); for (const entry of entries) { allHistory.push({ ...entry, topicTitle: topic.title }); } } return allHistory; } export async function createHistoryEntry(topicId: string, date: string, text: string, linkedContextId: string | null = null, wiedervorlage = false, isPrivate = false): Promise { const existing = await getHistoryByTopic(topicId); const autoWiedervorlage = date > today() || wiedervorlage; const entry: HistoryEntry = { id: newId(), topicId, date, text, sortOrder: existing.length, linkedContextId, doneAt: null, wiedervorlageDate: autoWiedervorlage ? date : null, wiedervorlageResolvedAt: null, updatedAt: now(), deletedAt: null, version: 1, ...(isPrivate ? { isPrivate: true } : {}) }; await db.historyEntries.put(entry); return entry; } export async function setWiedervorlage(id: string, date: string): Promise { const entry = await db.historyEntries.get(id); if (entry) { await db.historyEntries.update(id, { wiedervorlageDate: date, wiedervorlageResolvedAt: null, updatedAt: now(), version: entry.version + 1 }); } } export async function resolveWiedervorlage(id: string): Promise { const entry = await db.historyEntries.get(id); if (entry) { await db.historyEntries.update(id, { wiedervorlageResolvedAt: now(), updatedAt: now(), version: entry.version + 1 }); } } export async function convertToTopic(entryId: string, contextId: string): Promise { const entry = await db.historyEntries.get(entryId); if (!entry) throw new Error('Entry not found'); const lines = entry.text.split('\n'); const title = lines[0].trim(); const topic = await createTopic(contextId, title); await resolveWiedervorlage(entryId); return topic; } export async function updateHistoryEntry(id: string, text: string, isPrivate?: boolean, date?: string): Promise { const entry = await db.historyEntries.get(id); if (entry) { const patch: Record = { text, updatedAt: now(), version: entry.version + 1 }; if (isPrivate !== undefined) patch.isPrivate = isPrivate; if (date !== undefined) patch.date = date; await db.historyEntries.update(id, patch); } } export async function softDeleteHistoryEntry(id: string): Promise { const entry = await db.historyEntries.get(id); if (entry) { await db.historyEntries.update(id, { deletedAt: now(), updatedAt: now(), version: entry.version + 1 }); } } export async function toggleHistoryEntryDone(id: string): Promise { const entry = await db.historyEntries.get(id); if (entry) { const doneAt = entry.doneAt ? null : now(); await db.historyEntries.update(id, { doneAt, updatedAt: now(), version: entry.version + 1 }); } } // --- Ratings --- export async function getRating(topicId: string, historyEntryId: string, personName: string): Promise { return db.ratings .where('historyEntryId').equals(historyEntryId) .filter(r => r.topicId === topicId && r.personName === personName && !r.deletedAt) .first(); } export async function getRatingsByHistoryEntry(historyEntryId: string): Promise { return db.ratings .where('historyEntryId').equals(historyEntryId) .filter(r => !r.deletedAt) .toArray(); } export async function upsertRating(topicId: string, historyEntryId: string, personName: string, value: 1 | 2 | 3 | 4, comment: string | null = null): Promise { const existing = await db.ratings .where('historyEntryId').equals(historyEntryId) .filter(r => r.topicId === topicId && r.personName === personName && !r.deletedAt) .first(); if (existing) { await db.ratings.update(existing.id, { value, comment, updatedAt: now(), version: existing.version + 1 }); } else { await db.ratings.put({ id: newId(), topicId, historyEntryId, personName, value, comment, updatedAt: now(), deletedAt: null, version: 1 }); } } export async function getRatingsByPerson(personName: string): Promise { return db.ratings .where('personName').equals(personName) .filter(r => !r.deletedAt) .toArray(); } export async function softDeleteRating(id: string): Promise { const rating = await db.ratings.get(id); if (rating) { await db.ratings.update(id, { deletedAt: now(), updatedAt: now(), version: rating.version + 1 }); } } // --- Wiki Pages --- export async function getPage(id: string): Promise { return db.pages.get(id); } export async function getAllPages(): Promise { return db.pages.filter(p => !p.deletedAt).sortBy('sortOrder'); } export async function createPage(title: string, isPrivate = false): Promise { const all = await db.pages.toArray(); const page: Page = { id: newId(), title, body: '', isPrivate, sortOrder: all.length, updatedAt: now(), deletedAt: null, purgedAt: null, version: 1, }; await db.pages.put(page); return page; } export async function upsertPage(page: Partial & { id: string }): Promise { const existing = await db.pages.get(page.id); if (existing) { const oldTitle = existing.title; await db.pages.update(page.id, { ...page, updatedAt: now(), version: existing.version + 1 }); if (page.title && page.title !== oldTitle) { await renameTitleInLinks(oldTitle, page.title); } } else { await db.pages.put({ title: '', body: '', isPrivate: false, sortOrder: 0, deletedAt: null, purgedAt: null, updatedAt: now(), version: 1, ...page, }); } } export async function togglePageFavorite(id: string): Promise { const p = await db.pages.get(id); if (p) { await db.pages.update(id, { isFavorite: !p.isFavorite, updatedAt: now(), version: p.version + 1 }); } } export async function softDeletePage(id: string): Promise { const page = await db.pages.get(id); if (page) { await db.pages.update(id, { deletedAt: now(), updatedAt: now(), version: page.version + 1 }); // soft-delete all join entries const joins = await db.pageNotebooks.where('pageId').equals(id).filter(pn => !pn.deletedAt).toArray(); for (const pn of joins) { await db.pageNotebooks.update(pn.id, { deletedAt: now(), updatedAt: now(), version: pn.version + 1 }); } } } async function renameTitleInLinks(oldTitle: string, newTitle: string): Promise { const pattern = `[[${oldTitle}]]`; const replacement = `[[${newTitle}]]`; const pages = await db.pages.filter(p => !p.deletedAt && p.body.includes(pattern)).toArray(); const ts = now(); for (const p of pages) { await db.pages.update(p.id, { body: p.body.split(pattern).join(replacement), updatedAt: ts, version: p.version + 1, }); } } export async function getBacklinksForPage(pageId: string): Promise { const page = await db.pages.get(pageId); if (!page) return []; const pattern = `[[${page.title}]]`; return db.pages.filter(p => !p.deletedAt && p.id !== pageId && p.body.includes(pattern)).toArray(); } // --- Notebooks --- export async function getNotebook(id: string): Promise { return db.notebooks.get(id); } export async function getAllNotebooks(): Promise { return db.notebooks.filter(n => !n.deletedAt).sortBy('sortOrder'); } export async function upsertNotebook(notebook: Partial & { id: string }): Promise { const existing = await db.notebooks.get(notebook.id); if (existing) { await db.notebooks.update(notebook.id, { ...notebook, updatedAt: now(), version: existing.version + 1 }); } else { await db.notebooks.put({ name: '', contextId: null, isPrivate: false, isFavorite: false, sortOrder: 0, deletedAt: null, purgedAt: null, updatedAt: now(), version: 1, ...notebook, }); } } export async function createNotebook(name: string, contextId: string | null = null, isPrivate = false): Promise { const all = await db.notebooks.toArray(); const notebook: Notebook = { id: newId(), name, contextId, isPrivate, isFavorite: false, sortOrder: all.length, updatedAt: now(), deletedAt: null, purgedAt: null, version: 1, }; await db.notebooks.put(notebook); return notebook; } export async function toggleNotebookFavorite(id: string): Promise { const nb = await db.notebooks.get(id); if (nb) { await db.notebooks.update(id, { isFavorite: !nb.isFavorite, updatedAt: now(), version: nb.version + 1 }); } } export async function reorderNotebook(id: string, direction: 'up' | 'down'): Promise { const nb = await db.notebooks.get(id); if (!nb) return; const siblings = await db.notebooks .filter(n => !n.deletedAt) .sortBy('sortOrder'); const idx = siblings.findIndex(n => n.id === id); const swapIdx = direction === 'up' ? idx - 1 : idx + 1; if (swapIdx < 0 || swapIdx >= siblings.length) return; [siblings[idx], siblings[swapIdx]] = [siblings[swapIdx], siblings[idx]]; const ts = now(); for (let i = 0; i < siblings.length; i++) { const s = siblings[i]; await db.notebooks.update(s.id, { sortOrder: i, updatedAt: ts, version: s.version + 1 }); } } export async function softDeleteNotebook(id: string): Promise { const nb = await db.notebooks.get(id); if (nb) { await db.notebooks.update(id, { deletedAt: now(), updatedAt: now(), version: nb.version + 1 }); } } // --- PageNotebook join --- export async function assignPageToNotebook(pageId: string, notebookId: string): Promise { const existing = await db.pageNotebooks .filter(pn => pn.pageId === pageId && pn.notebookId === notebookId && !pn.deletedAt) .first(); if (existing) return; const joins = await db.pageNotebooks.where('notebookId').equals(notebookId).filter(pn => !pn.deletedAt).toArray(); const pn: PageNotebook = { id: newId(), pageId, notebookId, sortOrder: joins.length, updatedAt: now(), deletedAt: null, purgedAt: null, version: 1, }; await db.pageNotebooks.put(pn); } export async function removePageFromNotebook(pageId: string, notebookId: string): Promise { const existing = await db.pageNotebooks .filter(pn => pn.pageId === pageId && pn.notebookId === notebookId && !pn.deletedAt) .first(); if (existing) { await db.pageNotebooks.update(existing.id, { deletedAt: now(), updatedAt: now(), version: existing.version + 1 }); } } export async function getNotebooksForPage(pageId: string): Promise { const joins = await db.pageNotebooks.where('pageId').equals(pageId).filter(pn => !pn.deletedAt).toArray(); const ids = joins.map(pn => pn.notebookId); if (ids.length === 0) return []; const nbs = await db.notebooks.bulkGet(ids); return nbs.filter((n): n is Notebook => !!n && !n.deletedAt); } export async function getPagesForNotebook(notebookId: string): Promise { const joins = await db.pageNotebooks .where('notebookId').equals(notebookId) .filter(pn => !pn.deletedAt) .sortBy('sortOrder'); const ids = joins.map(pn => pn.pageId); if (ids.length === 0) return []; const ps = await db.pages.bulkGet(ids); return ps.filter((p): p is Page => !!p && !p.deletedAt); } export async function getUnassignedPages(): Promise { const allPages = await getAllPages(); const result: Page[] = []; for (const p of allPages) { const joined = await db.pageNotebooks .where('pageId').equals(p.id) .filter(pn => !pn.deletedAt) .count(); if (joined === 0) result.push(p); } return result; } export async function updatePageNotebookSortOrder(notebookId: string, orderedPageIds: string[]): Promise { await db.transaction('rw', db.pageNotebooks, async () => { for (let i = 0; i < orderedPageIds.length; i++) { const join = await db.pageNotebooks .filter(pn => pn.pageId === orderedPageIds[i] && pn.notebookId === notebookId && !pn.deletedAt) .first(); if (join) { await db.pageNotebooks.update(join.id, { sortOrder: i, updatedAt: now(), version: join.version + 1 }); } } }); } // --- Trash --- export type TrashEntityType = 'context' | 'topic' | 'history' | 'rating'; export interface TrashEntityRef { type: TrashEntityType; id: string; } export async function restoreEntities(entities: TrashEntityRef[]): Promise { const timestamp = now(); for (const { type, id } of entities) { if (type === 'context') { const item = await db.contexts.get(id); if (item) await db.contexts.update(id, { deletedAt: null, updatedAt: timestamp, version: item.version + 1 }); } else if (type === 'topic') { const item = await db.topics.get(id); if (item) await db.topics.update(id, { deletedAt: null, updatedAt: timestamp, version: item.version + 1 }); } else if (type === 'history') { const item = await db.historyEntries.get(id); if (item) await db.historyEntries.update(id, { deletedAt: null, updatedAt: timestamp, version: item.version + 1 }); } else if (type === 'rating') { const item = await db.ratings.get(id); if (item) await db.ratings.update(id, { deletedAt: null, updatedAt: timestamp, version: item.version + 1 }); } } } async function resolveLocalCascade(entities: TrashEntityRef[]) { 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)); const historyIds = new Set(entities.filter(e => e.type === 'history').map(e => e.id)); const ratingIds = new Set(entities.filter(e => e.type === 'rating').map(e => e.id)); if (contextIds.size > 0) { const childTopics = await db.topics.where('contextId').anyOf([...contextIds]).toArray(); for (const t of childTopics) topicIds.add(t.id); } if (topicIds.size > 0) { const childHistory = await db.historyEntries.where('topicId').anyOf([...topicIds]).toArray(); for (const h of childHistory) historyIds.add(h.id); } if (historyIds.size > 0) { const childRatings = await db.ratings.where('historyEntryId').anyOf([...historyIds]).toArray(); for (const r of childRatings) ratingIds.add(r.id); } return { contextIds, topicIds, historyIds, ratingIds }; } export async function hardDeleteEntities(entities: TrashEntityRef[]): Promise { const token = await getAccessToken(); const res = await fetch('/api/trash', { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ entities }), }); if (!res.ok) { const body = await res.text().catch(() => ''); throw new Error(`Server error ${res.status}: ${body}`); } const { contextIds, topicIds, historyIds, ratingIds } = await resolveLocalCascade(entities); await db.ratings.bulkDelete([...ratingIds]); await db.historyEntries.bulkDelete([...historyIds]); await db.topics.bulkDelete([...topicIds]); await db.contexts.bulkDelete([...contextIds]); }