Ka-Note/ka-note/client/src/lib/db/repositories.ts

674 lines
21 KiB
TypeScript

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<AgendaContext[]> {
return db.contexts.where('deletedAt').equals('').or('deletedAt').equals(null as any).sortBy('sortOrder');
}
export async function getContextsByType(type: ContextType): Promise<AgendaContext[]> {
const all = await getAllContexts();
return all.filter(c => c.type === type);
}
export async function getContext(id: string): Promise<AgendaContext | undefined> {
return db.contexts.get(id);
}
export async function upsertContext(ctx: Partial<AgendaContext> & { id: string }): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<AgendaContext | undefined> {
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<Topic[]> {
return db.topics
.where('contextId').equals(contextId)
.filter(t => !t.deletedAt)
.sortBy('sortOrder');
}
export async function getTopic(id: string): Promise<Topic | undefined> {
return db.topics.get(id);
}
export async function createTopic(contextId: string, rawTitle: string, isPrivate = false): Promise<Topic> {
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<Topic>): Promise<void> {
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<void> {
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<void> {
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<void> {
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<Topic> {
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<Topic> {
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<HistoryEntry[]> {
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<HistoryEntry> {
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<void> {
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<void> {
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<Topic> {
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<void> {
const entry = await db.historyEntries.get(id);
if (entry) {
const patch: Record<string, unknown> = { 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<void> {
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<void> {
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<Rating | undefined> {
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<Rating[]> {
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<void> {
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<Rating[]> {
return db.ratings
.where('personName').equals(personName)
.filter(r => !r.deletedAt)
.toArray();
}
export async function softDeleteRating(id: string): Promise<void> {
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<Page | undefined> {
return db.pages.get(id);
}
export async function getAllPages(): Promise<Page[]> {
return db.pages.filter(p => !p.deletedAt).sortBy('sortOrder');
}
export async function createPage(title: string, isPrivate = false): Promise<Page> {
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<Page> & { id: string }): Promise<void> {
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<void> {
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<void> {
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<void> {
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<Page[]> {
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<Notebook | undefined> {
return db.notebooks.get(id);
}
export async function getAllNotebooks(): Promise<Notebook[]> {
return db.notebooks.filter(n => !n.deletedAt).sortBy('sortOrder');
}
export async function upsertNotebook(notebook: Partial<Notebook> & { id: string }): Promise<void> {
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<Notebook> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<Notebook[]> {
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<Page[]> {
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<Page[]> {
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<void> {
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<void> {
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<void> {
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]);
}