diff --git a/src/csv/__tests__/csvExportUtil.test.ts b/src/csv/__tests__/csvExportUtil.test.ts index b4e4629..8e439ed 100644 --- a/src/csv/__tests__/csvExportUtil.test.ts +++ b/src/csv/__tests__/csvExportUtil.test.ts @@ -1,3 +1,4 @@ +import Rowset from "@/sheets/types/Rowset"; import { COMMA, rowArrayToCsvUnicode, rowArrayToCsvUtf8, TAB } from "../csvExportUtil"; describe('csvExportUtil', () => { @@ -29,7 +30,7 @@ describe('csvExportUtil', () => { }); it('throws if fieldNames is empty', () => { - const rowArray:any[][] = []; + const rowArray:Rowset = []; const fieldNames:string[] = []; expect(() => rowArrayToCsvUnicode(rowArray, fieldNames, false)).toThrow(); }); @@ -37,7 +38,7 @@ describe('csvExportUtil', () => { describe('datasets with no rows', () => { it('encodes an empty string when addHeader is false and row array is empty', () => { - const rowArray:any[][] = []; + const rowArray:Rowset = []; const fieldNames = ['one', 'two', 'three']; const addHeaders = false; const expected = ''; @@ -45,7 +46,7 @@ describe('csvExportUtil', () => { }); it('encodes an array of header names when addHeader is true and row array is empty', () => { - const rowArray:any[][] = []; + const rowArray:Rowset = []; const fieldNames = ['one', 'two', 'three']; const addHeaders = true; const expected = 'one\ttwo\tthree\r\n'; @@ -97,7 +98,7 @@ describe('csvExportUtil', () => { describe('headings', () => { it('encodes a heading preserving lower and upper case', () => { - const rowArray:any[][] = []; + const rowArray:Rowset = []; const fieldNames = ['One']; const addHeaders = true; const expected = 'One\r\n'; @@ -105,7 +106,7 @@ describe('csvExportUtil', () => { }); it('encodes a heading preserving interior whitespace', () => { - const rowArray:any[][] = []; + const rowArray:Rowset = []; const fieldNames = ['One Two']; const addHeaders = true; const expected = `One Two\r\n`; @@ -113,7 +114,7 @@ describe('csvExportUtil', () => { }); it('encodes a heading trimming leading whitespace', () => { - const rowArray:any[][] = []; + const rowArray:Rowset = []; const fieldNames = [' One']; const addHeaders = true; const expected = 'One\r\n'; @@ -121,7 +122,7 @@ describe('csvExportUtil', () => { }); it('encodes a heading trimming trailing whitespace', () => { - const rowArray:any[][] = []; + const rowArray:Rowset = []; const fieldNames = ['One ']; const addHeaders = true; const expected = 'One\r\n'; @@ -129,7 +130,7 @@ describe('csvExportUtil', () => { }); it('encodes a heading preserving non-alphanumeric ASCII characters', () => { - const rowArray:any[][] = []; + const rowArray:Rowset = []; const fieldNames = ['One!@#$%^&*()_+']; const addHeaders = true; const expected = `One!@#$%^&*()_+\r\n`; @@ -137,7 +138,7 @@ describe('csvExportUtil', () => { }); it('encodes a heading preserving quote characters', () => { - const rowArray:any[][] = []; + const rowArray:Rowset = []; const fieldNames = ['One"']; const addHeaders = true; const expected = `"One"""\r\n`; @@ -145,7 +146,7 @@ describe('csvExportUtil', () => { }); it('encodes a heading preserving field delimiter characters when delimiter is comma', () => { - const rowArray:any[][] = []; + const rowArray:Rowset = []; const fieldNames = ['One,']; const addHeaders = true; const expected = `"One,"\r\n`; @@ -153,7 +154,7 @@ describe('csvExportUtil', () => { }); it('encodes a heading preserving field delimiter characters when delimiter is tab', () => { - const rowArray:any[][] = []; + const rowArray:Rowset = []; const fieldNames = ['One\tTwo']; const addHeaders = true; const expected = `"One\tTwo"\r\n`; @@ -161,7 +162,7 @@ describe('csvExportUtil', () => { }); it('encodes a heading preserving row delimeter characters', () => { - const rowArray:any[][] = []; + const rowArray:Rowset = []; const fieldNames = ['One\rTwo\nThree\r\nFour']; const addHeaders = true; const expected = `"One\rTwo\nThree\r\nFour"\r\n`; @@ -169,7 +170,7 @@ describe('csvExportUtil', () => { }); it('encodes a heading preserving Unicode (non-ASCII) characters', () => { - const rowArray:any[][] = []; + const rowArray:Rowset = []; const fieldNames = ['One😀']; const addHeaders = true; const expected = 'One😀\r\n'; diff --git a/src/csv/csvExportUtil.ts b/src/csv/csvExportUtil.ts index f1845b2..9c0ab01 100644 --- a/src/csv/csvExportUtil.ts +++ b/src/csv/csvExportUtil.ts @@ -1,4 +1,5 @@ import { encodeUtf8 } from "@/common/stringUtil"; +import Rowset from "@/sheets/types/Rowset"; export const TAB = '\t'; export const COMMA = ','; @@ -50,11 +51,11 @@ function _cellValue(value:any, fieldDelimiter:string):string { return value.toString(); } -function _doAnyRowsHaveDifferentFieldCount(rowArray:any[][], fieldCount:number):boolean { +function _doAnyRowsHaveDifferentFieldCount(rowArray:Rowset, fieldCount:number):boolean { return rowArray.some(row => row.length !== fieldCount); } -export function rowArrayToCsvUnicode(rowArray:any[][], fieldNames:string[], addHeaders:boolean, fieldDelimiter:string = DEFAULT_FIELD_DELIMITER):string { +export function rowArrayToCsvUnicode(rowArray:Rowset, fieldNames:string[], addHeaders:boolean, fieldDelimiter:string = DEFAULT_FIELD_DELIMITER):string { let csv:string = addHeaders ? _concatHeaderRow(fieldNames, fieldDelimiter) : ''; if (fieldNames.length === 0) throw new Error('fieldNames must have at least one element.'); if (_doAnyRowsHaveDifferentFieldCount(rowArray, fieldNames.length)) throw new Error('All rows must have the same number of fields as fieldNames.'); @@ -67,7 +68,7 @@ export function rowArrayToCsvUnicode(rowArray:any[][], fieldNames:string[], addH return csv; } -export function rowArrayToCsvUtf8(rowArray:any[][], fieldNames:string[], addHeaders:boolean, fieldDelimiter:string = DEFAULT_FIELD_DELIMITER):Uint8Array { +export function rowArrayToCsvUtf8(rowArray:Rowset, fieldNames:string[], addHeaders:boolean, fieldDelimiter:string = DEFAULT_FIELD_DELIMITER):Uint8Array { let csvUnicode:string = rowArrayToCsvUnicode(rowArray, fieldNames, addHeaders, fieldDelimiter); return encodeUtf8(csvUnicode); } \ No newline at end of file diff --git a/src/csv/csvImportUtil.ts b/src/csv/csvImportUtil.ts index 766bf3f..8df1d76 100644 --- a/src/csv/csvImportUtil.ts +++ b/src/csv/csvImportUtil.ts @@ -1,5 +1,7 @@ -import { decodeUtf8, fillTemplate } from "@/common/stringUtil"; +import { decodeUtf8 } from "@/common/stringUtil"; import AppException from "@/common/types/AppException"; +import Row from "@/sheets/types/Row"; +import Rowset from "@/sheets/types/Rowset"; export enum CvsImportErrorType { NO_DATA = 'CvsImportError-NO_DATA', @@ -193,7 +195,7 @@ function _fieldTextToValue(text:string, rowNo:number):any { return _fieldTextToString(text, rowNo); } -function _parseCsvRow(row:string, fieldDelimiter:string, rowNo:number):any[] { +function _parseCsvRow(row:string, fieldDelimiter:string, rowNo:number):Row { // Similar to splitting rows, I'll split fields first by the delimiter because it's fast, and then fix mistakes, if any. let fields = row.split(fieldDelimiter); let wereAnyFieldsRemoved = false; @@ -219,7 +221,7 @@ function _valueToString(value:any):string { return (value === null) ? '' : value.toString(); } -function _parseHeaderRow(headerLine:string, fieldDelimiter:string):any[] { +function _parseHeaderRow(headerLine:string, fieldDelimiter:string):Row { const row = _parseCsvRow(headerLine, fieldDelimiter, 1); for(let fieldI = 0; fieldI < row.length; ++fieldI) { if (typeof row[fieldI] !== 'string') row[fieldI] = _valueToString(row[fieldI]); // Be forgiving and try to preserve intent. @@ -258,12 +260,12 @@ function _generateHeaderRow(fieldCount:number):string[] { } // Can throw CvsImportError.NO_DATA, FIELD_COUNT_MISMATCH, UNSTRUCTURED_DATA, TOO_MANY_FIELDS -export function csvUnicodeToRowArray(csvUnicode:string, includeHeaders:boolean):any[][] { +export function csvUnicodeToRowArray(csvUnicode:string, includeHeaders:boolean):Rowset { if (csvUnicode.trim() === '') throw new AppException(CvsImportErrorType.NO_DATA, 'No data found in CSV text.'); const lines = _splitCsvLines(csvUnicode); const fieldDelimiter = _findFieldDelimiter(lines); - const rows:any[][] = []; + const rows:Rowset = []; let fromRowI = 0; const fieldCount = _countFieldsInLine(lines[0], fieldDelimiter); @@ -285,7 +287,7 @@ export function csvUnicodeToRowArray(csvUnicode:string, includeHeaders:boolean): } // Can throw CvsImportError.NO_DATA, FIELD_COUNT_MISMATCH, UNSTRUCTURED_DATA, TOO_MANY_FIELDS -export function csvUtf8ToRowArray(csvBytes:Uint8Array, includesHeaders:boolean):any[][] { +export function csvUtf8ToRowArray(csvBytes:Uint8Array, includesHeaders:boolean):Rowset { const csvUnicode = decodeUtf8(csvBytes); return csvUnicodeToRowArray(csvUnicode, includesHeaders); } \ No newline at end of file diff --git a/src/homeScreen/HomeScreen.tsx b/src/homeScreen/HomeScreen.tsx index 05985c5..7c04f30 100644 --- a/src/homeScreen/HomeScreen.tsx +++ b/src/homeScreen/HomeScreen.tsx @@ -1,12 +1,11 @@ import { useEffect, useState } from "react"; -import { WorkBook } from 'xlsx'; import styles from './HomeScreen.module.css'; import { init } from "./interactions/initialization"; import ToastPane from "@/components/toasts/ToastPane"; import SheetPane from "./SheetPane"; import ImportSheetDialog from "./dialogs/ImportSheetDialog"; -import { importSheet, onCancelImportSheet, onChangeWorkbook, onSelectSheet } from "./interactions/import"; +import { importSheet, onSelectSheet } from "./interactions/import"; import PromptPane from "./PromptPane"; import HoneSheet from "@/sheets/types/HoneSheet"; import ExecuteSetupDialog from "./dialogs/ExecuteSetupDialog"; @@ -27,9 +26,8 @@ import ExportOptionsDialog from "./dialogs/ExportOptionsDialog"; import ImportOptionsDialog from "./dialogs/ImportOptionsDialog"; function HomeScreen() { - const [workbook, setWorkbook] = useState(null); - const [, setWorkbookName] = useState(''); // TODO - use workbookName later for export. const [sheet, setSheet] = useState(null); + const [availableSheets, setAvailableSheets] = useState([]); const [selectedRowNo, setSelectedRowNo] = useState(1); const [job, setJob] = useState(null); const [modalDialog, setModalDialog] = useState(null); @@ -52,18 +50,16 @@ function HomeScreen() {

Hone

setModalDialog(ImportOptionsDialog.name)} - onChangeWorkbook={(nextWorkbook, nextWorkbookName) => onChangeWorkbook(nextWorkbook, nextWorkbookName, - setWorkbook, setWorkbookName, setSheet, setModalDialog)} onExportSheet={() => chooseExportType(setModalDialog)} /> {promptPaneContent} - onSelectSheet(sheet, setSheet, setModalDialog)} - onCancel={() => onCancelImportSheet(setWorkbook, setWorkbookName, setSheet, setModalDialog)} + onSelectSheet(sheet, setAvailableSheets, setSheet, setModalDialog)} + onCancel={() => setModalDialog(null)} /> importSheet(importOptions, setSheet, setModalDialog)} + isOpen={modalDialog === ImportOptionsDialog.name} sheet={sheet} + onImport={(importOptions) => importSheet(importOptions, setAvailableSheets, setSheet, setModalDialog)} onCancel={() => setModalDialog(null)} /> diff --git a/src/homeScreen/PromptOutputRow.tsx b/src/homeScreen/PromptOutputRow.tsx index 83b897d..0a06ed9 100644 --- a/src/homeScreen/PromptOutputRow.tsx +++ b/src/homeScreen/PromptOutputRow.tsx @@ -4,6 +4,7 @@ import HoneColumn from "@/sheets/types/HoneColumn"; import HoneSheet from "@/sheets/types/HoneSheet"; import styles from './PromptOutputRow.module.css'; import GeneratedText from "@/components/generatedText/GeneratedText"; +import Row from "@/sheets/types/Row"; type Props = { sheet:HoneSheet; @@ -15,7 +16,7 @@ function _tableHeaderContent(columns:HoneColumn[]) { return #{columns.map((column, i) => {column.name})}; } -function _tableBodyContent(row:any[], rowNo:number) { +function _tableBodyContent(row:Row, rowNo:number) { const cells = row.map((cell:any, columnI:number) => { const cellValue = (columnI === row.length - 1) ? : '' + cell; return ({cellValue}); @@ -25,7 +26,7 @@ function _tableBodyContent(row:any[], rowNo:number) { function PromptOutputRow({sheet, rowNo, outputValue}: Props) { const [columns, setColumns] = useState([]); - const [row, setRow] = useState([]); + const [row, setRow] = useState([]); useEffect(() => { if (!sheet) { setColumns([]); setRow([]); return; } diff --git a/src/homeScreen/SheetPane.tsx b/src/homeScreen/SheetPane.tsx index fb35c55..9a7bdbe 100644 --- a/src/homeScreen/SheetPane.tsx +++ b/src/homeScreen/SheetPane.tsx @@ -1,19 +1,13 @@ -import { WorkBook } from "xlsx"; -import { useEffect } from "react"; - import SheetView from "./SheetView"; -import { errorToast } from "@/components/toasts/toastUtil"; import Pane, { ButtonDefinition } from "@/components/pane/Pane"; import { getComment } from "./interactions/comment"; import HoneSheet from "@/sheets/types/HoneSheet"; type Props = { sheet: HoneSheet|null, - workbook: WorkBook|null, className:string, selectedRowNo:number, onRowSelect:(rowNo:number)=>void - onChangeWorkbook(workbook:WorkBook, workbookName:string):void, onExportSheet():void, onImportSheet():void } @@ -27,12 +21,7 @@ function _sheetContent(sheet:HoneSheet|null, selectedRowNo:number, onRowSelect:( return ; } -function SheetPane({workbook, sheet, className, onImportSheet, selectedRowNo, onRowSelect, onExportSheet}:Props) { - useEffect(() => { - if (!workbook) return; - if (!workbook.SheetNames.length) errorToast('Workbook has no sheets.'); - }, [workbook]); - +function SheetPane({sheet, className, onImportSheet, selectedRowNo, onRowSelect, onExportSheet}:Props) { const content = _sheetContent(sheet, selectedRowNo, onRowSelect); const buttons:ButtonDefinition[] = [ diff --git a/src/homeScreen/SheetView.tsx b/src/homeScreen/SheetView.tsx index 4165796..68e62b5 100644 --- a/src/homeScreen/SheetView.tsx +++ b/src/homeScreen/SheetView.tsx @@ -5,6 +5,7 @@ import HoneSheet from "@/sheets/types/HoneSheet"; import HoneColumn from "@/sheets/types/HoneColumn"; import { getSheetRows } from "@/sheets/sheetUtil"; import GeneratedText from "@/components/generatedText/GeneratedText"; +import Rowset from "@/sheets/types/Rowset"; type Props = { sheet: HoneSheet, @@ -19,7 +20,7 @@ function _tableHeaderContent(columns:HoneColumn[]) { return #{columns.map((column, i) => {column.name})}; } -function _tableBodyContent(rows:any[][], generatingColumnI:number, selectedRowNo?:number, onRowSelect?:(rowNo:number)=>void) { +function _tableBodyContent(rows:Rowset, generatingColumnI:number, selectedRowNo?:number, onRowSelect?:(rowNo:number)=>void) { return rows.map( (row:any, rowI:number) => { const isSelected = (selectedRowNo === rowI+1); diff --git a/src/homeScreen/dialogs/ImportOptionsDialog.tsx b/src/homeScreen/dialogs/ImportOptionsDialog.tsx index e3510a0..b53de3f 100644 --- a/src/homeScreen/dialogs/ImportOptionsDialog.tsx +++ b/src/homeScreen/dialogs/ImportOptionsDialog.tsx @@ -52,7 +52,7 @@ function ImportOptionsDialog({isOpen, onImport, onCancel, sheet}:Props) { const importButtonName = IMPORT_BUTTON_NAMES[importOptions.importType]; const sheetNameInput = importOptions.importType !== ImportType.CLIPBOARD ? null : setImportOptions({...importOptions, sheetName})} />; - const useFirstRowColumnNames = importOptions.importType === ImportType.EXAMPLE ? null + const useFirstRowColumnNames = importOptions.importType === ImportType.EXAMPLE || importOptions.importType === ImportType.EXCEL ? null : setImportOptions({...importOptions, useFirstRowColumnNames}) } /> const importOptionsSection = sheetNameInput === null && useFirstRowColumnNames === null ? null diff --git a/src/homeScreen/dialogs/ImportSheetDialog.tsx b/src/homeScreen/dialogs/ImportSheetDialog.tsx index 9bd14f0..a9aa607 100644 --- a/src/homeScreen/dialogs/ImportSheetDialog.tsx +++ b/src/homeScreen/dialogs/ImportSheetDialog.tsx @@ -1,5 +1,4 @@ -import { WorkBook } from 'xlsx'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import ModalDialog from '@/components/modalDialogs/ModalDialog'; import DialogFooter from '@/components/modalDialogs/DialogFooter'; @@ -7,40 +6,37 @@ import DialogButton from '@/components/modalDialogs/DialogButton'; import SheetSelector from './SheetSelector'; import SheetView from '../SheetView'; import HoneSheet from '@/sheets/types/HoneSheet'; -import { createHoneSheet } from '@/sheets/sheetUtil'; type Props = { - workbook:WorkBook|null, + availableSheets:HoneSheet[], isOpen:boolean, onChoose(sheet:HoneSheet):void, onCancel():void } -function ImportSheetDialog({workbook, isOpen, onChoose, onCancel}:Props) { +function ImportSheetDialog({availableSheets, isOpen, onChoose, onCancel}:Props) { const [selectedSheetName, setSelectedSheetName] = useState(null); - const [selectedSheet, setSelectedSheet] = useState(); useEffect(() => { if (!isOpen) { setSelectedSheetName(''); return; } - if (!workbook || !workbook.SheetNames.length) throw Error('Unexpected'); - setSelectedSheetName(workbook.SheetNames[0]); - }, [isOpen, workbook]); + if (!availableSheets.length) throw Error('Unexpected'); + setSelectedSheetName(availableSheets[0].name); + }, [isOpen, availableSheets]); - useEffect(() => { - if (!workbook || !selectedSheetName) return; - const nextSheet = createHoneSheet(workbook, selectedSheetName); - setSelectedSheet(nextSheet); - }, [selectedSheetName, workbook]); + const selectedSheet:HoneSheet|null = useMemo( + () => availableSheets.find(sheet => sheet.name === selectedSheetName) ?? null, [availableSheets, selectedSheetName]); - const sheetPreview = selectedSheet ? : null; + const availableSheetNames = useMemo(() => availableSheets.map(sheet => sheet.name), [availableSheets]); + + if (!isOpen || !selectedSheet) return null; return ( - - {sheetPreview} + + - { if (selectedSheet) onChoose(selectedSheet)}} isPrimary disabled={selectedSheet===null}/> + { if (selectedSheet) onChoose(selectedSheet)}} isPrimary/> ); diff --git a/src/homeScreen/interactions/comment.ts b/src/homeScreen/interactions/comment.ts index 8b836bc..bbbf3f6 100644 --- a/src/homeScreen/interactions/comment.ts +++ b/src/homeScreen/interactions/comment.ts @@ -2,7 +2,7 @@ import { doesSheetHaveWritableColumns } from "@/sheets/sheetUtil"; import HoneSheet from "@/sheets/types/HoneSheet"; export function getComment(sheet:HoneSheet|null) { - if (!sheet) return "Import your own sheet or use the example sheet to play with something."; + if (!sheet) return "Import your own sheet or example data."; if (!doesSheetHaveWritableColumns(sheet)) return "You can add new prompt-generated columns to this sheet."; return "When you're happy with your sheet, you can download it."; } \ No newline at end of file diff --git a/src/homeScreen/interactions/import.ts b/src/homeScreen/interactions/import.ts index b58f902..24f55c9 100644 --- a/src/homeScreen/interactions/import.ts +++ b/src/homeScreen/interactions/import.ts @@ -1,13 +1,11 @@ -import { read, WorkBook } from 'xlsx'; - import { MIMETYPE_CSV, MIMETYPE_TSV, MIMETYPE_XLS, MIMETYPE_XLSX } from "@/persistence/mimeTypes"; import { errorToast } from '@/components/toasts/toastUtil'; import { baseUrl } from '@/common/urlUtil'; -import ImportSheetDialog from '../dialogs/ImportSheetDialog'; +import ImportSheetDialog from '@/homeScreen/dialogs/ImportSheetDialog'; import HoneSheet from '@/sheets/types/HoneSheet'; -import ImportOptions from '../types/ImportOptions'; -import ImportType from '../types/ImportType'; -import { importSheetFromClipboard, importSheetFromCsvFile, SheetErrorType } from '@/sheets/sheetUtil'; +import ImportOptions from '@/homeScreen/types/ImportOptions'; +import ImportType from '@/homeScreen/types/ImportType'; +import { importSheetFromClipboard, importSheetFromCsvFile, importSheetsFromXlsBytes, importSheetsFromXlsFile, SheetErrorType } from '@/sheets/sheetUtil'; import { CvsImportErrorType, MAX_FIELD_COUNT } from '@/csv/csvImportUtil'; async function _selectExcelFileHandle():Promise { @@ -50,56 +48,9 @@ async function _selectCsvFileHandle():Promise { } } -function _filenameToWorkbookName(filename:string):string { - const parts = filename.split('.'); - parts.pop(); - return parts.join('.'); -} - -export async function importWorkbook(onChangeWorkbook:Function):Promise { - const fileHandle = await _selectExcelFileHandle(); - if (!fileHandle) return; - try { - const file = await fileHandle.getFile(); - const blob = await file.arrayBuffer(); - const data = new Uint8Array(blob); - const workbook:WorkBook = read(data, {type: 'array'}); - const workbookName = _filenameToWorkbookName(file.name); - onChangeWorkbook(workbook, workbookName); - } catch(e) { - console.error(e); - errorToast('Failed to import workbook from provided file.'); - } -} - -export async function importExample(onChangeWorkbook:Function):Promise { - try { - const response = await fetch(baseUrl('/example/Examples.xlsx')); - const data = await response.arrayBuffer(); - const workbook:WorkBook = read(new Uint8Array(data), {type: 'array'}); - onChangeWorkbook(workbook, 'Example'); - } catch(e) { - console.error(e); - errorToast('Failed to import example workbook.'); - } -} - -export function onChangeWorkbook(workbook:WorkBook, workbookName:string, setWorkbook:Function, setWorkbookName:Function, setSelectedSheet:Function, setModalDialog:Function) { - setWorkbook(workbook); - setWorkbookName(workbookName); - setSelectedSheet(null); - if (workbook !== null) setModalDialog(ImportSheetDialog.name); - } - -export function onCancelImportSheet(setWorkbook:Function, setWorkbookName:Function, setSelectedSheet:Function, setModalDialog:Function) { - setWorkbook(null); - setWorkbookName(''); - setSelectedSheet(null); - setModalDialog(null); - } - -export function onSelectSheet(sheet:HoneSheet, setSelectedSheet:Function, setModalDialog:Function) { - setSelectedSheet(sheet); +export function onSelectSheet(sheet:HoneSheet, setAvailableSheets:Function, setSheet:Function, setModalDialog:Function) { + setAvailableSheets([]); + setSheet(sheet); setModalDialog(null); } @@ -127,7 +78,7 @@ async function _importFromClipboard(importOptions:ImportOptions, setSheet:Functi return; default: console.error(e); - errorToast(`It didn't work and the reason is unclear. Maybe try again?`); + errorToast(`There was an unexpected error importing the pasted data.`); return; } } @@ -156,13 +107,61 @@ async function _importFromCsv(importOptions:ImportOptions, setSheet:Function, se return; default: console.error(e); - errorToast(`It didn't work, and the reason is unclear. I'm sorry I can't give a better explanation.`); + errorToast(`There was an unexpected error importing the CSV file.`); + return; + } + } +} + +async function _importFromExcel(setAvailableSheets:Function, setSheet:Function, setModalDialog:Function) { + try { + const fileHandle = await _selectExcelFileHandle(); + if (!fileHandle) { setModalDialog(null); return; } // Not an error, user canceled. + const sheets:HoneSheet[] = await importSheetsFromXlsFile(fileHandle); + if (sheets.length === 0) { + errorToast('The Excel file didn\'t have any usable sheets.'); // TODO I need to think about giving enough information for the user to diagnose the problem. Also, some kinds of errors should have recoverability. + return; + } + if (sheets.length === 1) { // If only one sheet, import it directly. + setSheet(sheets[0]); + setAvailableSheets([]); + setModalDialog(null); + return; + } + setAvailableSheets(sheets); //Otherwise, need to let user choose a sheet. + setSheet(null); + setModalDialog(ImportSheetDialog.name); + } catch(e:any) { + switch(e.name) { + case SheetErrorType.READ_FILE_ERROR: + errorToast('There was a problem reading the file itself - maybe permission-related.'); + return; + case SheetErrorType.XLS_FORMAT_ERROR: + errorToast(`The Excel file was in an unexpected format, so I couldn't use it.`); + return; + default: + console.error(e); + errorToast(`There was an unexpected error importing the Excel file.`); return; } } } -export async function importSheet(importOptions:ImportOptions, setSheet:Function, setModalDialog:Function) { +async function _importExample(setAvailableSheets:Function, setModalDialog:Function):Promise { + try { // TODO correct error handling below. + const response = await fetch(baseUrl('/example/Examples.xlsx')); + const data = new Uint8Array(await response.arrayBuffer()); + const sheets:HoneSheet[] = await importSheetsFromXlsBytes(data); + if (sheets.length < 1) throw Error('Unexpected'); + setAvailableSheets(sheets); + setModalDialog(ImportSheetDialog.name); + } catch(e) { + console.error(e); + errorToast('There was an unexpected error importing the example file.'); + } +} + +export async function importSheet(importOptions:ImportOptions, setAvailableSheets:Function, setSheet:Function, setModalDialog:Function) { switch(importOptions.importType) { case ImportType.CLIPBOARD: await _importFromClipboard(importOptions, setSheet, setModalDialog); @@ -172,6 +171,14 @@ export async function importSheet(importOptions:ImportOptions, setSheet:Function await _importFromCsv(importOptions, setSheet, setModalDialog); return; + case ImportType.EXCEL: + await _importFromExcel(setAvailableSheets, setSheet, setModalDialog); + return; + + case ImportType.EXAMPLE: + await _importExample(setAvailableSheets, setModalDialog); + return; + default: throw Error('Unexpected'); } diff --git a/src/sheets/executionJobUtil.ts b/src/sheets/executionJobUtil.ts index 4df1e86..5494252 100644 --- a/src/sheets/executionJobUtil.ts +++ b/src/sheets/executionJobUtil.ts @@ -5,6 +5,7 @@ import HoneSheet from "./types/HoneSheet"; import { getAverageCompletionTime } from "@/llm/llmStatsUtil"; import { isEmpty } from "@/common/stringUtil"; import { describeDuration } from "@/common/timeUtil"; +import Row from "./types/Row"; const DEFAULT_PROMPT_AVERAGE_MSECS = 5000; @@ -19,7 +20,7 @@ export function countUnprocessedRows(sheet:HoneSheet, writeColumnName:string, wr let unprocessedRowCount = 0; for (let i = writeStartRowNo; i < writeEndRowNo; i++) { - const row:any[] = sheet.rows[i-1]; + const row:Row = sheet.rows[i-1]; const cellValue:any = row[writeColumnI]; if (isEmpty(cellValue)) unprocessedRowCount++; } diff --git a/src/sheets/sheetUtil.ts b/src/sheets/sheetUtil.ts index e2179af..5295600 100644 --- a/src/sheets/sheetUtil.ts +++ b/src/sheets/sheetUtil.ts @@ -1,4 +1,4 @@ -import { WorkBook, utils, write } from 'xlsx'; +import { WorkBook, utils, read, write } from 'xlsx'; import StringMap from '@/common/types/StringMap'; import HoneSheet from './types/HoneSheet'; @@ -6,12 +6,16 @@ import HoneColumn from './types/HoneColumn'; import { rowArrayToCsvUtf8 } from '@/csv/csvExportUtil'; import { csvUnicodeToRowArray, csvUtf8ToRowArray } from '@/csv/csvImportUtil'; import AppException from '@/common/types/AppException'; +import Rowset from './types/Rowset'; export enum SheetErrorType { CLIPBOARD_NO_ROWS = 'SheetErrorType-CLIPBOARD_NO_ROWS', NO_CLIPBOARD_ACCESS = 'SheetErrorType-NO_CLIPBOARD_ACCESS', UNEXPECTED_CLIPBOARD_ERROR = 'SheetErrorType-UNEXPECTED_CLIPBOARD_ERROR', - READ_FILE_ERROR = 'SheetErrorType-READ_FILE_ERROR' + READ_FILE_ERROR = 'SheetErrorType-READ_FILE_ERROR', + XLS_FORMAT_ERROR = 'SheetErrorType-XLS_FORMAT_ERROR', + XLS_FIELD_COUNT_MISMATCH = 'SheetErrorType-FIELD_COUNT_MISMATCH', + XLS_NOT_ENOUGH_ROWS = 'SheetErrorType-NO_DATA' } export function createRowNameValues(sheet:HoneSheet, rowNo:number):StringMap { @@ -23,17 +27,40 @@ export function createRowNameValues(sheet:HoneSheet, rowNo:number):StringMap { return rowNameValues; } +export function _findRowWithMismatchedFieldCount(rows:Rowset):number { + const fieldCount = rows[0].length; + for (let i = 1; i < rows.length; i++) { + if (rows[i].length !== fieldCount) return i + 1; + } + return -1; +} + +function _createSheetset(workbook:WorkBook):HoneSheet[] { + const sheets:HoneSheet[] = []; + workbook.SheetNames.forEach(sheetName => { + try { + const sheet = createHoneSheet(workbook, sheetName); + sheets.push(sheet); + } catch(_ignored) {} + }); + return sheets; +} + +// Can throw SheetErrorType.XLS_NOT_ENOUGH_ROWS, SheetErrorType.XLS_FIELD_COUNT_MISMATCH export function createHoneSheet(workbook:WorkBook, sheetName:string):HoneSheet { const sheet = workbook.Sheets[sheetName]; if (!sheet) throw Error('Unexpected'); const ref = sheet['!ref']; if (!ref) return { name:sheetName, columns:[], rows:[] }; - const rowsWithHeader = utils.sheet_to_json(sheet, {header:1}); + const rowsWithHeader = utils.sheet_to_json(sheet, {header:1}) as Rowset; + if (rowsWithHeader.length < 2) throw new AppException(SheetErrorType.XLS_NOT_ENOUGH_ROWS, 'Sheet needs at least two rows - one for headers and one for data.'); + const mismatchRowNo = _findRowWithMismatchedFieldCount(rowsWithHeader);; + if (mismatchRowNo !== -1) throw new AppException(SheetErrorType.XLS_FIELD_COUNT_MISMATCH, `Row ${mismatchRowNo} has a different number of fields than the header row.`); const columnNames = rowsWithHeader[0] as string[]; - const columns:HoneColumn[] = columnNames.map((name) => ({ name, cells:[], isWritable:false })); + const columns:HoneColumn[] = columnNames.map((cellValue) => ({ name:'' + cellValue, isWritable:false })); - const rows = rowsWithHeader.slice(1) as any[][]; + const rows = rowsWithHeader.slice(1) as Rowset; return { name:sheetName, columns, rows }; } @@ -45,7 +72,7 @@ export function duplicateSheet(sheet:HoneSheet):HoneSheet { } export const HTML_NBSP = '\u00A0'; // Same as " " - useful for padding in tables to keep rows from disappearing. -export function getSheetRows(sheet:HoneSheet, startRow:number = 0, maxRows:number = 0, padToMax:boolean = false, paddingValue = HTML_NBSP):any[][] { +export function getSheetRows(sheet:HoneSheet, startRow:number = 0, maxRows:number = 0, padToMax:boolean = false, paddingValue = HTML_NBSP):Rowset { let rows = sheet.rows.slice(startRow); if (maxRows) rows = rows.slice(0, maxRows); if (rows.length < maxRows && padToMax) { @@ -92,7 +119,7 @@ function _firstRowToColumns(firstRow:string[]):HoneColumn[] { return firstRow.map(name => ({ name, isWritable:false })); } -async function _readFileAsUint8Array(fileHandle:FileSystemFileHandle):Promise { +export async function readFileAsUint8Array(fileHandle:FileSystemFileHandle):Promise { try { const file = await fileHandle.getFile(); const blob = await file.arrayBuffer(); @@ -103,10 +130,28 @@ async function _readFileAsUint8Array(fileHandle:FileSystemFileHandle):Promise { + let workbook:WorkBook; + try { + workbook = read(data, {type: 'array'}); + } catch(e:any) { + throw new AppException(SheetErrorType.XLS_FORMAT_ERROR, e.message); + } + if (!workbook.SheetNames.length) return []; + return _createSheetset(workbook); +} + +// Can throw SheetErrorType.XLS_FORMAT_ERROR, SheetErrorType.READ_FILE_ERROR +export async function importSheetsFromXlsFile(fileHandle:FileSystemFileHandle):Promise { + const data = await readFileAsUint8Array(fileHandle); + return importSheetsFromXlsBytes(data); +} + // Can throw CvsImportError.NO_DATA, FIELD_COUNT_MISMATCH, UNSTRUCTURED_DATA, TOO_MANY_FIELDS, // SheetError.READ_FILE_ERROR export async function importSheetFromCsvFile(fileHandle:FileSystemFileHandle, useFirstRowColumnNames:boolean):Promise { - const csvUtf8 = await _readFileAsUint8Array(fileHandle); + const csvUtf8 = await readFileAsUint8Array(fileHandle); const sheetName = _fileNameToSheetName(fileHandle.name); let rows = csvUtf8ToRowArray(csvUtf8, useFirstRowColumnNames); const columns = _firstRowToColumns(rows[0]); diff --git a/src/sheets/types/HoneSheet.ts b/src/sheets/types/HoneSheet.ts index 4f9e418..f414b56 100644 --- a/src/sheets/types/HoneSheet.ts +++ b/src/sheets/types/HoneSheet.ts @@ -1,9 +1,10 @@ import HoneColumn from './HoneColumn'; +import Rowset from './Rowset'; type HoneSheet = { name:string, columns:HoneColumn[], - rows:any[][] + rows:Rowset } export default HoneSheet; \ No newline at end of file diff --git a/src/sheets/types/Row.ts b/src/sheets/types/Row.ts new file mode 100644 index 0000000..3198f28 --- /dev/null +++ b/src/sheets/types/Row.ts @@ -0,0 +1,3 @@ +type Row = any[]; + +export default Row; \ No newline at end of file diff --git a/src/sheets/types/Rowset.ts b/src/sheets/types/Rowset.ts new file mode 100644 index 0000000..6a72a5b --- /dev/null +++ b/src/sheets/types/Rowset.ts @@ -0,0 +1,3 @@ +type Rowset = any[][]; + +export default Rowset; \ No newline at end of file