Back to Body Journal
SOURCE / PINNED RELEASE

Made of little things.

Body Journal

Release
e8049f8da4e2…
Author-recorded commit
61c04bf0e8aa…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-6c4554844c7

Archive hash verified: 4369d778da046a8b…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/session-record.ts
import { parsePhotos, type CasePhoto } from './photo-record.js';
import { parseJawOpening } from './jaw-pose.js';
import { layers, type AnatomyLayer } from './layers.js';
import { parseDraft, type Draft } from './case-record.js';
import { boundedText, MAX_CASE_BYTES, object, parseReport, type Report } from './ai-contract.js';
import { poses, type Pose, type Point3 } from './case.js';
import { parseInspection, type InspectionView } from './inspection-record.js';
import { parseFingerPose, type FingerPose } from './finger-pose.js';

export interface CompletedReport { report: Report; model: string; provider: string; caseText: string; at: string; imageCount?:number; photoCount?: number }
export interface SessionRecord {
  version: 1; draft: Draft;
  view: { page: 'map' | 'context' | 'review'; panel: 'body' | 'profile' | 'concerns'; selectedId: number | null; expandedConcernId?: number | null; expandedGroupIds?: string[]; pose: Pose; layer?: AnatomyLayer; inspection?: InspectionView; fingers?: FingerPose; joints?: Record<string, Point3>; anatomyPanelOpen?: boolean; jawOpening?: number; newObservation?: boolean; exampleAttachment: boolean };
  completed?: CompletedReport; photos?: CasePhoto[]; exampleSource?: string;
}
export function parseSession(raw: string): SessionRecord {
  if (new TextEncoder().encode(raw).byteLength > 460_000) throw new Error('This session is too large to save. Remove a photo, reduce painted strokes or clear the report.');
  const data = object(JSON.parse(raw));
  // Reuse the existing storage slot and accept earlier manual drafts in place.
  if (data.profile) {
    const draft = parseDraft(raw);
    return { version: 1, draft, view: { page: 'map', panel: 'body', selectedId: draft.concerns[0]?.id || null, pose: draft.concerns[0]?.pose || 'standing', exampleAttachment: false } };
  }
  if (data.version !== 1) throw new Error('Unsupported session.');
  const draft = parseDraft(JSON.stringify(data.draft)), v = object(data.view);
  if (!['map', 'context', 'review'].includes(String(v.page)) || !['body', 'profile', 'concerns'].includes(String(v.panel)) || !poses.includes(v.pose as Pose) || typeof v.exampleAttachment !== 'boolean') throw new Error('Invalid session view.');
  const view: SessionRecord['view'] = { page: v.page as SessionRecord['view']['page'], panel: v.panel as SessionRecord['view']['panel'], selectedId: draft.concerns.some(c => c.id === v.selectedId) ? Number(v.selectedId) : null, pose: v.pose as Pose, exampleAttachment: v.exampleAttachment };
  // Omission keeps older sessions opening their selected concern. Null records
  // an intentionally collapsed editor without changing the painting target.
  if (v.expandedConcernId !== undefined) view.expandedConcernId = v.expandedConcernId === view.selectedId ? view.selectedId : null;
  if (v.expandedGroupIds !== undefined) {
    if (!Array.isArray(v.expandedGroupIds) || v.expandedGroupIds.length > 20 || v.expandedGroupIds.some(id => typeof id !== 'string')) throw new Error('Invalid expanded groups.');
    view.expandedGroupIds = [...new Set(v.expandedGroupIds as string[])].filter(id => draft.groups.some(g => g.id === id));
  }
  if(v.layer!==undefined) {
    if(!layers.includes(v.layer as AnatomyLayer)) throw new Error('Invalid view layer.');
    view.layer=v.layer as AnatomyLayer;
  }
  if(v.newObservation!==undefined){if(typeof v.newObservation!=='boolean')throw new Error('Invalid observation mode.');view.newObservation=v.newObservation;}
  if(v.anatomyPanelOpen!==undefined){if(typeof v.anatomyPanelOpen!=='boolean')throw new Error('Invalid anatomy panel state.');view.anatomyPanelOpen=v.anatomyPanelOpen;}
  if(v.joints!==undefined){
    const joints=object(v.joints);if(Object.keys(joints).length>8)throw new Error('Invalid working pose.');view.joints={};
    for(const [id,value] of Object.entries(joints)){
      if(!/^(left|right)-(arm|elbow|hip|knee)$/.test(id)||!Array.isArray(value)||value.length!==3||!value.every(n=>typeof n==='number'&&Number.isFinite(n)&&Math.abs(n)<=Math.PI*2))throw new Error('Invalid working joint.');
      view.joints[id]=value as Point3;
    }
  }
  let completed: CompletedReport | undefined;
  if(v.inspection!==undefined) view.inspection=parseInspection(v.inspection,view.layer || 'skin');
  if(v.fingers!==undefined) view.fingers=parseFingerPose(v.fingers);
  if(v.jawOpening!==undefined) view.jawOpening=parseJawOpening(v.jawOpening);
  if (data.completed) {
    const c = object(data.completed), at = boundedText(c.at, 40), caseText = boundedText(c.caseText, MAX_CASE_BYTES);
    if (!Number.isFinite(Date.parse(at)) || new TextEncoder().encode(caseText).byteLength > MAX_CASE_BYTES) throw new Error('Invalid saved report context.');
    object(JSON.parse(caseText));
    completed = { report: parseReport(c.report), model: boundedText(c.model, 120), provider: boundedText(c.provider, 80), caseText, at };
    if(c.imageCount!==undefined){if(!Number.isInteger(c.imageCount)||Number(c.imageCount)<0||Number(c.imageCount)>50)throw new Error('Invalid image count.');completed.imageCount=Number(c.imageCount);}
    if(c.photoCount!==undefined){if(!Number.isInteger(c.photoCount)||Number(c.photoCount)<0||Number(c.photoCount)>(completed.imageCount || 0))throw new Error('Invalid photo count.');completed.photoCount=Number(c.photoCount);}
  }
  const photos = parsePhotos(data.photos, draft.concerns.map(c => c.id));
  const exampleSource = data.exampleSource === undefined ? undefined : boundedText(data.exampleSource, 100);
  return { version: 1, draft, view, completed, photos, ...(exampleSource ? { exampleSource } : {}) };
}
export function sessionString(record: SessionRecord): string { return JSON.stringify(parseSession(JSON.stringify(record))); }