Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions apps/desktop/src/main/ipc/relation-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,33 @@ function insertEvent(db: TestDb, id: string, title: string): void {
`)
}

function insertCanvas(
db: TestDb,
id: string,
title: string,
deletedAt: number | null = null
): void {
db.run(sql`
INSERT INTO canvases (
id, vault_id, title, snapshot_ciphertext, vector_clock, created_at, updated_at, deleted_at
)
VALUES (${id}, 'vault-1', ${title}, '', '{}', 0, 0, ${deletedAt})
`)
}

function insertJournal(db: TestDb, id: string, title: string, date: string): void {
db.run(sql`
INSERT INTO note_cache (
id, path, title, file_type, content_hash, word_count, character_count,
date, created_at, modified_at
)
VALUES (
${id}, ${`journal/${date}.md`}, ${title}, 'markdown', ${`hash-${id}`}, 0, 0,
${date}, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z'
)
`)
}

describe('properties:resolveRefs', () => {
let indexDb: TestDb
let dataDb: TestDb
Expand All @@ -53,6 +80,9 @@ describe('properties:resolveRefs', () => {
insertNote(indexDb, 'nte_emoji', 'Jane Doe', 'markdown', '👩')
insertTask(dataDb, 'tsk_1', 'Call Richard')
insertEvent(dataDb, 'evt_1', 'Lunch')
insertCanvas(dataDb, 'cnv_1', 'Sprint Board')
insertCanvas(dataDb, 'cnv_gone', 'Deleted Board', 1_700_000_000_000)
insertJournal(indexDb, 'jrn_1', 'Sprint retro', '2026-05-10')
})

describe('navigation and display payload', () => {
Expand All @@ -75,6 +105,40 @@ describe('properties:resolveRefs', () => {
expect(event.startAt).toBe('2026-05-10T12:00:00.000Z')
})

it('resolves a canvas by id', async () => {
const [canvas] = await resolveRefs(indexDb, dataDb, ['memry://canvas/cnv_1'])
expect(canvas).toMatchObject({
targetType: 'canvas',
targetId: 'cnv_1',
title: 'Sprint Board',
exists: true
})
})

// The row survives a delete as a sync tombstone; the chip must still read
// as dangling so the user can see and remove it.
it('treats a soft-deleted canvas as a dangling target', async () => {
const [canvas] = await resolveRefs(indexDb, dataDb, ['memry://canvas/cnv_gone'])
expect(canvas.exists).toBe(false)
})

it('resolves a journal entry by its date and returns the date to navigate with', async () => {
const [journal] = await resolveRefs(indexDb, dataDb, ['memry://journal/2026-05-10'])
expect(journal).toMatchObject({
targetType: 'journal',
targetId: '2026-05-10',
title: 'Sprint retro',
exists: true,
date: '2026-05-10'
})
})

it('reports a day with no entry as dangling', async () => {
const [journal] = await resolveRefs(indexDb, dataDb, ['memry://journal/2026-05-11'])
expect(journal.exists).toBe(false)
expect(journal.targetId).toBe('2026-05-11')
})

it('leaves the navigation fields off a target that does not exist', async () => {
const [missing] = await resolveRefs(indexDb, dataDb, ['memry://task/tsk_gone'])
expect(missing.exists).toBe(false)
Expand Down
73 changes: 68 additions & 5 deletions apps/desktop/src/main/ipc/relation-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@
* Resolves `memry://<kind>/<id>` relation property URIs to display data
* (title, existence) for chip rendering in the renderer. Spans both
* databases: note targets live in `note_cache` (index DB), task and event
* targets live in `tasks` / `calendar_events` (data DB). External provider
* events (`calendar_external_events`) are out of scope and never queried.
* targets live in `tasks` / `calendar_events` (data DB). Canvas targets live in
* `canvases` (data DB) and journal targets are `note_cache` rows with a `date`
* (index DB). External provider events (`calendar_external_events`) are out of
* scope and never queried.
*
* @module ipc/relation-handlers
*/

import { ipcMain } from 'electron'
import { inArray } from 'drizzle-orm'
import { and, inArray, isNull } from 'drizzle-orm'
import { noteCache } from '@memry/db-schema/schema/notes-cache'
import { tasks } from '@memry/db-schema/schema/tasks'
import { calendarEvents } from '@memry/db-schema/schema/calendar-events'
import { canvases } from '@memry/db-schema/schema/canvas'
import {
PropertiesChannels,
ResolveRefsSchema,
Expand All @@ -31,7 +34,7 @@ const logger = createLogger('RelationRefs')
* Resolve a batch of relation URIs to display data.
*
* Groups the parsed URIs by kind and issues at most one `inArray` query per
* kind (three total, regardless of how many URIs come in) rather than one
* kind (one per kind, regardless of how many URIs come in) rather than one
* query per URI. The result preserves the order and length of the input
* array. Malformed or missing targets never throw — they come back as
* `exists: false` so a bad ref can't blank the property row.
Expand All @@ -43,7 +46,13 @@ export async function resolveRefs(
): Promise<ResolvedRelationRef[]> {
const parsed = uris.map((uri) => parseRelationUri(uri))

const idsByKind: Record<RelationKind, string[]> = { note: [], task: [], event: [] }
const idsByKind: Record<RelationKind, string[]> = {
note: [],
task: [],
event: [],
canvas: [],
journal: []
}
for (const ref of parsed) {
if (ref) idsByKind[ref.kind].push(ref.id)
}
Expand Down Expand Up @@ -78,10 +87,35 @@ export async function resolveRefs(
.where(inArray(calendarEvents.id, idsByKind.event))
.all()
: []
// A soft-deleted canvas is a dangling target, not a hit: `deletedAt` is the
// tombstone sync relies on, so the row is still there and must be filtered.
const canvasRows = idsByKind.canvas.length
? dataDb
.select({ id: canvases.id, title: canvases.title })
.from(canvases)
.where(and(inArray(canvases.id, idsByKind.canvas), isNull(canvases.deletedAt)))
.all()
: []
// Journal entries are `note_cache` rows carrying a `date`; the date IS the
// identity, so the lookup is by date rather than by row id.
const journalRows = idsByKind.journal.length
? indexDb
.select({ id: noteCache.id, title: noteCache.title, date: noteCache.date })
.from(noteCache)
.where(inArray(noteCache.date, idsByKind.journal))
.all()
: []

const noteById = new Map(noteRows.map((row) => [row.id, row]))
const taskById = new Map(taskRows.map((row) => [row.id, row]))
const eventById = new Map(eventRows.map((row) => [row.id, row]))
const canvasById = new Map(canvasRows.map((row) => [row.id, row]))
// Two notes can in principle claim the same journal date; first row wins, the
// same way every other journal-by-date read in the app resolves it.
const journalByDate = new Map<string, (typeof journalRows)[number]>()
for (const row of journalRows) {
if (row.date && !journalByDate.has(row.date)) journalByDate.set(row.date, row)
}

return uris.map((uri, index) => {
const ref = parsed[index]
Expand Down Expand Up @@ -117,6 +151,35 @@ export async function resolveRefs(
}
}

if (ref.kind === 'canvas') {
const canvas = canvasById.get(ref.id)
if (!canvas) return { uri, targetType: 'canvas', targetId: ref.id, title: '', exists: false }
return {
uri,
targetType: 'canvas',
targetId: ref.id,
title: canvas.title ?? '',
exists: true
}
}

if (ref.kind === 'journal') {
const journal = journalByDate.get(ref.id)
if (!journal) {
return { uri, targetType: 'journal', targetId: ref.id, title: '', exists: false }
}
return {
uri,
targetType: 'journal',
targetId: ref.id,
// The date is the chip's most useful label; the note title for a
// journal entry is usually the date anyway.
title: journal.title || ref.id,
exists: true,
date: ref.id
}
}

const event = eventById.get(ref.id)
if (!event) return { uri, targetType: 'event', targetId: ref.id, title: '', exists: false }
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
FileText,
CheckSquare,
Calendar,
PenTool,
BookOpen,
type AppIcon
} from '@/lib/icons'
import { cn } from '@/lib/utils'
Expand Down Expand Up @@ -762,7 +764,9 @@ const EMPTY_RELATION_URIS: string[] = []
const RELATION_KIND_ICONS: Record<RelationKind, AppIcon> = {
note: FileText,
task: CheckSquare,
event: Calendar
event: Calendar,
canvas: PenTool,
journal: BookOpen
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { useEffect, useState } from 'react'
import { FileText, CheckSquare, Calendar, Plus, X, type AppIcon } from '@/lib/icons'
import {
FileText,
CheckSquare,
Calendar,
PenTool,
BookOpen,
Plus,
X,
type AppIcon
} from '@/lib/icons'
import { cn } from '@/lib/utils'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { propertiesService, type ResolvedRelationRef } from '@/services/properties-service'
Expand All @@ -15,7 +24,9 @@ const log = createLogger('RelationEditor')
const KIND_ICONS: Record<RelationKind, AppIcon> = {
note: FileText,
task: CheckSquare,
event: Calendar
event: Calendar,
canvas: PenTool,
journal: BookOpen
}

interface RelationEditorProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { RelationPicker } from './RelationPicker'

const mocks = vi.hoisted(() => ({
quick: vi.fn(),
searchEvents: vi.fn()
searchEvents: vi.fn(),
listCanvases: vi.fn()
}))

vi.mock('@/services/search-service', () => ({
Expand All @@ -18,6 +19,9 @@ vi.mock('@/services/search-service', () => ({
vi.mock('@/services/calendar-service', () => ({
calendarService: { searchEvents: (input: unknown) => mocks.searchEvents(input) }
}))
vi.mock('@/services/canvas-service', () => ({
canvasService: { list: () => mocks.listCanvases() }
}))

let i18nEn: I18nInstance

Expand All @@ -36,11 +40,16 @@ interface MockHit {
function mockSearch({
notes = [],
tasks = [],
events = []
events = [],
canvases = [],
journals = []
}: {
notes?: MockHit[]
tasks?: MockHit[]
events?: MockHit[]
canvases?: MockHit[]
/** `id` is the entry's ISO date. */
journals?: MockHit[]
}): void {
const results: SearchResultItem[] = [
...notes.map((note) => ({
Expand Down Expand Up @@ -74,6 +83,22 @@ function mockSearch({
priority: 0,
completedAt: null
}
})),
...journals.map((journal) => ({
id: `jrn_${journal.id}`,
type: 'journal' as const,
title: journal.title,
snippet: '',
score: 1,
normalizedScore: 1,
matchType: 'fuzzy' as const,
modifiedAt: '2026-01-01T00:00:00.000Z',
metadata: {
type: 'journal' as const,
date: journal.id,
path: `/journal/${journal.id}.md`,
tags: []
}
}))
]
mocks.quick.mockResolvedValue({ results, queryTimeMs: 1 })
Expand All @@ -86,6 +111,16 @@ function mockSearch({
isAllDay: false
}))
})
mocks.listCanvases.mockResolvedValue({
canvases: canvases.map((canvas) => ({
id: canvas.id,
title: canvas.title,
folder: null,
icon: null,
createdAt: 0,
updatedAt: 0
}))
})
}

describe('RelationPicker', () => {
Expand All @@ -104,6 +139,41 @@ describe('RelationPicker', () => {
expect(await screen.findByText('NOTES & FILES')).toBeInTheDocument()
expect(await screen.findByText('TASKS')).toBeInTheDocument()
expect(screen.queryByText('EVENTS')).not.toBeInTheDocument()
expect(screen.queryByText('CANVASES')).not.toBeInTheDocument()
expect(screen.queryByText('JOURNAL')).not.toBeInTheDocument()
})

it('searches canvases and journal entries in their own groups', async () => {
mockSearch({
canvases: [{ id: 'cnv_1', title: 'Sprint Board' }],
journals: [{ id: '2026-05-10', title: 'Sprint retro' }]
})
const onSelect = vi.fn()
renderWithI18n(<RelationPicker onSelect={onSelect} />)
await userEvent.type(screen.getByRole('textbox'), 'sprint')

expect(await screen.findByText('CANVASES')).toBeInTheDocument()
expect(await screen.findByText('JOURNAL')).toBeInTheDocument()

await userEvent.click(await screen.findByText('Sprint Board'))
expect(onSelect).toHaveBeenCalledWith('memry://canvas/cnv_1')

// A journal URI carries the DATE, never the note row id.
await userEvent.click(await screen.findByText('Sprint retro'))
expect(onSelect).toHaveBeenCalledWith('memry://journal/2026-05-10')
})

it('filters canvases by title rather than listing every canvas', async () => {
mockSearch({
canvases: [
{ id: 'cnv_1', title: 'Sprint Board' },
{ id: 'cnv_2', title: 'Holiday plans' }
]
})
renderWithI18n(<RelationPicker onSelect={vi.fn()} />)
await userEvent.type(screen.getByRole('textbox'), 'sprint')
expect(await screen.findByText('Sprint Board')).toBeInTheDocument()
expect(screen.queryByText('Holiday plans')).not.toBeInTheDocument()
})

it('emits a well-formed URI on select', async () => {
Expand Down Expand Up @@ -159,6 +229,7 @@ describe('RelationPicker', () => {
renderWithI18n(<RelationPicker onSelect={vi.fn()} />)
expect(mocks.quick).not.toHaveBeenCalled()
expect(mocks.searchEvents).not.toHaveBeenCalled()
expect(mocks.listCanvases).not.toHaveBeenCalled()
})

it('shows an empty state when a search has no matches anywhere', async () => {
Expand Down
Loading
Loading