diff --git a/apps/src/components/file-upload.stories.tsx b/apps/src/components/file-upload.stories.tsx new file mode 100644 index 000000000..4c186cb29 --- /dev/null +++ b/apps/src/components/file-upload.stories.tsx @@ -0,0 +1,192 @@ +/** + * Ladle stories for FileUpload component + */ + +import { useState } from 'react'; +import type { Story } from '@ladle/react'; +import { FileUpload } from './file-upload'; +import { introspectFile } from '../lib/shapefile/file-introspection'; + +export const Basic: Story = () => { + const [file, setFile] = useState(null); + + return ( +
+

Basic File Upload

+ + {file && ( +
+ Selected: {file.name} ({file.size} bytes) +
+ )} +
+ ); +}; + +export const WithValidation: Story = () => { + const [file, setFile] = useState(null); + const [error, setError] = useState(); + + const handleFileChange = async (selectedFile: File | null) => { + setFile(selectedFile); + + if (!selectedFile) { + setError(undefined); + return; + } + + try { + const inspection = await introspectFile(selectedFile); + + if (inspection.isEmpty) { + setError('File is empty'); + } else if (!inspection.isZip) { + setError('File must be a ZIP archive'); + } else { + setError(undefined); + } + } catch (err) { + setError('Failed to read file'); + } + }; + + return ( +
+

File Upload with Validation

+

Select a file to validate it's a non-empty ZIP archive.

+ + {file && !error && ( +
+ ✓ Valid ZIP file: {file.name} +
+ )} +
+ ); +}; + +export const WithCustomStyling: Story = () => { + const [file, setFile] = useState(null); + const [error, setError] = useState(); + + const handleFileChange = async (selectedFile: File | null) => { + setFile(selectedFile); + + if (!selectedFile) { + setError(undefined); + return; + } + + // Simulate validation + if (!selectedFile.name.toLowerCase().endsWith('.zip')) { + setError('Please select a ZIP file'); + } else { + setError(undefined); + } + }; + + return ( +
+

Custom Styled File Upload

+ + + {file && !error && ( +
+ Selected: {file.name} +
+ )} +
+ ); +}; + +export const MultipleStates: Story = () => { + return ( +
+

File Upload States

+ +
+

Default State

+ +
+ +
+

Error State

+ +
+ +
+

Disabled State

+ +
+ +
+

With Custom Styling

+ + +
+
+ ); +}; diff --git a/apps/src/components/file-upload.test.tsx b/apps/src/components/file-upload.test.tsx new file mode 100644 index 000000000..9f03f0d7f --- /dev/null +++ b/apps/src/components/file-upload.test.tsx @@ -0,0 +1,139 @@ +/** + * Tests for FileUpload component + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FileUpload } from './file-upload'; + +describe('FileUpload', () => { + it('renders a file input', () => { + render(); + + const input = screen.getByRole('button', { name: /choose file|browse|select/i }); + expect(input).toBeInTheDocument(); + }); + + it('applies accept attribute', () => { + render(); + + const input = document.querySelector('input[type="file"]'); + expect(input).toHaveAttribute('accept', 'application/zip'); + }); + + it('calls onChange when file is selected', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + const file = new File(['content'], 'test.zip', { type: 'application/zip' }); + + render(); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + await user.upload(input, file); + + expect(handleChange).toHaveBeenCalledWith(file); + }); + + it('calls onChange with null when selection is cleared', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + + render(); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + // Simulate clearing the selection + await user.click(input); + // In real browser, user can cancel the file picker + // Simulate by triggering change with no files + Object.defineProperty(input, 'files', { + value: null, + writable: true, + }); + input.dispatchEvent(new Event('change', { bubbles: true })); + + expect(handleChange).toHaveBeenCalledWith(null); + }); + + it('displays error message when error prop is set', () => { + const errorMessage = 'File must be a ZIP archive'; + + render(); + + const error = screen.getByRole('alert'); + expect(error).toHaveTextContent(errorMessage); + }); + + it('sets aria-invalid when error is present', () => { + render(); + + const input = document.querySelector('input[type="file"]'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + }); + + it('links error message with aria-describedby', () => { + render(); + + const input = document.querySelector('input[type="file"]'); + const errorId = input?.getAttribute('aria-describedby'); + + expect(errorId).toBe('test-upload-error'); + + const error = document.getElementById(errorId!); + expect(error).toHaveTextContent('Invalid file'); + }); + + it('applies error className when error is present', () => { + render( + , + ); + + const input = document.querySelector('input[type="file"]'); + expect(input).toHaveClass('base-class', 'error-class'); + }); + + it('does not apply error className when no error', () => { + render( + , + ); + + const input = document.querySelector('input[type="file"]'); + expect(input).toHaveClass('base-class'); + expect(input).not.toHaveClass('error-class'); + }); + + it('applies custom error message className', () => { + render( + , + ); + + const error = screen.getByRole('alert'); + expect(error).toHaveClass('custom-error-message'); + }); + + it('generates unique id when not provided', () => { + const { container } = render(); + + const input = container.querySelector('input[type="file"]'); + expect(input?.id).toMatch(/^file-upload-/); + }); + + it('uses provided id', () => { + render(); + + const input = document.querySelector('input[type="file"]'); + expect(input?.id).toBe('custom-id'); + }); +}); diff --git a/apps/src/components/file-upload.tsx b/apps/src/components/file-upload.tsx new file mode 100644 index 000000000..df523f09c --- /dev/null +++ b/apps/src/components/file-upload.tsx @@ -0,0 +1,125 @@ +/** + * FileUpload component + * + * Wraps native HTML file input with error state management. + * Uses HTML5 Constraint Validation API for error handling. + */ + +import { useRef, useState, type ChangeEvent, type InputHTMLAttributes } from 'react'; + +export interface FileUploadProps extends Omit, 'type' | 'onChange'> { + /** + * Callback when file selection changes + */ + onChange?: (file: File | null) => void; + /** + * Custom error message to display + */ + error?: string; + /** + * Class name for the input element + */ + className?: string; + /** + * Class name for the input when in error state + */ + errorClassName?: string; + /** + * Class name for the error message container + */ + errorMessageClassName?: string; +} + +/** + * File upload component with native HTML input and error state support. + * + * Features: + * - Uses native `` element + * - HTML5 Constraint Validation API for error states + * - Flexible styling via className props + * - Accessible error messages linked via aria-describedby + * + * @example + * ```typescript + * const [error, setError] = useState(); + * + * const handleFileChange = async (file: File | null) => { + * if (!file) { + * setError(undefined); + * return; + * } + * + * try { + * const inspection = await introspectFile(file); + * if (!inspection.isZip) { + * setError('File must be a ZIP archive'); + * } else { + * setError(undefined); + * } + * } catch (err) { + * setError('Failed to read file'); + * } + * }; + * + * + * ``` + */ +export const FileUpload = ({ + onChange, + error, + className = '', + errorClassName = '', + errorMessageClassName = '', + id, + ...props +}: FileUploadProps) => { + const inputRef = useRef(null); + const [inputId] = useState(() => id || `file-upload-${Math.random().toString(36).slice(2)}`); + const errorId = `${inputId}-error`; + + const handleChange = (event: ChangeEvent) => { + const file = event.target.files?.[0] || null; + + if (onChange) { + onChange(file); + } + }; + + // Update custom validity when error changes + if (inputRef.current) { + inputRef.current.setCustomValidity(error || ''); + } + + const hasError = Boolean(error); + const inputClassName = hasError + ? `${className} ${errorClassName}`.trim() + : className; + + return ( +
+ + {hasError && ( + + )} +
+ ); +}; diff --git a/apps/src/lib/shapefile/file-introspection.stories.tsx b/apps/src/lib/shapefile/file-introspection.stories.tsx new file mode 100644 index 000000000..4f6b46133 --- /dev/null +++ b/apps/src/lib/shapefile/file-introspection.stories.tsx @@ -0,0 +1,224 @@ +/** + * Ladle stories for shapefile file upload - exploring native File API capabilities. + */ + +import type { Story } from '@ladle/react'; +import { useState } from 'react'; +import { + introspectFile, + type FileIntrospection +} from './file-introspection'; + +const FileExplorer = () => { + const [file, setFile] = useState(null); + const [introspection, setIntrospection] = useState( + null + ); + + const handleFileChange = async ( + event: React.ChangeEvent + ) => { + const files = event.target.files; + if (!files || files.length === 0) { + setFile(null); + setIntrospection(null); + return; + } + + const selectedFile = files[0]; + setFile(selectedFile); + + const result = await introspectFile(selectedFile); + setIntrospection(result); + }; + + return ( +
+

File API Explorer

+

+ Upload any file to explore what the native File API can tell us +

+ + + + {file && introspection && ( +
+
+

+ File Properties (native File object) +

+ + + + + + + + + + + + + + + + + + + +
+ name: + {file.name}
+ size: + {file.size.toLocaleString()} bytes
+ type: + {file.type || '(empty)'}
+ lastModified: + {new Date(file.lastModified).toLocaleString()}
+
+ +
+

Validation (native introspection)

+ + + + + + + + + + + +
+ Is ZIP: + + + {introspection.isZip ? '✓ Yes' : '✗ No'} + +
+ Is Empty: + + + {introspection.isEmpty ? '✗ Yes' : '✓ No'} + +
+
+ +
+

Magic Bytes (first 16 bytes)

+

+ {introspection.firstBytes} +

+

+ ZIP files start with: 50 4b 03 04 (ASCII "PK..") +

+
+ +
+

Raw Data Preview (first 100 bytes)

+
+							{introspection.rawDataPreview}
+						
+
+
+ )} + +
+

Native capabilities (no libraries):

+
    +
  • + Use native File object directly - no need to copy + properties +
  • +
  • + file.arrayBuffer() - read entire file as binary +
  • +
  • Validate ZIP magic bytes (50 4B 03 04) - no library needed
  • +
  • Check if file is empty (size === 0 or buffer.length === 0)
  • +
  • + MIME type (file.type) is often unreliable - prefer + magic bytes +
  • +
+

What requires a library:

+
    +
  • Parsing ZIP central directory to list file entries
  • +
  • Extracting individual files from ZIP archive
  • +
  • Handling compression (DEFLATE, etc.)
  • +
+
+
+ ); +}; + +export const NativeFileAPI: Story = () => ; + +NativeFileAPI.storyName = 'Shapefile File Loader'; diff --git a/apps/src/lib/shapefile/file-introspection.test.ts b/apps/src/lib/shapefile/file-introspection.test.ts new file mode 100644 index 000000000..169556754 --- /dev/null +++ b/apps/src/lib/shapefile/file-introspection.test.ts @@ -0,0 +1,128 @@ +/** + * Tests for file introspection validation logic. + * + * Focus: Test our validation decisions (isZip, isEmpty) based on byte content. + * Not tested: Browser File API, TextDecoder, or platform string operations. + * + * We mock File to provide controlled byte sequences, then verify our logic + * correctly identifies ZIP magic bytes and empty files. + */ + +import { describe, it, expect } from 'vitest'; +import { introspectFile } from './file-introspection'; + +/** + * Mock File with controlled byte content. + * + * Provides only what our code needs: arrayBuffer() returning specific bytes. + * This isolates our validation logic from platform File API implementation. + */ +const createMockFile = ( + name: string, + content: Uint8Array | string, + type = 'application/octet-stream', +): File => { + const buffer = + typeof content === 'string' + ? new TextEncoder().encode(content).buffer + : content.buffer; + + const file = { + name, + size: buffer.byteLength, + type, + arrayBuffer: async (): Promise => buffer, + } as File; + + return file; +}; + +describe('introspectFile', () => { + describe('validation logic - ZIP detection', () => { + it('should detect ZIP magic bytes: 50 4B 03 04', async () => { + const zipBytes = new Uint8Array([ + 0x50, + 0x4b, + 0x03, + 0x04, + ]); + const file = createMockFile('test.zip', zipBytes); + const result = await introspectFile(file); + + // Our validation logic + expect(result.isZip).toBe(true); + }); + + it('should reject non-ZIP magic bytes', async () => { + const file = createMockFile('test.txt', 'plain text', 'text/plain'); + const result = await introspectFile(file); + + // Our validation logic + expect(result.isZip).toBe(false); + }); + + it('should reject files with incomplete magic bytes', async () => { + const twoBytes = new Uint8Array([0x50, 0x4b]); + const file = createMockFile('tiny.zip', twoBytes); + const result = await introspectFile(file); + + // Our validation logic - need all 4 bytes + expect(result.isZip).toBe(false); + }); + + it('should ignore MIME type and check bytes only', async () => { + // File claims to be ZIP but has wrong bytes + const file = createMockFile('fake.zip', 'not a zip', 'application/zip'); + const result = await introspectFile(file); + + // Our validation logic - MIME type ignored + expect(result.isZip).toBe(false); + }); + }); + + describe('validation logic - empty file detection', () => { + it('should detect empty files', async () => { + const emptyBytes = new Uint8Array([]); + const file = createMockFile('empty.zip', emptyBytes); + const result = await introspectFile(file); + + // Our validation logic + expect(result.isEmpty).toBe(true); + expect(result.isZip).toBe(false); + }); + + it('should not flag non-empty files as empty', async () => { + const someBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); + const file = createMockFile('test.zip', someBytes); + const result = await introspectFile(file); + + // Our validation logic + expect(result.isEmpty).toBe(false); + + }); + }); + + describe('diagnostic data format - platform-dependent', () => { + // These tests verify format/structure but rely on platform APIs + // (TextDecoder, Array.from, string operations) + // We test the contract, not the platform implementation + + it('should return hex string format for firstBytes', async () => { + const data = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); + const file = createMockFile('test.zip', data); + const result = await introspectFile(file); + + // Format: space-separated hex pairs + expect(result.firstBytes).toBe('50 4b 03 04'); + }); + + it('should return string for rawDataPreview', async () => { + const data = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" + const file = createMockFile('test.txt', data); + const result = await introspectFile(file); + + // Returns a string (platform handles decoding) + expect(typeof result.rawDataPreview).toBe('string'); + }); + }); +}); diff --git a/apps/src/lib/shapefile/file-introspection.ts b/apps/src/lib/shapefile/file-introspection.ts new file mode 100644 index 000000000..ac0d257bd --- /dev/null +++ b/apps/src/lib/shapefile/file-introspection.ts @@ -0,0 +1,105 @@ +/** + * Native file introspection utilities. + * + * Validates file type and basic properties using only native File API. + * No external libraries required. + */ + +/** + * Results from native file introspection. + */ +export interface FileIntrospection { + /** + * Whether file has valid ZIP magic bytes (50 4B 03 04) + */ + isZip: boolean; + + /** + * Whether file is empty (zero bytes) + */ + isEmpty: boolean; + + /** + * First 16 bytes as hex string (for diagnostics) + */ + firstBytes: string; + + /** + * Raw data preview (first 100 bytes, escaped) + */ + rawDataPreview: string; +} + +/** + * Introspect file using only native File API. + * + * This function performs basic validation without external libraries: + * - Checks ZIP magic bytes (50 4B 03 04 = "PK..") + * - Detects empty files + * - Provides diagnostic data for debugging + * + * Use this for initial file type validation before passing to + * libraries like JSZip for actual extraction. + * + * @param file - File to introspect + * @returns Introspection results with validation status + * + * @example + * ```typescript + * const file = fileInput.files[0]; + * const info = await introspectFile(file); + * + * if (!info.isZip) { + * throw new Error('File must be a ZIP archive'); + * } + * + * if (info.isEmpty) { + * throw new Error('File is empty'); + * } + * ``` + */ +export const introspectFile = async ( + file: File, +): Promise => { + const arrayBuffer = await file.arrayBuffer(); + const bytes = new Uint8Array(arrayBuffer); + + // Check if file is empty + const isEmpty = bytes.length === 0; + + // Read first 16 bytes for magic byte detection + const headerBytes = bytes.slice(0, 16); + const firstBytes = Array.from(headerBytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(' '); + + // ZIP magic bytes: 50 4B 03 04 ("PK" for Phil Katz, creator of PKZIP) + const isZip = + bytes.length >= 4 && + bytes[0] === 0x50 && + bytes[1] === 0x4b && + bytes[2] === 0x03 && + bytes[3] === 0x04; + + // Raw data preview for visualization - escape non-printable characters + const decoder = new TextDecoder('utf-8', { fatal: false }); + const previewBytes = bytes.slice(0, 100); + const textPreview = decoder.decode(previewBytes); + const rawDataPreview = textPreview + .split('') + .map((char) => { + const code = char.charCodeAt(0); + // Non-printable characters (below space or above tilde) + return code < 32 || code > 126 + ? `\\x${code.toString(16).padStart(2, '0')}` + : char; + }) + .join(''); + + return { + isZip, + isEmpty, + firstBytes, + rawDataPreview, + }; +}; diff --git a/apps/src/lib/shapefile/file-loader.test.ts b/apps/src/lib/shapefile/file-loader.test.ts new file mode 100644 index 000000000..25e36daa2 --- /dev/null +++ b/apps/src/lib/shapefile/file-loader.test.ts @@ -0,0 +1,191 @@ +/** + * Tests for shapefile file loading and extraction. + * + * These tests verify our extraction logic and error handling, + * not the browser File API. We mock File.arrayBuffer() to test + * our code's behavior with different inputs. + */ + +import { describe, it, expect } from 'vitest'; +import JSZip from 'jszip'; +import { + loadShapeFile, + ShapefileLoadError, +} from './file-loader'; + +/** + * Create a mock File with controlled arrayBuffer() response. + * + * This allows us to test our JSZip integration and validation + * logic without relying on browser File API implementation. + */ +const createMockFile = ( + name: string, + content: ArrayBuffer | string, + type = 'application/zip', +): File => { + const buffer = + typeof content === 'string' + ? new TextEncoder().encode(content).buffer + : content; + + const file = { + name, + size: buffer.byteLength, + type, + arrayBuffer: async (): Promise => buffer, + } as File; + + return file; +};/** + * Create a valid ZIP file containing .shp and .prj files + */ +const createValidShapefileZip = async (): Promise => { + const zip = new JSZip(); + + // Mock binary .shp file + const shpData = new Uint8Array([ + 0x00, 0x00, 0x27, 0x0a, // File code + 0x00, 0x00, 0x00, 0x00, // Unused + ]); + zip.file('test.shp', shpData); + + // Mock .prj file (WGS84 projection) + const prjData = 'GEOGCS["WGS 84",DATUM["WGS_1984"]]'; + zip.file('test.prj', prjData); + + const zipBlob = await zip.generateAsync({ type: 'arraybuffer' }); + return createMockFile('test.zip', zipBlob); +}; + +describe('loadShapeFile', () => { + describe('happy path', () => { + it('should extract .shp and .prj files from valid ZIP', async () => { + const file = await createValidShapefileZip(); + const result = await loadShapeFile(file); + + expect(result).toHaveProperty('file.shp'); + expect(result).toHaveProperty('file.prj'); + expect(result['file.shp']).toBeInstanceOf(ArrayBuffer); + expect(typeof result['file.prj']).toBe('string'); + }); + + it('should ignore other files in ZIP (dbf, shx, etc)', async () => { + const zip = new JSZip(); + zip.file('test.shp', new Uint8Array([0x00])); + zip.file('test.prj', 'GEOGCS["WGS 84"]'); + zip.file('test.dbf', 'ignored data'); + zip.file('test.shx', 'ignored data'); + zip.file('readme.txt', 'ignored data'); + + const zipBlob = await zip.generateAsync({ type: 'arraybuffer' }); + const file = createMockFile('test.zip', zipBlob); + + const result = await loadShapeFile(file); + + // Should only have .shp and .prj + expect(Object.keys(result)).toHaveLength(2); + expect(result).toHaveProperty('file.shp'); + expect(result).toHaveProperty('file.prj'); + }); + }); + + describe('error cases', () => { + it('should reject non-ZIP files', async () => { + const file = createMockFile( + 'test.txt', + 'not a zip', + 'text/plain', + ); + + await expect(loadShapeFile(file)) + .rejects + .toThrow(ShapefileLoadError); + + await expect(loadShapeFile(file)) + .rejects + .toThrow('must be a ZIP file'); + }); + + it('should reject ZIP without .shp file', async () => { + const zip = new JSZip(); + zip.file('test.prj', 'GEOGCS["WGS 84"]'); + zip.file('readme.txt', 'data'); + + const zipBlob = await zip.generateAsync({ type: 'arraybuffer' }); + const file = createMockFile('test.zip', zipBlob); + + await expect(loadShapeFile(file)) + .rejects + .toThrow(ShapefileLoadError); + + await expect(loadShapeFile(file)) + .rejects + .toThrow('must contain both .shp and .prj'); + }); + + it('should reject ZIP without .prj file', async () => { + const zip = new JSZip(); + zip.file('test.shp', new Uint8Array([0x00])); + zip.file('readme.txt', 'data'); + + const zipBlob = await zip.generateAsync({ type: 'arraybuffer' }); + const file = createMockFile('test.zip', zipBlob); + + await expect(loadShapeFile(file)) + .rejects + .toThrow(ShapefileLoadError); + + await expect(loadShapeFile(file)) + .rejects + .toThrow('must contain both .shp and .prj'); + }); + + it('should reject empty ZIP', async () => { + const zip = new JSZip(); + const zipBlob = await zip.generateAsync({ type: 'arraybuffer' }); + const file = createMockFile('empty.zip', zipBlob); + + await expect(loadShapeFile(file)) + .rejects + .toThrow(ShapefileLoadError); + + await expect(loadShapeFile(file)) + .rejects + .toThrow('does not contain a valid shapefile'); + }); + + it('should reject invalid ZIP data', async () => { + const file = createMockFile( + 'corrupt.zip', + 'not valid zip data', + ); + + await expect(loadShapeFile(file)) + .rejects + .toThrow(ShapefileLoadError); + + await expect(loadShapeFile(file)) + .rejects + .toThrow('Failed to read ZIP file'); + }); + }); + + describe('ShapefileLoadError', () => { + it('should have correct name for debugging', () => { + const error = new ShapefileLoadError('test error'); + expect(error.name).toBe('ShapefileLoadError'); + expect(error.message).toBe('test error'); + }); + + it('should support error chaining', () => { + const cause = new Error('underlying error'); + const error = new ShapefileLoadError( + 'wrapper error', + { cause }, + ); + + expect(error.cause).toBe(cause); + }); + }); +}); diff --git a/apps/src/lib/shapefile/file-loader.ts b/apps/src/lib/shapefile/file-loader.ts new file mode 100644 index 000000000..f94ae5c0e --- /dev/null +++ b/apps/src/lib/shapefile/file-loader.ts @@ -0,0 +1,163 @@ +/** + * Shapefile file loading and extraction utilities. + * + * Extracts .shp (binary geometry) and .prj (projection) files from ZIP archives. + */ + +import JSZip from 'jszip'; +import { introspectFile } from './file-introspection'; + +/** + * Extracted shapefile data. + */ +export interface ShapefileData { + /** + * Binary shapefile geometry data + */ + 'file.shp': ArrayBuffer; + /** + * Projection definition string + */ + 'file.prj': string; +} + +/** + * Custom error for shapefile loading failures. + */ +export class ShapefileLoadError extends Error { + constructor( + message: string, + options?: ErrorOptions, + ) { + super(message, options); + // Explicit name - survives minification + this.name = 'ShapefileLoadError'; + } +} + +/** + * Validates extracted data contains required files + * + * @param data - Extracted shapefile data + * @throws {ShapefileLoadError} If required files are missing + */ +const assertHasRequiredFiles = ( + data: Partial, +): asserts data is ShapefileData => { + if (!data['file.shp'] || !data['file.prj']) { + throw new ShapefileLoadError( + 'ZIP file must contain both .shp and .prj files', + ); + } +}; + +/** + * Load and extract shapefile data from a ZIP file. + * + * Validation steps: + * 1. Verify file is not empty (native introspection) + * 2. Verify ZIP magic bytes (native introspection) + * 3. Extract .shp and .prj files with JSZip + * + * A valid shapefile ZIP must contain: + * - `.shp` file: Binary geometry data + * - `.prj` file: Projection definition + * + * Other files in the ZIP are ignored to minimize data exposure. + * + * @param file - ZIP file containing shapefile data + * @returns Promise resolving to extracted shapefile data + * @throws {ShapefileLoadError} If file is invalid or missing required files + * + * @example + * ```typescript + * const fileInput = document.querySelector('input[type="file"]'); + * const file = fileInput.files[0]; + * + * try { + * const data = await loadShapeFile(file); + * console.log('Loaded shapefile:', data); + * } catch (error) { + * if (error instanceof ShapefileLoadError) { + * console.error('Invalid shapefile:', error.message); + * } + * } + * ``` + */ +export const loadShapeFile = async ( + file: File, +): Promise => { + // Step 1-2: Validate file content (not empty + ZIP magic bytes) + const inspection = await introspectFile(file); + + if (inspection.isEmpty) { + throw new ShapefileLoadError( + 'File is empty', + ); + } + + if (!inspection.isZip) { + throw new ShapefileLoadError( + 'File is not a valid ZIP archive (invalid magic bytes)', + if (!inspection.isZip) { + throw new ShapefileLoadError( + 'File is not a valid ZIP archive (invalid magic bytes)', + ); + } + + // Step 3: Extract files with JSZip + try { + const data: Partial = {}; + const promises: Promise[] = []; + + // Extract only .shp and .prj files + for (const filename in zip.files) { + const lowerName = filename.toLowerCase(); + const parts = lowerName.split('.'); + const extension = parts.pop(); + + if (extension === 'shp') { + const promise = zip.files[filename] + .async('arraybuffer') + .then((buffer) => { + data['file.shp'] = buffer; + }); + promises.push(promise); + } else if (extension === 'prj') { + const promise = zip.files[filename] + .async('string') + .then((content) => { + data['file.prj'] = content; + }); + promises.push(promise); + } + } + + if (promises.length === 0) { + throw new ShapefileLoadError( + 'ZIP file does not contain a valid shapefile', + ); + } + + await Promise.all(promises); + assertHasRequiredFiles(data); + + return data; + } catch (error) { + if (error instanceof ShapefileLoadError) { + throw error; + } + + // JSZip or other errors + if (error instanceof Error) { + throw new ShapefileLoadError( + 'Failed to read ZIP file', + { cause: error }, + ); + } + + throw new ShapefileLoadError( + 'An unexpected error occurred while loading shapefile', + ); + } +}; diff --git a/apps/src/lib/shapefile/index.ts b/apps/src/lib/shapefile/index.ts new file mode 100644 index 000000000..17f0febc2 --- /dev/null +++ b/apps/src/lib/shapefile/index.ts @@ -0,0 +1,2 @@ +export * from './file-introspection'; +export * from './file-loader';