/** * Annotation service — creates technical marks on document ranges and * emits `AnnotationCreated`. Resolution-status updates emit * `AnnotationResolved` / `AnnotationResolutionFailed`. * * Annotation creation is the engine's response to a user action in the * viewer (T07). The viewer adapter has already turned the selection into * `Selector[]`; this service stamps an ID, normalize-version, timestamps, * persists, and broadcasts. */ import type { Annotation, AnnotationResolutionStatus, } from "@shared/annotation"; import type { DocumentId, RepresentationId, AnnotationId } from "@shared/ids"; import type { Selector } from "@shared/selector"; import { newId } from "@shared/ids"; import { NORMALIZE_VERSION } from "@shared/text/normalize"; import type { EventBus } from "../events"; import type { AnnotationRepository } from "../repos"; export interface CreateAnnotationInput { readonly documentId: DocumentId; readonly representationId?: RepresentationId; readonly selectors: readonly Selector[]; readonly quote?: string; readonly note?: string; readonly createdBy?: string; } export interface AnnotationService { create(input: CreateAnnotationInput): Annotation; get(id: AnnotationId): Annotation | null; listByDocument(documentId: DocumentId): readonly Annotation[]; setResolutionStatus( id: AnnotationId, status: AnnotationResolutionStatus, opts: { readonly confidence: number; readonly reason?: string }, ): Annotation; /** * Edit the human-facing `quote` text on an annotation without touching * the underlying selectors. Selectors stay the source of truth for * locating the passage; the quote is the user's editable display copy. */ updateQuote(id: AnnotationId, quote: string): Annotation; } export function createAnnotationService( annotations: AnnotationRepository, bus: EventBus, now: () => string = () => new Date().toISOString(), ): AnnotationService { return { create(input) { const ts = now(); const annotation: Annotation = { id: newId("annotation"), documentId: input.documentId, ...(input.representationId !== undefined ? { representationId: input.representationId } : {}), selectors: input.selectors, ...(input.quote !== undefined ? { quote: input.quote } : {}), ...(input.note !== undefined ? { note: input.note } : {}), normalizeVersion: NORMALIZE_VERSION, ...(input.createdBy !== undefined ? { createdBy: input.createdBy } : {}), createdAt: ts, updatedAt: ts, }; const stored = annotations.create(annotation); bus.emit({ type: "AnnotationCreated", annotationId: stored.id, annotation: stored }); return stored; }, get(id) { return annotations.get(id); }, listByDocument(documentId) { return annotations.listByDocument(documentId); }, setResolutionStatus(id, status, opts) { const existing = annotations.get(id); if (!existing) { throw new Error(`AnnotationService.setResolutionStatus: unknown id ${id}`); } const updated: Annotation = { ...existing, resolutionStatus: status, updatedAt: now(), }; const stored = annotations.update(updated); if (status === "unresolved" || status === "stale") { bus.emit({ type: "AnnotationResolutionFailed", annotationId: stored.id, reason: opts.reason ?? status, }); } else { bus.emit({ type: "AnnotationResolved", annotationId: stored.id, status, confidence: opts.confidence, }); } return stored; }, updateQuote(id, quote) { const existing = annotations.get(id); if (!existing) { throw new Error(`AnnotationService.updateQuote: unknown id ${id}`); } const trimmed = quote.length === 0 ? undefined : quote; const updated: Annotation = { ...existing, // exactOptionalPropertyTypes: drop `quote` when empty rather // than setting it to undefined. ...(trimmed !== undefined ? { quote: trimmed } : {}), updatedAt: now(), }; if (trimmed === undefined && "quote" in updated) { // Remove the field outright when clearing. delete (updated as { quote?: string }).quote; } const stored = annotations.update(updated); bus.emit({ type: "AnnotationUpdated", annotationId: stored.id, annotation: stored }); return stored; }, }; }