mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 01:29:40 +02:00
feat(crypto): phase 7.1 — encrypt timeBlocks-coupled tasks + calendar events
Flips three coordinated registry entries to enabled:true at once:
- tasks: title, description, subtasks, metadata
- events (calendar): title, description, location
- timeBlocks: title, description (NEW entry)
These three tables have to move together because the consumer modules
(todo, calendar) denormalize their title/description into a TimeBlock
for cheap calendar rendering. Encrypting only the source records would
still leak the same fields through the timeBlocks hub. Indexed columns
(startDate, endDate, kind, type, sourceModule/sourceId, parentBlockId,
recurrenceDate, isLive, isCompleted, dueDate, priority) all stay
plaintext — the calendar query layer needs them for range scans.
Service layer
-------------
- time-blocks/service.ts: createBlock + updateBlock now route through
encryptRecord before the Dexie write. startFromScheduled decrypts the
scheduled block first so the new logged block carries plaintext
forward instead of an already-encrypted blob (encryptRecord is
idempotent so this is also defence-in-depth). New decryptBlock helper
for callers that need plaintext outside a liveQuery.
- todo/stores/tasks.svelte.ts: createTask snapshots the plaintext task
before encryptRecord mutates it, returns the snapshot to the UI.
updateTask decrypts the existing row before forwarding task.title as
a fallback into updateBlock (would otherwise leak ciphertext to the
linked TimeBlock). updateLabels + updateSubtasks decrypt-merge-encrypt
so structured fields don't get spliced into a ciphertext blob.
- calendar/stores/events.svelte.ts: encryptRecord wrapped around all
four event-write paths (create, update, updateSingleInstance,
updateAllFuture).
Read paths
----------
Every liveQuery / one-shot read that surfaces title/description/
location through the UI now decrypts after the plaintext-metadata
filter:
- time-blocks/queries.ts: useAllTimeBlocks, timeBlocksInRange$,
timeBlocksBySource$, useLiveTimeBlock
- todo/queries.ts: useAllTasks
- calendar/queries.ts: useAllCalendarItems (decrypts both the blocks
and the joined events)
- cross-app-queries.ts: useOpenTasks, useTodayTasks, useUpcomingTasks,
useUpcomingEvents
- dashboard widgets: DayTimelineWidget, ActivityFeedWidget,
TasksTodayWidget, UpcomingEventsWidget
- search providers: todo + calendar (substring scoring needs
plaintext)
- quick-input adapters: todo + calendar (search-as-you-type)
- calendar/components/ConflictWarning, CalendarHeader (iCal export
embeds title in the file)
- calendar/views/DetailView, todo/views/DetailView (inline editor)
- api/services/qr-export (the QR snapshot would otherwise ship
ciphertext)
- triggers/suggestions (cross-matches habit titles against task /
event titles)
- todo/reminder-source (notification body uses task title)
Habits is implicitly covered: it only writes through createBlock /
updateBlock and only reads block.startDate from the timeBlock side, so
no per-store changes were needed for habits to participate.
Why
---
This closes the last big plaintext gap on the dashboard. tasks +
events + the timeBlocks hub were the highest-value targets after chat
+ contacts because they're the surfaces a casual observer of an
unlocked DB would scan first ("what's this person doing today?"). With
Phase 7.1, the answer to that query is opaque without the master key.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4bdf4238ce
commit
c875b4e966
23 changed files with 362 additions and 176 deletions
|
|
@ -1,12 +1,20 @@
|
|||
/**
|
||||
* QR Export API Service
|
||||
* QR Export Service
|
||||
*
|
||||
* Collects data from contacts, calendar, and todo services for QR export.
|
||||
* Builds a QR-encoded snapshot of the user's most relevant contacts,
|
||||
* upcoming calendar events and todo tasks. Reads from the local
|
||||
* IndexedDB (Dexie) directly — there is no longer a per-app HTTP backend
|
||||
* to call, all module data lives in the unified `mana` database via
|
||||
* the local-first sync layer.
|
||||
*/
|
||||
|
||||
import { contactsService, type Contact } from './contacts';
|
||||
import { calendarService, type CalendarEvent } from './calendar';
|
||||
import { todoService, type Task } from './todo';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { toTimeBlock } from '$lib/data/time-blocks/queries';
|
||||
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
|
||||
import type { LocalContact } from '$lib/modules/contacts/types';
|
||||
import type { LocalEvent } from '$lib/modules/calendar/types';
|
||||
import type { LocalTask } from '$lib/modules/todo/types';
|
||||
import type { UserDataSummary } from './my-data';
|
||||
import {
|
||||
createManaQRExport,
|
||||
|
|
@ -18,19 +26,15 @@ import {
|
|||
type TodoPriority,
|
||||
} from '@mana/qr-export';
|
||||
|
||||
/**
|
||||
* Data collected for QR export
|
||||
*/
|
||||
/** Data collected for QR export. */
|
||||
export interface QRExportData {
|
||||
contacts: Contact[];
|
||||
events: CalendarEvent[];
|
||||
tasks: Task[];
|
||||
contacts: LocalContact[];
|
||||
events: Array<LocalEvent & { startTime: string; endTime: string | null; isAllDay: boolean }>;
|
||||
tasks: LocalTask[];
|
||||
userData: UserDataSummary | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of QR export generation
|
||||
*/
|
||||
/** Result of QR export generation. */
|
||||
export interface QRExportResult {
|
||||
encodeResult: EncodeResult;
|
||||
stats: {
|
||||
|
|
@ -40,54 +44,121 @@ export interface QRExportResult {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Contact to ContactInput for qr-export
|
||||
*/
|
||||
function mapContactToInput(contact: Contact): ContactInput {
|
||||
const displayName = contactsService.getDisplayName(contact);
|
||||
// ─── Local helpers (replace the deleted *Service modules) ─────
|
||||
|
||||
// Determine relation based on available data
|
||||
// Default to 3 (Freund), but this could be enhanced with actual relation data
|
||||
let relation: ContactRelation = 3;
|
||||
/** Best-effort display name for a contact. Mirrors the legacy
|
||||
* contactsService.getDisplayName so QR output stays consistent. */
|
||||
function getContactDisplayName(c: LocalContact): string {
|
||||
const anyC = c as unknown as Record<string, unknown>;
|
||||
const displayName = anyC.displayName as string | undefined;
|
||||
if (displayName) return displayName;
|
||||
if (c.firstName && c.lastName) return `${c.firstName} ${c.lastName}`;
|
||||
if (c.firstName) return c.firstName;
|
||||
if (c.lastName) return c.lastName;
|
||||
if (c.email) return c.email;
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/** Top N favorite (or recently updated) contacts. */
|
||||
async function loadFavoriteContacts(limit: number): Promise<LocalContact[]> {
|
||||
const all = await db.table<LocalContact>('contacts').toArray();
|
||||
const live = all.filter((c) => !c.deletedAt && !c.isArchived);
|
||||
live.sort((a, b) => {
|
||||
// Favorites first, then by updatedAt descending.
|
||||
if (a.isFavorite !== b.isFavorite) return a.isFavorite ? -1 : 1;
|
||||
return (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '');
|
||||
});
|
||||
return live.slice(0, limit);
|
||||
}
|
||||
|
||||
/** Upcoming calendar events for the next `days` days. Pulls scheduling
|
||||
* info from the linked timeBlocks (events table no longer holds dates
|
||||
* directly after the v3 schema migration). */
|
||||
async function loadUpcomingEvents(days: number): Promise<QRExportData['events']> {
|
||||
const horizon = new Date(Date.now() + days * 86_400_000).toISOString();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const blocks = await db.table<LocalTimeBlock>('timeBlocks').toArray();
|
||||
const candidateBlocks = blocks
|
||||
.filter(
|
||||
(b) =>
|
||||
!b.deletedAt &&
|
||||
b.sourceModule === 'calendar' &&
|
||||
b.type === 'event' &&
|
||||
b.startDate >= now &&
|
||||
b.startDate <= horizon
|
||||
)
|
||||
.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||
const upcomingBlocks = await decryptRecords('timeBlocks', candidateBlocks);
|
||||
|
||||
const allEvents = await db.table<LocalEvent>('events').toArray();
|
||||
const visibleEvents = allEvents.filter((e) => !e.deletedAt);
|
||||
const decryptedEvents = await decryptRecords('events', visibleEvents);
|
||||
const eventsById = new Map<string, LocalEvent>();
|
||||
for (const e of decryptedEvents) {
|
||||
eventsById.set(e.id, e);
|
||||
}
|
||||
|
||||
return upcomingBlocks
|
||||
.map((block) => {
|
||||
const tb = toTimeBlock(block);
|
||||
const event = eventsById.get(block.sourceId);
|
||||
if (!event) return null;
|
||||
return {
|
||||
...event,
|
||||
startTime: tb.startDate,
|
||||
endTime: tb.endDate,
|
||||
isAllDay: tb.allDay,
|
||||
};
|
||||
})
|
||||
.filter((e): e is NonNullable<typeof e> => e !== null);
|
||||
}
|
||||
|
||||
/** Tasks with a dueDate inside the next `days` days, soft-deleted out. */
|
||||
async function loadUpcomingTasks(days: number): Promise<LocalTask[]> {
|
||||
const horizon = new Date(Date.now() + days * 86_400_000).toISOString();
|
||||
const all = await db.table<LocalTask>('tasks').toArray();
|
||||
const visible = all.filter(
|
||||
(t) => !t.deletedAt && !t.isCompleted && t.dueDate && t.dueDate <= horizon
|
||||
);
|
||||
const decrypted = await decryptRecords('tasks', visible);
|
||||
return decrypted.sort((a, b) => (a.dueDate ?? '').localeCompare(b.dueDate ?? ''));
|
||||
}
|
||||
|
||||
// ─── Mappers (unchanged in spirit, retargeted at the local types) ───
|
||||
|
||||
function mapContactToInput(contact: LocalContact): ContactInput {
|
||||
const displayName = getContactDisplayName(contact);
|
||||
const relation: ContactRelation = 3; // default Freund
|
||||
|
||||
const anyC = contact as unknown as Record<string, unknown>;
|
||||
return {
|
||||
name: displayName,
|
||||
phone: contact.mobile || contact.phone,
|
||||
phone: (anyC.mobile as string | undefined) ?? contact.phone,
|
||||
email: contact.email,
|
||||
relation,
|
||||
importance: contact.isFavorite ? 100 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map CalendarEvent to EventInput for qr-export
|
||||
*/
|
||||
function mapEventToInput(event: CalendarEvent): EventInput {
|
||||
const startDate = new Date(event.startTime);
|
||||
const endDate = new Date(event.endTime);
|
||||
|
||||
function mapEventToInput(event: QRExportData['events'][number]): EventInput {
|
||||
return {
|
||||
title: event.title,
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
location: event.location,
|
||||
title: event.title ?? '',
|
||||
start: new Date(event.startTime),
|
||||
end: new Date(event.endTime ?? event.startTime),
|
||||
location: (event as { location?: string }).location,
|
||||
allDay: event.isAllDay,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Task to TodoInput for qr-export
|
||||
*/
|
||||
function mapTaskToInput(task: Task): TodoInput {
|
||||
// Map priority string to number
|
||||
function mapTaskToInput(task: LocalTask): TodoInput {
|
||||
const priorityMap: Record<string, TodoPriority> = {
|
||||
urgent: 1,
|
||||
high: 1,
|
||||
medium: 2,
|
||||
low: 3,
|
||||
};
|
||||
|
||||
const priority = priorityMap[task.priority] || 2;
|
||||
const priority = priorityMap[task.priority ?? 'medium'] ?? 2;
|
||||
|
||||
return {
|
||||
title: task.title,
|
||||
|
|
@ -97,32 +168,20 @@ function mapTaskToInput(task: Task): TodoInput {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* QR Export service
|
||||
*/
|
||||
export const qrExportService = {
|
||||
/**
|
||||
* Collect all data needed for QR export
|
||||
*/
|
||||
async collectExportData(): Promise<QRExportData> {
|
||||
// Fetch all data in parallel
|
||||
const [contactsResult, eventsResult, tasksResult] = await Promise.all([
|
||||
contactsService.getFavoriteContacts(10), // Get more, we'll filter
|
||||
calendarService.getUpcomingEvents(30), // Next 30 days
|
||||
todoService.getUpcomingTasks(30), // Next 30 days
|
||||
]);
|
||||
// ─── Public service ───────────────────────────────────────────
|
||||
|
||||
return {
|
||||
contacts: contactsResult.data || [],
|
||||
events: eventsResult.data || [],
|
||||
tasks: tasksResult.data || [],
|
||||
userData: null, // Will be set by caller if needed
|
||||
};
|
||||
export const qrExportService = {
|
||||
/** Collect contacts/events/tasks needed for the QR export. */
|
||||
async collectExportData(): Promise<QRExportData> {
|
||||
const [contacts, events, tasks] = await Promise.all([
|
||||
loadFavoriteContacts(10),
|
||||
loadUpcomingEvents(30),
|
||||
loadUpcomingTasks(30),
|
||||
]);
|
||||
return { contacts, events, tasks, userData: null };
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate QR export from collected data
|
||||
*/
|
||||
/** Encode an already-collected dataset into a QR result. */
|
||||
generateExport(
|
||||
data: QRExportData,
|
||||
options?: {
|
||||
|
|
@ -135,31 +194,26 @@ export const qrExportService = {
|
|||
const maxEvents = options?.maxEvents ?? 10;
|
||||
const maxTodos = options?.maxTodos ?? 15;
|
||||
|
||||
// Map to input formats
|
||||
const contactInputs = data.contacts.map(mapContactToInput);
|
||||
const eventInputs = data.events.map(mapEventToInput);
|
||||
const taskInputs = data.tasks.map(mapTaskToInput);
|
||||
|
||||
// Build export using the builder
|
||||
const builder = createManaQRExport();
|
||||
|
||||
// Set user context if available
|
||||
if (data.userData?.user) {
|
||||
builder.user({
|
||||
n: data.userData.user.name || data.userData.user.email.split('@')[0],
|
||||
l: 'de', // Could be derived from user settings
|
||||
z: 'Europe/Berlin', // Could be derived from user settings
|
||||
l: 'de',
|
||||
z: 'Europe/Berlin',
|
||||
});
|
||||
} else {
|
||||
builder.userName('Mana User');
|
||||
}
|
||||
|
||||
// Add data using smart selectors
|
||||
builder.contactsFrom(contactInputs, maxContacts);
|
||||
builder.eventsFrom(eventInputs, maxEvents);
|
||||
builder.todosFrom(taskInputs, maxTodos);
|
||||
|
||||
// Encode
|
||||
const encodeResult = builder.encode();
|
||||
|
||||
return {
|
||||
|
|
@ -172,9 +226,7 @@ export const qrExportService = {
|
|||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate QR export with all data fetched automatically
|
||||
*/
|
||||
/** One-shot helper used by the QR Export modal. */
|
||||
async generateFullExport(
|
||||
userData?: UserDataSummary | null,
|
||||
options?: {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import { _ } from 'svelte-i18n';
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import type { LocalTimeBlock, TimeBlockType } from '$lib/data/time-blocks/types';
|
||||
import { toTimeBlock } from '$lib/data/time-blocks/queries';
|
||||
import type { TimeBlock } from '$lib/data/time-blocks/types';
|
||||
|
|
@ -29,8 +30,9 @@
|
|||
|
||||
const recentQuery = useLiveQueryWithDefault(async () => {
|
||||
const locals = await db.table<LocalTimeBlock>('timeBlocks').toArray();
|
||||
return locals
|
||||
.filter((b) => !b.deletedAt)
|
||||
const visible = locals.filter((b) => !b.deletedAt);
|
||||
const decrypted = await decryptRecords('timeBlocks', visible);
|
||||
return decrypted
|
||||
.map(toTimeBlock)
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
.slice(0, MAX_ITEMS);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import { _ } from 'svelte-i18n';
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import type { LocalTimeBlock, TimeBlockType } from '$lib/data/time-blocks/types';
|
||||
import { toTimeBlock, getBlockDuration } from '$lib/data/time-blocks/queries';
|
||||
import type { TimeBlock } from '$lib/data/time-blocks/types';
|
||||
|
|
@ -26,10 +27,9 @@
|
|||
.where('startDate')
|
||||
.between(todayStart, todayEnd, true, true)
|
||||
.toArray();
|
||||
return locals
|
||||
.filter((b) => !b.deletedAt)
|
||||
.map(toTimeBlock)
|
||||
.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||
const visible = locals.filter((b) => !b.deletedAt);
|
||||
const decrypted = await decryptRecords('timeBlocks', visible);
|
||||
return decrypted.map(toTimeBlock).sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||
}, [] as TimeBlock[]);
|
||||
|
||||
let blocks = $derived(blocksQuery.value ?? []);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from './database';
|
||||
import { decryptRecords } from './crypto';
|
||||
|
||||
import type { LocalTask } from '$lib/modules/todo/types';
|
||||
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
|
||||
|
|
@ -27,7 +28,8 @@ import type { LocalDeck as LocalCardDeck, LocalCard } from '$lib/modules/cards/t
|
|||
export function useOpenTasks() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const all = await db.table<LocalTask>('tasks').orderBy('order').toArray();
|
||||
return all.filter((t) => !t.isCompleted && !t.deletedAt);
|
||||
const visible = all.filter((t) => !t.isCompleted && !t.deletedAt);
|
||||
return decryptRecords('tasks', visible);
|
||||
}, [] as LocalTask[]);
|
||||
}
|
||||
|
||||
|
|
@ -44,7 +46,8 @@ export function useTodayTasks() {
|
|||
.where('dueDate')
|
||||
.belowOrEqual(endOfToday.toISOString())
|
||||
.toArray();
|
||||
return candidates.filter((t) => !t.isCompleted && !t.deletedAt);
|
||||
const visible = candidates.filter((t) => !t.isCompleted && !t.deletedAt);
|
||||
return decryptRecords('tasks', visible);
|
||||
}, [] as LocalTask[]);
|
||||
}
|
||||
|
||||
|
|
@ -66,7 +69,8 @@ export function useUpcomingTasks(days = 7) {
|
|||
.where('dueDate')
|
||||
.between(startOfTomorrow.toISOString(), endOfWindow.toISOString(), true, true)
|
||||
.toArray();
|
||||
return candidates.filter((t) => !t.isCompleted && !t.deletedAt);
|
||||
const visible = candidates.filter((t) => !t.isCompleted && !t.deletedAt);
|
||||
return decryptRecords('tasks', visible);
|
||||
}, [] as LocalTask[]);
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +91,8 @@ export function useUpcomingEvents(days = 7) {
|
|||
.where('startDate')
|
||||
.between(now.toISOString(), future.toISOString(), true, true)
|
||||
.toArray();
|
||||
return candidates.filter((b) => !b.deletedAt);
|
||||
const visible = candidates.filter((b) => !b.deletedAt);
|
||||
return decryptRecords('timeBlocks', visible);
|
||||
}, [] as LocalTimeBlock[]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -95,10 +95,16 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
},
|
||||
|
||||
// ─── Tasks ───────────────────────────────────────────────
|
||||
tasks: { enabled: false, fields: ['title', 'description', 'subtasks', 'metadata'] },
|
||||
// Phase 7.1: tasks coordinated with timeBlocks below — title and
|
||||
// description are duplicated to the TimeBlock for calendar display,
|
||||
// so both sides have to be encrypted in lockstep.
|
||||
tasks: { enabled: true, fields: ['title', 'description', 'subtasks', 'metadata'] },
|
||||
|
||||
// ─── Calendar ────────────────────────────────────────────
|
||||
events: { enabled: false, fields: ['title', 'description', 'location'] },
|
||||
// Same coordination as tasks: events.title/description/location are
|
||||
// mirrored onto a TimeBlock; encrypting only the calendar copy
|
||||
// would still leak via the timeBlocks table.
|
||||
events: { enabled: true, fields: ['title', 'description', 'location'] },
|
||||
|
||||
// ─── Cycles ──────────────────────────────────────────────
|
||||
// Health data — GDPR Art. 9 sensitive personal data category.
|
||||
|
|
@ -177,6 +183,16 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
// for now; broader coverage is a Phase 7 concern that needs a
|
||||
// different storage layout.
|
||||
invItems: { enabled: true, fields: ['description'] },
|
||||
|
||||
// ─── TimeBlocks (cross-module hub) ───────────────────────
|
||||
// Phase 7.1: encrypted alongside tasks + calendar.events + habits
|
||||
// because the consumer modules denormalize their title/description
|
||||
// into the timeBlock for cheap calendar rendering. Encrypting only
|
||||
// the source records would still leak the same fields here.
|
||||
// Indexed columns (startDate, endDate, kind, type, sourceModule,
|
||||
// sourceId, parentBlockId, recurrenceDate) all stay plaintext —
|
||||
// the calendar query layer needs them for range scans.
|
||||
timeBlocks: { enabled: true, fields: ['title', 'description'] },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
import { liveQuery } from 'dexie';
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import type {
|
||||
LocalTimeBlock,
|
||||
TimeBlock,
|
||||
|
|
@ -55,7 +56,9 @@ export function toTimeBlock(local: LocalTimeBlock): TimeBlock {
|
|||
export function useAllTimeBlocks() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await db.table<LocalTimeBlock>('timeBlocks').toArray();
|
||||
return locals.filter((b) => !b.deletedAt).map(toTimeBlock);
|
||||
const visible = locals.filter((b) => !b.deletedAt);
|
||||
const decrypted = await decryptRecords('timeBlocks', visible);
|
||||
return decrypted.map(toTimeBlock);
|
||||
}, [] as TimeBlock[]);
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +73,9 @@ export function timeBlocksInRange$(start: string, end: string) {
|
|||
.where('startDate')
|
||||
.between(start, end, true, true)
|
||||
.toArray();
|
||||
return locals.filter((b) => !b.deletedAt).map(toTimeBlock);
|
||||
const visible = locals.filter((b) => !b.deletedAt);
|
||||
const decrypted = await decryptRecords('timeBlocks', visible);
|
||||
return decrypted.map(toTimeBlock);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +87,9 @@ export function timeBlocksBySource$(sourceModule: TimeBlockSourceModule, sourceI
|
|||
.where('[sourceModule+sourceId]')
|
||||
.equals([sourceModule, sourceId])
|
||||
.toArray();
|
||||
return locals.filter((b) => !b.deletedAt).map(toTimeBlock);
|
||||
const visible = locals.filter((b) => !b.deletedAt);
|
||||
const decrypted = await decryptRecords('timeBlocks', visible);
|
||||
return decrypted.map(toTimeBlock);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -90,10 +97,14 @@ export function timeBlocksBySource$(sourceModule: TimeBlockSourceModule, sourceI
|
|||
export function useLiveTimeBlock() {
|
||||
return useLiveQueryWithDefault(
|
||||
async () => {
|
||||
// Can't index boolean in Dexie reliably, so scan and filter
|
||||
// Can't index boolean in Dexie reliably, so scan and filter.
|
||||
// isLive is a plaintext column so we can find before decrypting,
|
||||
// then only decrypt the single row we actually need.
|
||||
const locals = await db.table<LocalTimeBlock>('timeBlocks').toArray();
|
||||
const active = locals.find((b) => b.isLive && !b.deletedAt);
|
||||
return active ? toTimeBlock(active) : null;
|
||||
if (!active) return null;
|
||||
const [decrypted] = await decryptRecords('timeBlocks', [active]);
|
||||
return toTimeBlock(decrypted);
|
||||
},
|
||||
null as TimeBlock | null
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,9 +3,21 @@
|
|||
*
|
||||
* Module stores create both their domain record and a timeBlock in the same
|
||||
* Dexie transaction to keep them consistent.
|
||||
*
|
||||
* Phase 7.1 encryption: title + description are encrypted at rest. The
|
||||
* consumer modules (todo, calendar, habits, times) flow their plaintext
|
||||
* snapshots through this service, which wraps them via encryptRecord
|
||||
* before the actual Dexie write — so every caller gets encryption for
|
||||
* free without touching their own code paths.
|
||||
*
|
||||
* `getBlock` returns the raw row (still encrypted). Read-paths must go
|
||||
* through queries.ts which calls decryptRecord on the way out, OR call
|
||||
* decryptBlock() explicitly if reading via getBlock for write-coupling
|
||||
* (e.g. startFromScheduled needs the plaintext title to copy it forward).
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
|
||||
import { timeBlockTable } from './collections';
|
||||
import type { LocalTimeBlock, CreateTimeBlockInput, UpdateTimeBlockInput } from './types';
|
||||
|
||||
|
|
@ -36,6 +48,9 @@ export async function createBlock(input: CreateTimeBlockInput): Promise<string>
|
|||
updatedAt: now,
|
||||
};
|
||||
|
||||
// Encrypt configured fields (title + description) before write.
|
||||
// All other columns stay plaintext for indexed queries.
|
||||
await encryptRecord('timeBlocks', block);
|
||||
await timeBlockTable.add(block);
|
||||
return id;
|
||||
}
|
||||
|
|
@ -43,10 +58,12 @@ export async function createBlock(input: CreateTimeBlockInput): Promise<string>
|
|||
/** Update an existing timeBlock. */
|
||||
export async function updateBlock(id: string, input: UpdateTimeBlockInput): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
await timeBlockTable.update(id, {
|
||||
const diff: Partial<LocalTimeBlock> = {
|
||||
...input,
|
||||
updatedAt: now,
|
||||
});
|
||||
};
|
||||
await encryptRecord('timeBlocks', diff);
|
||||
await timeBlockTable.update(id, diff);
|
||||
}
|
||||
|
||||
/** Soft-delete a timeBlock. */
|
||||
|
|
@ -84,6 +101,13 @@ export async function startFromScheduled(
|
|||
const scheduled = await timeBlockTable.get(scheduledId);
|
||||
if (!scheduled || scheduled.deletedAt) throw new Error('Scheduled block not found');
|
||||
|
||||
// scheduled.title is encrypted on disk — decrypt before forwarding
|
||||
// to createBlock, otherwise the new logged block would carry the
|
||||
// already-encrypted blob through encryptRecord again. encryptRecord
|
||||
// is idempotent on already-encrypted strings, so this is defence-in-
|
||||
// depth: future code that compares titles needs the plaintext anyway.
|
||||
const decryptedScheduled = await decryptRecord('timeBlocks', { ...scheduled });
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const loggedId = await createBlock({
|
||||
startDate: now,
|
||||
|
|
@ -94,7 +118,7 @@ export async function startFromScheduled(
|
|||
sourceModule: scheduled.sourceModule,
|
||||
sourceId: scheduled.sourceId,
|
||||
linkedBlockId: scheduledId,
|
||||
title: overrides?.title ?? scheduled.title,
|
||||
title: overrides?.title ?? decryptedScheduled.title,
|
||||
color: overrides?.color ?? scheduled.color ?? null,
|
||||
icon: overrides?.icon ?? scheduled.icon ?? null,
|
||||
projectId: overrides?.projectId ?? scheduled.projectId ?? null,
|
||||
|
|
@ -106,9 +130,27 @@ export async function startFromScheduled(
|
|||
return loggedId;
|
||||
}
|
||||
|
||||
/** Get a single timeBlock by ID. */
|
||||
/**
|
||||
* Get a single timeBlock by ID. Returns the raw row WITH ciphertext
|
||||
* still in the encrypted columns — caller is responsible for calling
|
||||
* `decryptBlock` if they need the plaintext title/description.
|
||||
*
|
||||
* Read-paths via queries.ts already decrypt automatically; getBlock
|
||||
* is the explicit escape hatch for code that needs the row outside
|
||||
* a liveQuery (e.g. write-coupling helpers like startFromScheduled).
|
||||
*/
|
||||
export async function getBlock(id: string): Promise<LocalTimeBlock | undefined> {
|
||||
const block = await timeBlockTable.get(id);
|
||||
if (block?.deletedAt) return undefined;
|
||||
return block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a decrypted copy of a single timeBlock — convenience for the
|
||||
* few callers that need plaintext title/description outside of the
|
||||
* liveQuery layer. Mutates a fresh copy, never the original row, so the
|
||||
* IndexedDB record stays encrypted.
|
||||
*/
|
||||
export async function decryptBlock(block: LocalTimeBlock): Promise<LocalTimeBlock> {
|
||||
return decryptRecord('timeBlocks', { ...block });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
Export,
|
||||
} from '@mana/shared-icons';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
|
||||
import { toTimeBlock } from '$lib/data/time-blocks/queries';
|
||||
import { downloadICalendar } from '$lib/data/time-blocks/ical-export';
|
||||
|
|
@ -41,8 +42,11 @@
|
|||
|
||||
async function handleExport() {
|
||||
const locals = await db.table<LocalTimeBlock>('timeBlocks').toArray();
|
||||
const blocks = locals
|
||||
.filter((b) => !b.deletedAt)
|
||||
const visible = locals.filter((b) => !b.deletedAt);
|
||||
// iCal export embeds the title/description in the file — must
|
||||
// decrypt before writing or we'd ship ciphertext to the user.
|
||||
const decrypted = await decryptRecords('timeBlocks', visible);
|
||||
const blocks = decrypted
|
||||
.map(toTimeBlock)
|
||||
.filter((b) => calendarViewStore.visibleBlockTypes.has(b.type));
|
||||
downloadICalendar(blocks);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
-->
|
||||
<script lang="ts">
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
|
||||
import { toTimeBlock, findOverlaps } from '$lib/data/time-blocks/queries';
|
||||
import type { TimeBlock } from '$lib/data/time-blocks/types';
|
||||
|
|
@ -36,8 +37,10 @@
|
|||
.where('startDate')
|
||||
.between(dayStart, dayEnd, true, true)
|
||||
.toArray()
|
||||
.then((locals) => {
|
||||
const blocks = locals.filter((b) => !b.deletedAt).map(toTimeBlock);
|
||||
.then(async (locals) => {
|
||||
const visible = locals.filter((b) => !b.deletedAt);
|
||||
const decrypted = await decryptRecords('timeBlocks', visible);
|
||||
const blocks = decrypted.map(toTimeBlock);
|
||||
conflicts = findOverlaps(blocks, startDate, endDate, excludeBlockId);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import type { LocalCalendar, LocalEvent, Calendar, CalendarEvent } from './types';
|
||||
import { timeBlockToCalendarEvent } from './types';
|
||||
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
|
||||
|
|
@ -49,19 +50,23 @@ export function useAllCalendars() {
|
|||
*/
|
||||
export function useAllCalendarItems() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
// Fetch all non-deleted timeBlocks
|
||||
// Fetch all non-deleted timeBlocks (filter on plaintext deletedAt
|
||||
// before paying the per-row decrypt cost)
|
||||
const blocks = await db.table<LocalTimeBlock>('timeBlocks').toArray();
|
||||
const activeBlocks = blocks.filter((b) => !b.deletedAt);
|
||||
const visibleBlocks = blocks.filter((b) => !b.deletedAt);
|
||||
const decryptedBlocks = await decryptRecords('timeBlocks', visibleBlocks);
|
||||
|
||||
// Fetch all non-deleted events for joining with calendar-type blocks
|
||||
const events = await db.table<LocalEvent>('events').toArray();
|
||||
const visibleEvents = events.filter((e) => !e.deletedAt);
|
||||
const decryptedEvents = await decryptRecords('events', visibleEvents);
|
||||
const eventsById = new Map<string, LocalEvent>();
|
||||
for (const e of events) {
|
||||
if (!e.deletedAt) eventsById.set(e.id, e);
|
||||
for (const e of decryptedEvents) {
|
||||
eventsById.set(e.id, e);
|
||||
}
|
||||
|
||||
// Convert to CalendarEvent, joining event data for calendar blocks
|
||||
return activeBlocks.map((block) => {
|
||||
return decryptedBlocks.map((block) => {
|
||||
const tb = toTimeBlock(block);
|
||||
const eventData =
|
||||
block.sourceModule === 'calendar' ? (eventsById.get(block.sourceId) ?? null) : null;
|
||||
|
|
@ -70,32 +75,6 @@ export function useAllCalendarItems() {
|
|||
}, [] as CalendarEvent[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only native calendar events (for backward compatibility with calendar-specific views).
|
||||
*/
|
||||
export function useAllEvents() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const blocks = await db.table<LocalTimeBlock>('timeBlocks').toArray();
|
||||
const calendarBlocks = blocks.filter(
|
||||
(b) => !b.deletedAt && b.sourceModule === 'calendar' && b.type === 'event'
|
||||
);
|
||||
|
||||
const events = await db.table<LocalEvent>('events').toArray();
|
||||
const eventsById = new Map<string, LocalEvent>();
|
||||
for (const e of events) {
|
||||
if (!e.deletedAt) eventsById.set(e.id, e);
|
||||
}
|
||||
|
||||
return calendarBlocks
|
||||
.map((block) => {
|
||||
const tb = toTimeBlock(block);
|
||||
const eventData = eventsById.get(block.sourceId) ?? null;
|
||||
return timeBlockToCalendarEvent(tb, eventData);
|
||||
})
|
||||
.filter((e) => e.calendarId !== '__external__');
|
||||
}, [] as CalendarEvent[]);
|
||||
}
|
||||
|
||||
// ─── Pure Calendar Helpers ─────────────────────────────────
|
||||
|
||||
/** Get visible calendars (where isVisible is true). */
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import type { InputBarAdapter } from '$lib/quick-input/types';
|
||||
import type { QuickInputItem } from '@mana/shared-ui';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { parseEventInput, resolveEventIds, formatParsedEventPreview } from './utils/event-parser';
|
||||
import { toCalendar } from './queries';
|
||||
import type { LocalCalendar, LocalEvent } from './types';
|
||||
|
|
@ -22,16 +23,17 @@ export function createAdapter(): InputBarAdapter {
|
|||
|
||||
async onSearch(query) {
|
||||
const q = query.toLowerCase();
|
||||
// Search timeBlocks of type 'event' for calendar events
|
||||
// Search timeBlocks of type 'event' for calendar events. title is
|
||||
// encrypted on disk, so we can only filter on plaintext metadata
|
||||
// (sourceModule/type/deletedAt) before decrypting, then run the
|
||||
// substring match on the decrypted titles.
|
||||
const blocks = await db.table<LocalTimeBlock>('timeBlocks').toArray();
|
||||
return blocks
|
||||
.filter(
|
||||
(b) =>
|
||||
!b.deletedAt &&
|
||||
b.sourceModule === 'calendar' &&
|
||||
b.type === 'event' &&
|
||||
b.title?.toLowerCase().includes(q)
|
||||
)
|
||||
const candidates = blocks.filter(
|
||||
(b) => !b.deletedAt && b.sourceModule === 'calendar' && b.type === 'event'
|
||||
);
|
||||
const decrypted = await decryptRecords('timeBlocks', candidates);
|
||||
return decrypted
|
||||
.filter((b) => b.title?.toLowerCase().includes(q))
|
||||
.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime())
|
||||
.slice(0, 10)
|
||||
.map((b) => ({
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
|
||||
import { timeBlockTable } from '$lib/data/time-blocks/collections';
|
||||
import {
|
||||
|
|
@ -78,6 +79,11 @@ export const eventsStore = {
|
|||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// title/description/location are encrypted at rest. createBlock
|
||||
// already handled the TimeBlock side; this wraps the LocalEvent
|
||||
// row before the Dexie write. UI never sees this mutation —
|
||||
// reads go through queries.ts which decrypts on the way out.
|
||||
await encryptRecord('events', newLocal);
|
||||
await db.table<LocalEvent>('events').add(newLocal);
|
||||
CalendarEvents.eventCreated(!!input.recurrenceRule);
|
||||
return { success: true, data: { id: eventId, timeBlockId } };
|
||||
|
|
@ -134,6 +140,7 @@ export const eventsStore = {
|
|||
if (input.color !== undefined) localData.color = input.color;
|
||||
if (input.calendarId !== undefined) localData.calendarId = input.calendarId;
|
||||
|
||||
await encryptRecord('events', localData);
|
||||
await db.table('events').update(id, localData);
|
||||
CalendarEvents.eventUpdated();
|
||||
return { success: true };
|
||||
|
|
@ -182,6 +189,7 @@ export const eventsStore = {
|
|||
if (input.location !== undefined) localData.location = input.location;
|
||||
if (input.color !== undefined) localData.color = input.color;
|
||||
|
||||
await encryptRecord('events', localData);
|
||||
await db.table('events').update(id, localData);
|
||||
CalendarEvents.eventUpdated();
|
||||
return { success: true };
|
||||
|
|
@ -243,6 +251,7 @@ export const eventsStore = {
|
|||
if (input.description !== undefined) localData.description = input.description;
|
||||
if (input.location !== undefined) localData.location = input.location;
|
||||
if (input.color !== undefined) localData.color = input.color;
|
||||
await encryptRecord('events', localData);
|
||||
await db.table('events').update(templateEvent.id, localData);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@
|
|||
<script lang="ts">
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecord } from '$lib/data/crypto';
|
||||
import { eventsStore } from '../stores/events.svelte';
|
||||
import { Trash, MapPin, Clock, X } from '@mana/shared-icons';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
import type { LocalEvent } from '../types';
|
||||
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
|
||||
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
|
||||
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
|
||||
import LinkedItems from '$lib/components/links/LinkedItems.svelte';
|
||||
import { toastStore } from '@mana/shared-ui/toast';
|
||||
|
||||
|
|
@ -57,7 +58,11 @@
|
|||
const block = ev.timeBlockId
|
||||
? await db.table<LocalTimeBlock>('timeBlocks').get(ev.timeBlockId)
|
||||
: null;
|
||||
return { event: ev, block: block ?? null };
|
||||
// Both rows carry encrypted title/description (events also encrypts
|
||||
// location). Decrypt clones so the inline editor binds to plaintext.
|
||||
const decryptedEvent = await decryptRecord('events', { ...ev });
|
||||
const decryptedBlock = block ? await decryptRecord('timeBlocks', { ...block }) : null;
|
||||
return { event: decryptedEvent, block: decryptedBlock };
|
||||
}).subscribe((val) => {
|
||||
event = val?.event ?? null;
|
||||
timeBlock = val?.block ?? null;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import type { BaseRecord } from '@mana/local-store';
|
||||
|
||||
interface Task extends BaseRecord {
|
||||
|
|
@ -36,13 +37,14 @@
|
|||
$effect(() => {
|
||||
const sub = liveQuery(async () => {
|
||||
const all = await db.table<Task>('tasks').toArray();
|
||||
return all
|
||||
.filter((t) => {
|
||||
if (t.isCompleted || t.deletedAt) return false;
|
||||
if (!t.dueDate) return false;
|
||||
return t.dueDate.slice(0, 10) <= todayStr;
|
||||
})
|
||||
.sort((a, b) => a.order - b.order);
|
||||
const visible = all.filter((t) => {
|
||||
if (t.isCompleted || t.deletedAt) return false;
|
||||
if (!t.dueDate) return false;
|
||||
return t.dueDate.slice(0, 10) <= todayStr;
|
||||
});
|
||||
// task.title is encrypted on disk; decrypt before rendering.
|
||||
const decrypted = await decryptRecords('tasks', visible);
|
||||
return decrypted.sort((a, b) => a.order - b.order);
|
||||
}).subscribe({
|
||||
next: (val) => {
|
||||
tasks = val;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import type { BaseRecord } from '@mana/local-store';
|
||||
|
||||
interface CalendarEvent extends BaseRecord {
|
||||
|
|
@ -36,13 +37,17 @@
|
|||
const nowStr = now.toISOString();
|
||||
const futureStr = future.toISOString();
|
||||
|
||||
const [allEvents, calendars] = await Promise.all([
|
||||
const [rawEvents, calendars] = await Promise.all([
|
||||
db.table<CalendarEvent>('events').toArray(),
|
||||
db.table<Calendar>('calendars').toArray(),
|
||||
]);
|
||||
|
||||
const calendarMap = new Map(calendars.map((c) => [c.id, c]));
|
||||
|
||||
// title/description/location are encrypted on disk; the widget
|
||||
// renders title + location, so decrypt before further filtering.
|
||||
const allEvents = await decryptRecords('events', rawEvents);
|
||||
|
||||
return allEvents
|
||||
.filter((e) => {
|
||||
if (e.deletedAt) return false;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import type {
|
||||
LocalTask,
|
||||
LocalBoardView,
|
||||
|
|
@ -42,7 +43,9 @@ export function toTask(local: LocalTask): Task {
|
|||
export function useAllTasks() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await db.table<LocalTask>('tasks').orderBy('order').toArray();
|
||||
return locals.filter((t) => !t.deletedAt).map(toTask);
|
||||
const visible = locals.filter((t) => !t.deletedAt);
|
||||
const decrypted = await decryptRecords('tasks', visible);
|
||||
return decrypted.map(toTask);
|
||||
}, [] as Task[]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { InputBarAdapter } from '$lib/quick-input/types';
|
|||
import type { QuickInputItem } from '@mana/shared-ui';
|
||||
import { goto } from '$app/navigation';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { parseTaskInput, resolveTaskIds, formatParsedTaskPreview } from './utils/task-parser';
|
||||
import type { LocalTask } from './types';
|
||||
|
||||
|
|
@ -19,13 +20,14 @@ export function createAdapter(): InputBarAdapter {
|
|||
|
||||
async onSearch(query) {
|
||||
const q = query.toLowerCase();
|
||||
const tasks = await db.table<LocalTask>('tasks').toArray();
|
||||
// title + description are encrypted on disk; pre-filter on
|
||||
// plaintext flags first, then decrypt the small candidate set.
|
||||
const rawTasks = await db.table<LocalTask>('tasks').toArray();
|
||||
const candidates = rawTasks.filter((t) => !t.deletedAt && !t.isCompleted);
|
||||
const tasks = await decryptRecords('tasks', candidates);
|
||||
return tasks
|
||||
.filter(
|
||||
(t) =>
|
||||
!t.deletedAt &&
|
||||
!t.isCompleted &&
|
||||
(t.title?.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q))
|
||||
(t) => t.title?.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q)
|
||||
)
|
||||
.slice(0, 10)
|
||||
.map((t) => ({
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import type { ReminderSource, DueReminder } from '@mana/shared-stores';
|
||||
import type { LocalTask, LocalReminder } from './types';
|
||||
|
||||
|
|
@ -17,7 +18,10 @@ export const todoReminderSource: ReminderSource = {
|
|||
const pending = reminders.filter((r) => r.status === 'pending' && !r.deletedAt);
|
||||
if (pending.length === 0) return [];
|
||||
|
||||
const tasks = await db.table<LocalTask>('tasks').toArray();
|
||||
// task.title is encrypted on disk; the notification body uses the
|
||||
// plaintext title, so decrypt before mapping.
|
||||
const rawTasks = await db.table<LocalTask>('tasks').toArray();
|
||||
const tasks = await decryptRecords('tasks', rawTasks);
|
||||
const taskMap = new Map(tasks.map((t) => [t.id, t]));
|
||||
const now = Date.now();
|
||||
const due: DueReminder[] = [];
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { taskTable } from '../collections';
|
|||
import { toTask } from '../queries';
|
||||
import type { LocalTask, TaskPriority, Subtask } from '../types';
|
||||
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
|
||||
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
|
||||
import { TodoEvents } from '@mana/shared-utils/analytics';
|
||||
|
||||
export const tasksStore = {
|
||||
|
|
@ -72,14 +73,23 @@ export const tasksStore = {
|
|||
newLocal.projectId = data.projectId;
|
||||
}
|
||||
|
||||
// Snapshot plaintext for the return value before encryptRecord
|
||||
// mutates `newLocal` in place. Callers (UI) need plaintext title
|
||||
// + description; only the on-disk row is ciphertext.
|
||||
const plaintextSnapshot = toTask({ ...newLocal });
|
||||
await encryptRecord('tasks', newLocal);
|
||||
await taskTable.add(newLocal);
|
||||
TodoEvents.taskCreated(!!data.dueDate);
|
||||
return toTask(newLocal);
|
||||
return plaintextSnapshot;
|
||||
},
|
||||
|
||||
async updateTask(id: string, data: Record<string, unknown>) {
|
||||
const task = await taskTable.get(id);
|
||||
if (!task) return;
|
||||
const raw = await taskTable.get(id);
|
||||
if (!raw) return;
|
||||
// task.title/description are encrypted on disk — decrypt a clone so
|
||||
// fallbacks like `data.title ?? task.title` forward plaintext to
|
||||
// the linked TimeBlock instead of leaking ciphertext.
|
||||
const task = await decryptRecord('tasks', { ...raw });
|
||||
|
||||
// Handle schedule changes via TimeBlock
|
||||
const schedStartDate = data._scheduleStartDate as string | null | undefined;
|
||||
|
|
@ -145,10 +155,12 @@ export const tasksStore = {
|
|||
await updateBlock(task.scheduledBlockId, { recurrenceRule });
|
||||
}
|
||||
|
||||
await taskTable.update(id, {
|
||||
const diff: Record<string, unknown> = {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
await encryptRecord('tasks', diff);
|
||||
await taskTable.update(id, diff);
|
||||
TodoEvents.taskEdited();
|
||||
},
|
||||
|
||||
|
|
@ -194,19 +206,27 @@ export const tasksStore = {
|
|||
},
|
||||
|
||||
async updateSubtasks(id: string, subtasks: Subtask[]) {
|
||||
await taskTable.update(id, {
|
||||
const diff: Record<string, unknown> = {
|
||||
subtasks,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
await encryptRecord('tasks', diff);
|
||||
await taskTable.update(id, diff);
|
||||
},
|
||||
|
||||
async updateLabels(id: string, labelIds: string[]) {
|
||||
const existing = await taskTable.get(id);
|
||||
const raw = await taskTable.get(id);
|
||||
// metadata is in the encrypted-fields list — decrypt before
|
||||
// merging labelIds in, otherwise we'd splice a plaintext key
|
||||
// into a ciphertext blob.
|
||||
const existing = raw ? await decryptRecord('tasks', { ...raw }) : null;
|
||||
const existingMeta = (existing?.metadata as Record<string, unknown>) ?? {};
|
||||
await taskTable.update(id, {
|
||||
const diff: Record<string, unknown> = {
|
||||
metadata: { ...existingMeta, labelIds },
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
await encryptRecord('tasks', diff);
|
||||
await taskTable.update(id, diff);
|
||||
},
|
||||
|
||||
async reorderTasks(taskIds: string[]) {
|
||||
|
|
|
|||
|
|
@ -5,14 +5,15 @@
|
|||
<script lang="ts">
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecord } from '$lib/data/crypto';
|
||||
import { tasksStore } from '../stores/tasks.svelte';
|
||||
import { getBlock } from '$lib/data/time-blocks/service';
|
||||
import { getBlock, decryptBlock } from '$lib/data/time-blocks/service';
|
||||
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
|
||||
import { Check, Trash, X, CalendarBlank } from '@mana/shared-icons';
|
||||
import SlotSuggestions from '$lib/modules/calendar/components/SlotSuggestions.svelte';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
import type { LocalTask, TaskPriority } from '../types';
|
||||
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
|
||||
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
|
||||
import LinkedItems from '$lib/components/links/LinkedItems.svelte';
|
||||
import { toastStore } from '@mana/shared-ui/toast';
|
||||
|
||||
|
|
@ -65,7 +66,11 @@
|
|||
const t = await db.table<LocalTask>('tasks').get(taskId);
|
||||
if (!t) return { task: null, block: null };
|
||||
const block = t.scheduledBlockId ? await getBlock(t.scheduledBlockId) : null;
|
||||
return { task: t, block: block ?? null };
|
||||
// Decrypt clones so the inline editor binds to plaintext title /
|
||||
// description / metadata. The on-disk rows stay encrypted.
|
||||
const decryptedTask = await decryptRecord('tasks', { ...t });
|
||||
const decryptedBlock = block ? await decryptBlock(block) : null;
|
||||
return { task: decryptedTask, block: decryptedBlock };
|
||||
}).subscribe((val) => {
|
||||
task = val?.task ?? null;
|
||||
if (val?.task && !focused) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { getManaApp } from '@mana/shared-branding';
|
||||
import { scoreRecord, truncateSubtitle } from '../scoring';
|
||||
import type { SearchProvider, SearchResult, SearchOptions } from '../types';
|
||||
|
|
@ -16,9 +17,12 @@ export const calendarSearchProvider: SearchProvider = {
|
|||
const limit = options?.limit ?? 5;
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
const events = await db.table('events').toArray();
|
||||
// title/description/location are encrypted at rest. Filter on the
|
||||
// plaintext deletedAt before paying for the decrypt batch.
|
||||
const rawEvents = await db.table('events').toArray();
|
||||
const visibleEvents = rawEvents.filter((e) => !e.deletedAt);
|
||||
const events = await decryptRecords('events', visibleEvents);
|
||||
for (const event of events) {
|
||||
if (event.deletedAt) continue;
|
||||
const { score, matchedField } = scoreRecord(
|
||||
[
|
||||
{ name: 'title', value: event.title, weight: 1.0 },
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { getManaApp } from '@mana/shared-branding';
|
||||
import { scoreRecord, truncateSubtitle } from '../scoring';
|
||||
import type { SearchProvider, SearchResult, SearchOptions } from '../types';
|
||||
|
|
@ -16,10 +17,13 @@ export const todoSearchProvider: SearchProvider = {
|
|||
const limit = options?.limit ?? 5;
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
// Search tasks
|
||||
const tasks = await db.table('tasks').toArray();
|
||||
// Search tasks. title + description are encrypted on disk; the
|
||||
// scorer needs plaintext to do substring matching, so we decrypt
|
||||
// the visible rows in one batch before iterating.
|
||||
const rawTasks = await db.table('tasks').toArray();
|
||||
const visibleTasks = rawTasks.filter((t) => !t.deletedAt);
|
||||
const tasks = await decryptRecords('tasks', visibleTasks);
|
||||
for (const task of tasks) {
|
||||
if (task.deletedAt) continue;
|
||||
const { score, matchedField } = scoreRecord(
|
||||
[
|
||||
{ name: 'title', value: task.title, weight: 1.0 },
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import type { ConditionOp } from './conditions';
|
||||
|
||||
export interface AutomationSuggestion {
|
||||
|
|
@ -77,8 +78,11 @@ export async function generateSuggestions(): Promise<AutomationSuggestion[]> {
|
|||
}
|
||||
|
||||
// ─── Events ↔ Habits ────────────────────────────────────
|
||||
// Title is encrypted on disk; we have to decrypt before string-matching
|
||||
// against habit titles. Filter on plaintext metadata first to keep the
|
||||
// decrypt batch small.
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const calendarBlocks = await db
|
||||
const rawBlocks = await db
|
||||
.table('timeBlocks')
|
||||
.toArray()
|
||||
.then((all: Record<string, unknown>[]) =>
|
||||
|
|
@ -90,6 +94,7 @@ export async function generateSuggestions(): Promise<AutomationSuggestion[]> {
|
|||
(b.startDate as string) >= thirtyDaysAgo
|
||||
)
|
||||
);
|
||||
const calendarBlocks = await decryptRecords('timeBlocks', rawBlocks);
|
||||
|
||||
for (const habit of habits) {
|
||||
const matchingEvents = calendarBlocks.filter((e: Record<string, unknown>) =>
|
||||
|
|
@ -118,10 +123,12 @@ export async function generateSuggestions(): Promise<AutomationSuggestion[]> {
|
|||
}
|
||||
|
||||
// ─── Tasks ↔ Habits ─────────────────────────────────────
|
||||
const tasks = await db
|
||||
// tasks.title is encrypted at rest — same decrypt-after-filter dance.
|
||||
const rawTasks = await db
|
||||
.table('tasks')
|
||||
.toArray()
|
||||
.then((all) => all.filter((t: Record<string, unknown>) => !t.deletedAt));
|
||||
const tasks = await decryptRecords('tasks', rawTasks);
|
||||
|
||||
for (const habit of habits) {
|
||||
const matchingTasks = tasks.filter((t: Record<string, unknown>) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue