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/photo-import.ts
import { fs } from '@napplet/sdk';
import { MAX_PHOTO_CHARS, type CasePhoto } from './photo-record.js';

const MAX_SOURCE = 10 * 1024 * 1024;
/** The picker grants a temporary host copy. Retain only a bounded, re-encoded copy. */
export async function importPhoto(): Promise<Pick<CasePhoto, 'name' | 'dataUrl' | 'width' | 'height' | 'sha256'>> {
  const picked = await fs.pickFile({ accept: [{ extension: '.jpg' }, { extension: '.jpeg' }, { extension: '.png' }, { extension: '.webp' }], description: 'Choose a photo for your symptom record' });
  const entry = picked.entries[0];
  if (!entry || entry.kind !== 'file') throw new Error('Choose one JPEG, PNG or WebP photo.');
  try {
    if (entry.size !== undefined && entry.size > MAX_SOURCE) throw new Error('Choose a photo smaller than 10 MiB.');
    const parts: Uint8Array<ArrayBuffer>[] = []; let offset = 0;
    for (;;) {
      const part = await fs.read(entry.path, { offset, length: 262144 });
      const raw = atob(part.data), bytes = Uint8Array.from(raw, c => c.charCodeAt(0));
      if (part.offset !== offset || bytes.length !== part.bytesRead || (!bytes.length && !part.eof)) throw new Error('Photo could not be read completely.');
      offset += bytes.length;
      if (offset > MAX_SOURCE) throw new Error('Choose a photo smaller than 10 MiB.');
      parts.push(bytes);
      if (part.eof) break;
    }
    if (entry.size !== undefined && offset !== entry.size) throw new Error('Photo could not be read completely.');
    const first = parts[0] || new Uint8Array();
    const magic = String.fromCharCode(...first.slice(0, 12));
    const mime = first[0] === 255 && first[1] === 216 && first[2] === 255 ? 'image/jpeg' : magic.startsWith('\x89PNG\r\n\x1a\n') ? 'image/png' : magic.startsWith('RIFF') && magic.slice(8) === 'WEBP' ? 'image/webp' : '';
    if (!mime) throw new Error('This file is not a supported JPEG, PNG or WebP photo.');
    const bitmap = await createImageBitmap(new Blob(parts, { type: mime }), { imageOrientation: 'from-image' });
    try {
      if (bitmap.width * bitmap.height > 60_000_000) throw new Error('This photo is too large to process. Choose a smaller copy.');
      const canvas = document.createElement('canvas'), ctx = canvas.getContext('2d');
      if (!ctx) throw new Error('Photo processing is unavailable on this device.');
      for (const edge of [1280, 1024, 768, 640]) {
        const scale = Math.min(1, edge / Math.max(bitmap.width, bitmap.height));
        canvas.width = Math.max(1, Math.round(bitmap.width * scale)); canvas.height = Math.max(1, Math.round(bitmap.height * scale));
        ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
        for (const quality of [.86, .74, .62]) {
          const dataUrl = canvas.toDataURL('image/jpeg', quality);
          if (dataUrl.length <= MAX_PHOTO_CHARS) {
            const sha256 = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(dataUrl))), b => b.toString(16).padStart(2, '0')).join('');
            return { name: entry.name.slice(0, 120) || 'Photo', dataUrl, width: canvas.width, height: canvas.height, sha256 };
          }
        }
      }
      throw new Error('This photo cannot fit the saved-copy limit. Crop it on your device and choose it again.');
    } finally { bitmap.close(); }
  } finally {
    // Remove only the file returned by this picker, never its original or other session files.
    try { await fs.remove(entry.path); } catch { /* Host will discard its temporary copy at teardown. */ }
  }
}