├── .nvmrc ├── .gitignore ├── __mocks__ └── styleMock.js ├── CONTRIBUTING.md ├── .yarnrc.yml ├── screenshots ├── 0.png ├── 1.png └── icon.png ├── static ├── icon0-16.png ├── icon0-32.png ├── icon0-64.png ├── icon0-80.png ├── icon1-16.png ├── icon1-32.png ├── icon1-80.png └── logo-minified-margins.svg ├── .yarn └── install-state.gz ├── definitions.d.ts ├── .idea ├── .gitignore ├── encodings.xml ├── codeStyles │ └── codeStyleConfig.xml ├── misc.xml ├── vcs.xml ├── jsLibraryMappings.xml ├── modules.xml ├── runConfigurations │ ├── lint.xml │ ├── devServer.xml │ └── All_tests.xml ├── inspectionProfiles │ └── Project_Default.xml ├── excel-csv-import.iml └── jsonSchemas.xml ├── Pages.ts ├── components ├── BaseProps.ts ├── BackButton.tsx ├── styles.ts ├── licenses │ ├── generateThirdPartyLicenses.js │ └── thisApp.ts ├── ProgressBar.test.tsx ├── BottomBar.tsx ├── EncodingDropdownOptions.ts ├── ProgressBar.tsx ├── ErrorBoundary.test.tsx ├── EncodingDropdown.tsx ├── ParserOutputBox.tsx ├── NumberFormatDropdown.tsx ├── generateEncodingList.js ├── LicenseInformation.tsx ├── ErrorBoundary.tsx ├── NewlineDropdown.tsx ├── SourceInput.tsx ├── Export.test.tsx ├── Page.tsx ├── Import.test.tsx ├── About.tsx ├── DelimiterInput.test.tsx ├── DelimiterInput.tsx ├── Import.tsx ├── Export.tsx └── __snapshots__ │ ├── Export.test.tsx.snap │ └── Import.test.tsx.snap ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── test.yml │ └── deploy.yml ├── .babelrc.js ├── errorhandler.ts ├── jest.config.js ├── tsconfig.json ├── sourceMap.js ├── webpack ├── common.js ├── prod.js └── dev.js ├── test └── setup.ts ├── state.ts ├── reducer.ts ├── index.html ├── generateCSV.js ├── icons ├── exportIcons.sh ├── import.svg └── export.svg ├── useLocalStorage.ts ├── LICENSE ├── useLocalStorage.test.tsx ├── README.md ├── .vscode └── launch.json ├── excel.test.ts ├── eslint.config.mjs ├── index.tsx ├── package.json ├── action.ts ├── excel.ts ├── manifests ├── dev.manifest.xml └── prod.manifest.xml ├── parser.ts └── parser.test.ts /.nvmrc: -------------------------------------------------------------------------------- 1 | v13.13.0 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | I welcome any and all issues and pull requests! Thanks! -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: pnp 2 | 3 | yarnPath: .yarn/releases/yarn-4.12.0.cjs 4 | -------------------------------------------------------------------------------- /screenshots/0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emurasoft/excel-csv-import/HEAD/screenshots/0.png -------------------------------------------------------------------------------- /screenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emurasoft/excel-csv-import/HEAD/screenshots/1.png -------------------------------------------------------------------------------- /screenshots/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emurasoft/excel-csv-import/HEAD/screenshots/icon.png -------------------------------------------------------------------------------- /static/icon0-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emurasoft/excel-csv-import/HEAD/static/icon0-16.png -------------------------------------------------------------------------------- /static/icon0-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emurasoft/excel-csv-import/HEAD/static/icon0-32.png -------------------------------------------------------------------------------- /static/icon0-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emurasoft/excel-csv-import/HEAD/static/icon0-64.png -------------------------------------------------------------------------------- /static/icon0-80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emurasoft/excel-csv-import/HEAD/static/icon0-80.png -------------------------------------------------------------------------------- /static/icon1-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emurasoft/excel-csv-import/HEAD/static/icon1-16.png -------------------------------------------------------------------------------- /static/icon1-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emurasoft/excel-csv-import/HEAD/static/icon1-32.png -------------------------------------------------------------------------------- /static/icon1-80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emurasoft/excel-csv-import/HEAD/static/icon1-80.png -------------------------------------------------------------------------------- /.yarn/install-state.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emurasoft/excel-csv-import/HEAD/.yarn/install-state.gz -------------------------------------------------------------------------------- /definitions.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css' { 2 | const content: Record; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /Pages.ts: -------------------------------------------------------------------------------- 1 | export const enum Pages { 2 | import = 'import', 3 | export = 'export', 4 | about = 'about', 5 | licenseInformation = 'licenseInformation', 6 | } 7 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /components/BaseProps.ts: -------------------------------------------------------------------------------- 1 | export interface BaseProps { 2 | value: T; 3 | onChange: (value: T) => any; // eslint-disable-line @typescript-eslint/no-explicit-any 4 | } 5 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | useBuiltIns: 'usage', 7 | targets: 'ie 11', 8 | corejs: {version: '3.33'}, 9 | }, 10 | ], 11 | ], 12 | plugins: ['@babel/plugin-syntax-dynamic-import'], 13 | }; 14 | -------------------------------------------------------------------------------- /errorhandler.ts: -------------------------------------------------------------------------------- 1 | import {errorOutput, SET_OUTPUT} from './action'; 2 | 3 | export const errorHandler = ({dispatch}) => next => async (action) => { 4 | try { 5 | return await next(action); 6 | } catch (error) { 7 | dispatch({type: SET_OUTPUT, output: errorOutput(error)}); 8 | throw error; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'jest-fixed-jsdom', 5 | moduleNameMapper: { 6 | '\\.(css|less)$': '/__mocks__/styleMock.js', 7 | }, 8 | setupFiles: ['/test/setup.ts'], 9 | snapshotSerializers: ['@griffel/jest-serializer'], 10 | }; 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "es2023", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "lib": [ 8 | "dom", 9 | "es2018" 10 | ], 11 | "jsx": "react", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "skipLibCheck": true, 15 | "esModuleInterop": true 16 | } 17 | } -------------------------------------------------------------------------------- /sourceMap.js: -------------------------------------------------------------------------------- 1 | const sourceMap = require('source-map'); 2 | const fetch = require('node-fetch'); 3 | 4 | async function main() { 5 | const map = await fetch('https://emurasoft.github.io/excel-csv-import/export~import.8f32e9df82fd02d124e8.js.map'); 6 | const smc = await new sourceMap.SourceMapConsumer(await map.text()); 7 | console.log(smc.originalPositionFor({line: 1, column: 3307})); 8 | } 9 | 10 | main(); 11 | -------------------------------------------------------------------------------- /webpack/common.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 2 | const CopyPlugin = require('copy-webpack-plugin'); 3 | 4 | module.exports = { 5 | resolve: { 6 | extensions: ['.ts', '.tsx', '.js', '.css'], 7 | }, 8 | target: 'web', 9 | output: { 10 | filename: '[name].[fullhash].js', 11 | }, 12 | plugins: [ 13 | new HtmlWebpackPlugin({template: 'index.html'}), 14 | new CopyPlugin({ 15 | patterns: [{from: 'static/*'}], 16 | }), 17 | ], 18 | }; 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - '*' 6 | pull_request: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: '21.x' 18 | cache: 'npm' 19 | - run: yarn install --immutable 20 | - run: yarn test 21 | - run: yarn lint 22 | -------------------------------------------------------------------------------- /.idea/runConfigurations/lint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | -------------------------------------------------------------------------------- /webpack/prod.js: -------------------------------------------------------------------------------- 1 | const {merge} = require('webpack-merge'); 2 | const common = require('./common'); 3 | 4 | module.exports = merge(common, { 5 | mode: 'production', 6 | output: { 7 | path: __dirname + '/../build', 8 | }, 9 | entry: ['@babel/polyfill', __dirname + '/../index.tsx'], 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.tsx?$/, 14 | use: ['babel-loader', 'ts-loader'], 15 | }, 16 | { 17 | test: /\.css$/, 18 | use: ['style-loader', 'css-loader'], 19 | }, 20 | ], 21 | }, 22 | devtool: 'source-map', // source-map gets the most accurate traces 23 | }); 24 | -------------------------------------------------------------------------------- /.idea/excel-csv-import.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /components/licenses/generateThirdPartyLicenses.js: -------------------------------------------------------------------------------- 1 | const childProcess = require('child_process'); 2 | const fs = require('fs'); 3 | 4 | function main() { 5 | const cwd = __dirname + '/../../' 6 | const output = childProcess.execSync('npx yarn licenses generate-disclaimer', {cwd}).toString(); 7 | const trimmed = output.substring(output.indexOf('-----\n\n') + '-----\n\n'.length); 8 | const escaped = trimmed.replace(/'/g, '\\\''); 9 | const newlinesReplaced = escaped.replace(/\n/g, '\\n'); 10 | const text = 'export default \'' + newlinesReplaced + '\';'; 11 | fs.writeFileSync(__dirname + '/thirdParty.ts', text); 12 | } 13 | 14 | main(); -------------------------------------------------------------------------------- /generateCSV.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const rows = 1000; 4 | const columns = 10; 5 | const bytesPerCell = 10; 6 | 7 | /** 8 | * @returns {string} 9 | */ 10 | function row() { 11 | let result = ''; 12 | for (let i = 0; i < columns; ++i) { 13 | result += 'a'.repeat(bytesPerCell) + ','; 14 | } 15 | return result.substring(0, result.length - 1) + '\n'; 16 | } 17 | 18 | function main() { 19 | const rowStr = row(); 20 | fs.writeFileSync(__dirname + '/csvFile.csv', ''); 21 | const fd = fs.openSync(__dirname + '/csvFile.csv', 'a'); 22 | for (let i = 0; i < rows; i++) { 23 | fs.writeSync(fd, rowStr); 24 | } 25 | } 26 | 27 | main(); 28 | -------------------------------------------------------------------------------- /icons/exportIcons.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | script_path=$(cd "$(dirname "${BASH_SOURCE[0]}")"; pwd -P) 3 | 4 | # 1=svg file name 2=export file name 3=icon width 5 | function exportPNG { 6 | inkscape --file=${script_path}'/'${1} --export-png=${script_path}'/../public/'${2} --export-area-page --export-width=${3} --export-height=${3} 7 | pngcrush -ow ${script_path}'/../public/'${2} 8 | } 9 | 10 | exportPNG import.svg icon0-16.png 16 11 | exportPNG import.svg icon0-32.png 32 12 | exportPNG import.svg icon0-64.png 64 13 | exportPNG import.svg icon0-80.png 80 14 | exportPNG export.svg icon1-16.png 16 15 | exportPNG export.svg icon1-32.png 32 16 | exportPNG export.svg icon1-80.png 80 -------------------------------------------------------------------------------- /components/ProgressBar.test.tsx: -------------------------------------------------------------------------------- 1 | import {ProgressBarWithStopButton} from './ProgressBar'; 2 | import * as React from 'react'; 3 | import {describe, expect, test} from '@jest/globals'; 4 | import {render} from '@testing-library/react'; 5 | import userEvent from '@testing-library/user-event'; 6 | 7 | describe('ProgressBarWithStopButton', () => { 8 | test('ProgressBarWithStopButton', async () => { 9 | let clicked = false; 10 | const wrapper = render( 11 | {clicked = true;}} 13 | progress={{show: true, aborting: false, percent: 0.0}} 14 | /> 15 | ); 16 | 17 | await userEvent.click(wrapper.getByRole('button')); 18 | 19 | expect(clicked); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /components/BottomBar.tsx: -------------------------------------------------------------------------------- 1 | import {Link, mergeClasses, Text} from '@fluentui/react-components'; 2 | import * as React from 'react'; 3 | import {Link as RouterLink} from 'react-router-dom'; 4 | import {Pages} from '../Pages'; 5 | import {useStyles} from './styles'; 6 | 7 | export function BottomBar(): React.ReactElement { 8 | const styles = useStyles(); 9 | 10 | return ( 11 |
15 | 16 | 20 | About 21 | 22 | 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | pages: write 12 | id-token: write 13 | environment: 14 | name: github-pages 15 | url: ${{ steps.deployment.outputs.page_url }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: '21.x' 21 | cache: 'npm' 22 | - run: yarn install --immutable 23 | - run: yarn build 24 | - name: Upload pages 25 | if: ${{ github.ref_type == 'tag' }} 26 | uses: actions/upload-pages-artifact@v3 27 | with: 28 | path: build 29 | - uses: actions/deploy-pages@v4 30 | -------------------------------------------------------------------------------- /components/EncodingDropdownOptions.ts: -------------------------------------------------------------------------------- 1 | export const EncodingDropdownOptions: string[] = [ 2 | 'Big5', 3 | 'EUC-JP', 4 | 'EUC-KR', 5 | 'GBK', 6 | 'IBM866', 7 | 'ISO-2022-JP', 8 | 'ISO-8859-10', 9 | 'ISO-8859-13', 10 | 'ISO-8859-14', 11 | 'ISO-8859-15', 12 | 'ISO-8859-16', 13 | 'ISO-8859-2', 14 | 'ISO-8859-3', 15 | 'ISO-8859-4', 16 | 'ISO-8859-5', 17 | 'ISO-8859-6', 18 | 'ISO-8859-7', 19 | 'ISO-8859-8', 20 | 'ISO-8859-8-I', 21 | 'KOI8-R', 22 | 'KOI8-U', 23 | 'Shift_JIS', 24 | 'UTF-16BE', 25 | 'UTF-16LE', 26 | 'UTF-8', 27 | 'gb18030', 28 | 'macintosh', 29 | 'replacement', 30 | 'windows-1250', 31 | 'windows-1251', 32 | 'windows-1252', 33 | 'windows-1253', 34 | 'windows-1254', 35 | 'windows-1255', 36 | 'windows-1256', 37 | 'windows-1257', 38 | 'windows-1258', 39 | 'windows-874', 40 | 'x-mac-cyrillic', 41 | 'x-user-defined', 42 | ]; 43 | -------------------------------------------------------------------------------- /.idea/runConfigurations/All_tests.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | project 4 | 5 | $PROJECT_DIR$/node_modules/mocha 6 | $PROJECT_DIR$ 7 | true 8 | 9 | 10 | 11 | 12 | bdd 13 | -r ts-node/register,test/setup.ts 14 | PATTERN 15 | **/*.test.ts **/*.test.tsx 16 | 17 | 18 | -------------------------------------------------------------------------------- /components/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {Link, ProgressBar, Text} from '@fluentui/react-components'; 3 | import {AppState} from '../state'; 4 | 5 | interface Props { 6 | // Fired when the "Stop" link is clicked. 7 | onClick: () => void; 8 | progress: AppState['progress']; 9 | } 10 | 11 | export function ProgressBarWithStopButton({onClick, progress}: Props): React.ReactElement { 12 | let stopText: React.ReactElement; 13 | if (progress.aborting) { 14 | stopText = Stopping; 15 | } else { 16 | stopText = Stop; 17 | } 18 | 19 | return ( 20 | <> 21 | { 22 | progress.show 23 | ? ( 24 | <> 25 | {stopText} 26 | 27 | 28 | ) 29 | :   30 | } 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /components/ErrorBoundary.test.tsx: -------------------------------------------------------------------------------- 1 | import {ErrorBoundary} from './ErrorBoundary'; 2 | import * as React from 'react'; 3 | import {describe, expect, test} from '@jest/globals'; 4 | import {render} from '@testing-library/react'; 5 | import {JSX} from 'react'; 6 | 7 | describe('ErrorBoundary', () => { 8 | test('message appears', () => { 9 | const errorBoundary = render(
); 10 | expect(errorBoundary.queryByRole('textbox')).toBeNull(); 11 | 12 | // Silence errors for test 13 | const consoleError = console.error; 14 | console.error = () => {}; 15 | 16 | const msg = 'you should see this'; 17 | 18 | function BuggyComponent(): JSX.Element { 19 | throw new Error(msg); 20 | } 21 | errorBoundary.rerender(); 22 | expect(errorBoundary.getByRole('textbox').textContent.includes(msg)); 23 | 24 | console.error = consoleError; 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /components/EncodingDropdown.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {Dropdown, Label, Option, Subtitle1} from '@fluentui/react-components'; 3 | import {EncodingDropdownOptions} from './EncodingDropdownOptions'; 4 | 5 | interface Props { 6 | showAutoDetect: boolean; 7 | value: string; 8 | onChange: (value: string) => void; 9 | } 10 | 11 | export function EncodingDropdown({showAutoDetect, value, onChange}: Props): React.ReactElement { 12 | return ( 13 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import {Dispatch, useState} from 'react'; 2 | 3 | export function useLocalStorage(key: string, initialValue: T): [T, Dispatch] { 4 | const [storedValue, setStoredValue] = useState(() => { 5 | let value: T = initialValue; 6 | try { 7 | const item = window.localStorage.getItem(key); 8 | if (item) { 9 | value = JSON.parse(item); 10 | } 11 | } catch (e) { 12 | console.warn(e); 13 | } 14 | return value; 15 | }); 16 | 17 | const setValue = (value) => { 18 | setStoredValue(value); 19 | try { 20 | window.localStorage.setItem(key, JSON.stringify(value)); 21 | } catch (e) { 22 | console.warn(e); 23 | } 24 | }; 25 | 26 | return [storedValue, setValue]; 27 | } 28 | 29 | export function namespacedUseLocalStorage(namespace: string): typeof useLocalStorage { 30 | return function (key: string, initialValue) { 31 | return useLocalStorage(namespace + '-' + key, initialValue); 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /components/ParserOutputBox.tsx: -------------------------------------------------------------------------------- 1 | import {mergeClasses, Textarea} from '@fluentui/react-components'; 2 | import * as React from 'react'; 3 | import {AppState, OutputType} from '../state'; 4 | import {useStyles} from './styles'; 5 | 6 | interface Props { 7 | output: AppState['output']; 8 | } 9 | 10 | export function ParserOutputBox({output}: Props): React.ReactElement { 11 | const styles = useStyles(); 12 | 13 | switch (output.type) { 14 | case OutputType.text: 15 | return ( 16 | 270 | 271 |
275 | 278 | 282 | 288 | 289 | 290 |
291 |
292 | 293 | `; 294 | -------------------------------------------------------------------------------- /parser.ts: -------------------------------------------------------------------------------- 1 | /* global Office */ 2 | import * as ExcelAPI from './excel'; 3 | import {Shape} from './excel'; 4 | import * as Papa from 'papaparse'; 5 | 6 | export const enum InputType { 7 | file = 'File', 8 | text = 'Text input', 9 | } 10 | 11 | export interface Source { 12 | inputType: InputType; 13 | file?: File; 14 | text: string; 15 | } 16 | 17 | type Config = Pick; 18 | 19 | export const enum NewlineSequence { 20 | AutoDetect = '', 21 | CRLF = '\r\n', 22 | CR = '\r', 23 | LF = '\n', 24 | } 25 | 26 | export interface ImportOptions extends Config { 27 | source: Source; 28 | newline: NewlineSequence; 29 | numberFormat: NumberFormat; 30 | } 31 | 32 | export interface ExportOptions { 33 | delimiter: string; 34 | newline: NewlineSequence; 35 | } 36 | 37 | export const enum NumberFormat { 38 | Text = '@', 39 | General = 'General', 40 | } 41 | 42 | let reduceChunkSize = null; 43 | 44 | export class Parser { 45 | constructor() { 46 | this.abortFlag = new AbortFlag(); 47 | } 48 | 49 | async init(): Promise { 50 | const platform = await ExcelAPI.init(); 51 | if (platform === Office.PlatformType.OfficeOnline) { 52 | // Online API can throw error if request size is too large 53 | reduceChunkSize = true; 54 | (Papa.LocalChunkSize as unknown as number) = 10_000; 55 | } else { 56 | reduceChunkSize = false; 57 | } 58 | return platform; 59 | } 60 | 61 | async importCSV( 62 | importOptions: ImportOptions, 63 | progressCallback: ProgressCallback, 64 | ): Promise { 65 | this.abort(); 66 | 67 | let errors = null; 68 | await ExcelAPI.runOnBlankWorksheet(async (worksheet) => { 69 | const chunkProcessor = new ChunkProcessor(worksheet, progressCallback, this.abortFlag); 70 | errors = await chunkProcessor.run(importOptions); 71 | }); 72 | return errors; 73 | } 74 | 75 | async csvStringAndName( 76 | exportOptions: ExportOptions, 77 | progressCallback: ProgressCallback, 78 | ): Promise { 79 | this.abort(); 80 | 81 | let namesAndShape = null; 82 | let resultString = ''; 83 | await ExcelAPI.runOnCurrentWorksheet(async (worksheet) => { 84 | namesAndShape = await ExcelAPI.worksheetNamesAndShape(worksheet); 85 | worksheet.context.application.suspendApiCalculationUntilNextSync(); 86 | resultString = await csvString( 87 | worksheet, 88 | namesAndShape.shape, 89 | chunkRows(namesAndShape.shape), 90 | exportOptions, 91 | progressCallback, 92 | this.abortFlag, 93 | ); 94 | }); 95 | 96 | return { 97 | name: nameToUse(namesAndShape.workbookName, namesAndShape.worksheetName), 98 | string: resultString, 99 | }; 100 | } 101 | 102 | abort(): void { 103 | this.abortFlag.abort(); 104 | this.abortFlag = new AbortFlag(); 105 | } 106 | 107 | private abortFlag: AbortFlag; 108 | } 109 | 110 | export class AbortFlag { 111 | public constructor() { 112 | this._aborted = false; 113 | } 114 | 115 | public abort(): void { 116 | this._aborted = true; 117 | } 118 | 119 | public aborted(): boolean { 120 | return this._aborted; 121 | } 122 | 123 | private _aborted: boolean; 124 | } 125 | 126 | type ProgressCallback = (progress: number) => void; 127 | 128 | export class ChunkProcessor { 129 | public constructor( 130 | worksheet: Excel.Worksheet, 131 | progressCallback: ProgressCallback, 132 | abortFlag: AbortFlag, 133 | ) { 134 | this._worksheet = worksheet; 135 | this._progressCallback = progressCallback; 136 | this._abortFlag = abortFlag; 137 | this._currRow = 0; 138 | this._currentProgress = 0.0; 139 | } 140 | 141 | public run(importOptions: ImportOptions): Promise { 142 | this._progressCallback(0.0); 143 | this._progressPerChunk = ChunkProcessor.progressPerChunk( 144 | importOptions.source, 145 | Papa.LocalChunkSize as unknown as number, 146 | ); 147 | this._numberFormat = importOptions.numberFormat; 148 | 149 | return new Promise((resolve) => { 150 | importOptions.chunk = this.chunk; 151 | importOptions.complete = results => resolve(results.errors); 152 | 153 | switch (importOptions.source.inputType) { 154 | case InputType.file: 155 | Papa.parse(importOptions.source.file, importOptions as Papa.ParseLocalConfig); 156 | break; 157 | case InputType.text: 158 | Papa.parse( 159 | /* eslint-disable @typescript-eslint/no-explicit-any */ 160 | importOptions.source.text as any, 161 | importOptions as Papa.ParseLocalConfig, 162 | ); 163 | break; 164 | } 165 | }); 166 | } 167 | 168 | private static progressPerChunk(source: Source, chunkSize: number): number { 169 | switch (source.inputType) { 170 | case InputType.file: 171 | if (source.file.size === 0) { 172 | return 1.0; 173 | } 174 | return chunkSize / source.file.size; 175 | case InputType.text: 176 | if (source.text.length === 0) { 177 | return 1.0; 178 | } 179 | return chunkSize / source.text.length; 180 | } 181 | } 182 | 183 | private readonly _worksheet: Excel.Worksheet; 184 | private readonly _progressCallback: ProgressCallback; 185 | private readonly _abortFlag: AbortFlag; 186 | private readonly _excelAPI = ExcelAPI; 187 | private _currRow: number; 188 | private _progressPerChunk: number; 189 | private _currentProgress: number; 190 | private _numberFormat: NumberFormat; 191 | 192 | private chunk = (chunk: Papa.ParseResult, parser: Papa.Parser) => { 193 | if (this._abortFlag.aborted()) { 194 | parser.abort(); 195 | } 196 | 197 | this._worksheet.context.application.suspendApiCalculationUntilNextSync(); 198 | this._excelAPI.setChunk(this._worksheet, this._currRow, chunk.data, this._numberFormat); 199 | this._currRow += chunk.data.length; 200 | parser.pause(); 201 | // sync() must be called after each chunk, otherwise API may throw exception 202 | this._worksheet.context.sync().then(parser.resume); 203 | // Since the Excel API is so damn slow, updating GUI every chunk has a negligible impact 204 | // on performance. 205 | this._progressCallback(this._currentProgress += this._progressPerChunk); 206 | }; 207 | } 208 | 209 | /* 210 | RFC 4180 standard: 211 | MS-DOS-style lines that end with (CR/LF) characters (optional for the last line). 212 | An optional header record (there is no sure way to detect whether it is present, so care is required 213 | when importing). 214 | Each record "should" contain the same number of comma-separated fields. 215 | Any field may be quoted (with double quotes). 216 | Fields containing a line-break, double-quote or commas (delimiter) should be quoted. (If they are 217 | not, the file will likely be impossible to process correctly). 218 | A (double) quote character in a field must be represented by two (double) quote characters. 219 | Thanks Wikipedia. 220 | */ 221 | 222 | export function chunkRange( 223 | chunk: number, 224 | shape: Shape, 225 | chunkRows: number, 226 | ): {startRow: number; startColumn: number; rowCount: number; columnCount: number} { 227 | return { 228 | startRow: chunk * chunkRows, 229 | startColumn: 0, 230 | rowCount: Math.min(chunkRows, shape.rows), 231 | columnCount: shape.columns, 232 | }; 233 | } 234 | 235 | export function addQuotes(row: string[], delimiter: string): void { 236 | if (delimiter == '') { 237 | return; 238 | } 239 | 240 | const charactersToWatchOutFor = ['\r', '\n', '\u0022' /* double quote */, delimiter]; 241 | for (let i = 0; i < row.length; i++) { 242 | if (charactersToWatchOutFor.some(c => row[i].includes(c))) { 243 | row[i] = '\u0022' + row[i].replace(/\u0022/g, '\u0022\u0022') + '\u0022'; 244 | } 245 | } 246 | } 247 | 248 | /* eslint-disable @typescript-eslint/no-explicit-any */ 249 | export function rowString(row: any[], exportOptions: Readonly): string { 250 | const stringValues = row.map(a => a.toString()); 251 | addQuotes(stringValues, exportOptions.delimiter); 252 | return stringValues.join(exportOptions.delimiter) + exportOptions.newline; 253 | } 254 | 255 | export function chunkString(values: any[][], exportOptions: Readonly): string { 256 | let result = ''; 257 | 258 | for (let i = 0; i < values.length; i++) { 259 | result += rowString(values[i], exportOptions); 260 | } 261 | 262 | return result; 263 | } 264 | /* eslint-enable @typescript-eslint/no-explicit-any */ 265 | 266 | export async function csvString( 267 | worksheet: Excel.Worksheet, 268 | shape: Shape, 269 | chunkRows: number, 270 | exportOptions: Readonly, 271 | progressCallback: ProgressCallback, 272 | abortFlag: AbortFlag, 273 | ): Promise { 274 | let result = ''; 275 | 276 | // chunkRows is never 0 277 | for (let chunk = 0; chunk < Math.ceil(shape.rows / chunkRows); chunk++) { 278 | if (abortFlag.aborted()) { 279 | break; 280 | } 281 | 282 | // shape.rows is never 0 283 | progressCallback(chunk * chunkRows / shape.rows); 284 | 285 | const chunkRange_ = chunkRange(chunk, shape, chunkRows); 286 | const range = worksheet.getRangeByIndexes( 287 | chunkRange_.startRow, 288 | chunkRange_.startColumn, 289 | chunkRange_.rowCount, 290 | chunkRange_.columnCount, 291 | ).load('values'); 292 | await worksheet.context.sync(); 293 | 294 | result += chunkString(range.values, exportOptions); 295 | } 296 | 297 | return result; 298 | } 299 | 300 | export function nameToUse(workbookName: string, worksheetName: string): string { 301 | if (/^Sheet\d+$/.test(worksheetName)) { // 'Sheet1' isn't a good name to use 302 | // Workbook name usually includes the file extension 303 | const to = workbookName.lastIndexOf('.'); 304 | return workbookName.substr(0, to === -1 ? workbookName.length : to); 305 | } else { 306 | return worksheetName; 307 | } 308 | } 309 | 310 | function chunkRows(shape: Shape): number { 311 | if (reduceChunkSize) { 312 | return Math.floor(10_000 / shape.columns); 313 | } else { 314 | return shape.rows; 315 | } 316 | } 317 | 318 | export interface CsvStringAndName { 319 | name: string; 320 | string: string; 321 | } 322 | -------------------------------------------------------------------------------- /components/__snapshots__/Import.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 2 | 3 | exports[`Import import 1`] = ` 4 | 5 |
8 |
11 | 14 | Import CSV 15 | 16 | 24 | 48 | 49 |
50 |
51 | 114 | 117 | 126 | 127 |
128 |
129 |
130 | 192 |
193 |
194 | 256 |
257 |
258 | 319 |
320 |
321 | 328 |
329 | 332 |   333 | 334 |
338 | 341 | 345 | 351 | 352 | 353 |
354 |
355 |
356 | `; 357 | -------------------------------------------------------------------------------- /parser.test.ts: -------------------------------------------------------------------------------- 1 | import * as Parser from './parser'; 2 | import { 3 | AbortFlag, 4 | addQuotes, 5 | ChunkProcessor, 6 | chunkRange, 7 | chunkString, 8 | csvString, 9 | ExportOptions, 10 | InputType, 11 | nameToUse, 12 | NewlineSequence, 13 | rowString, 14 | Source, 15 | } from './parser'; 16 | import {ParseConfig} from 'papaparse'; 17 | import assert from 'assert'; 18 | import {Shape} from './excel'; 19 | import {describe, test} from '@jest/globals'; 20 | 21 | describe('parser', () => { 22 | describe('ChunkProcessor', () => { 23 | test('progressPerChunk()', () => { 24 | const tests: {source: Source; expected: number}[] = [ 25 | { 26 | source: {inputType: InputType.text, text: ''}, 27 | expected: 1.0, 28 | }, 29 | { 30 | source: {inputType: InputType.text, text: 'a'}, 31 | expected: 10.0}, 32 | { 33 | source: {inputType: InputType.file, text: '', file: new File([], '')}, 34 | expected: 1.0, 35 | }, 36 | { 37 | source: {inputType: InputType.file, text: '', file: new File(['a'], '')}, 38 | expected: 10.0, 39 | }, 40 | ]; 41 | 42 | for (const test of tests) { 43 | // @ts-ignore 44 | assert.strictEqual(ChunkProcessor.progressPerChunk(test.source, 10), test.expected); 45 | } 46 | }); 47 | 48 | describe('run()', () => { 49 | test('normal operation', async () => { 50 | let setChunkDone = false; 51 | let syncDone = false; 52 | let progressCallbackDone = false; 53 | 54 | const worksheetStub: any = {context: { 55 | application: {suspendApiCalculationUntilNextSync: () => {}}, 56 | sync: async () => syncDone = true, 57 | }}; 58 | 59 | const api: any = {}; 60 | api.setChunk = (worksheet, row, data) => { 61 | assert.strictEqual(worksheet, worksheetStub); 62 | assert.strictEqual(row, 0); 63 | assert.deepStrictEqual(data, [['a', 'b']]); 64 | setChunkDone = true; 65 | }; 66 | 67 | const progressCallback = (progress): void => { 68 | assert(progress === 0.0 || progress > 1.0); 69 | if (progress > 1.0) { 70 | progressCallbackDone = true; 71 | } 72 | }; 73 | 74 | const processor = new ChunkProcessor( 75 | worksheetStub as any, 76 | progressCallback, 77 | new AbortFlag(), 78 | ); 79 | // @ts-ignore 80 | processor._excelAPI = api; 81 | 82 | const importOptions: Parser.ImportOptions | ParseConfig = { 83 | source: {inputType: Parser.InputType.text, text: 'a,b'}, 84 | delimiter: ',', 85 | newline: NewlineSequence.LF, 86 | encoding: '', 87 | numberFormat: Parser.NumberFormat.Text, 88 | }; 89 | 90 | const errors = await processor.run(importOptions); 91 | assert.deepStrictEqual(errors, []); 92 | assert(setChunkDone); 93 | assert(syncDone); 94 | assert(progressCallbackDone); 95 | }); 96 | 97 | test('abort', async () => { 98 | const progressCallback = (progress): void => { 99 | assert.strictEqual(progress, 0.0); 100 | }; 101 | 102 | const flag = new AbortFlag(); 103 | flag.abort(); 104 | const processor = new ChunkProcessor(null, progressCallback, flag); 105 | // @ts-ignore 106 | processor._excelAPI = null; 107 | 108 | const importOptions: Parser.ImportOptions | ParseConfig = { 109 | source: {inputType: Parser.InputType.text, text: 'a,b'}, 110 | delimiter: ',', 111 | newline: NewlineSequence.LF, 112 | encoding: '', 113 | numberFormat: Parser.NumberFormat.Text, 114 | }; 115 | 116 | const errors = await processor.run(importOptions); 117 | assert.deepStrictEqual(errors, []); 118 | }); 119 | }); 120 | }); 121 | 122 | describe('AbortFlag', () => { 123 | test('abort()', () => { 124 | const flag0 = new AbortFlag(); 125 | assert.strictEqual(flag0.aborted(), false); 126 | flag0.abort(); 127 | assert.strictEqual(flag0.aborted(), true); 128 | flag0.abort(); 129 | assert.strictEqual(flag0.aborted(), true); 130 | 131 | const flag1 = new AbortFlag(); 132 | flag1.abort(); 133 | assert.strictEqual(flag1.aborted(), true); 134 | }); 135 | }); 136 | 137 | test('chunkRange()', () => { 138 | interface Test { 139 | chunk: number; 140 | shape: Shape; 141 | chunkRows: number; 142 | expected: { 143 | startRow: number; 144 | startColumn: number; 145 | rowCount: number; 146 | columnCount: number; 147 | }; 148 | } 149 | 150 | const tests: Test[] = [ 151 | { 152 | chunk: 0, 153 | shape: {rows: 0, columns: 0}, 154 | chunkRows: 0, 155 | expected: { 156 | startRow: 0, 157 | startColumn: 0, 158 | rowCount: 0, 159 | columnCount: 0, 160 | }, 161 | }, 162 | { 163 | chunk: 1, 164 | shape: {rows: 0, columns: 0}, 165 | chunkRows: 0, 166 | expected: { 167 | startRow: 0, 168 | startColumn: 0, 169 | rowCount: 0, 170 | columnCount: 0, 171 | }, 172 | }, 173 | { 174 | chunk: 0, 175 | shape: {rows: 1, columns: 0}, 176 | chunkRows: 0, 177 | expected: { 178 | startRow: 0, 179 | startColumn: 0, 180 | rowCount: 0, 181 | columnCount: 0, 182 | }, 183 | }, 184 | { 185 | chunk: 0, 186 | shape: {rows: 0, columns: 1}, 187 | chunkRows: 0, 188 | expected: { 189 | startRow: 0, 190 | startColumn: 0, 191 | rowCount: 0, 192 | columnCount: 1, 193 | }, 194 | }, 195 | { 196 | chunk: 0, 197 | shape: {rows: 0, columns: 0}, 198 | chunkRows: 1, 199 | expected: { 200 | startRow: 0, 201 | startColumn: 0, 202 | rowCount: 0, 203 | columnCount: 0, 204 | }, 205 | }, 206 | { 207 | chunk: 0, 208 | shape: {rows: 1, columns: 0}, 209 | chunkRows: 1, 210 | expected: { 211 | startRow: 0, 212 | startColumn: 0, 213 | rowCount: 1, 214 | columnCount: 0, 215 | }, 216 | }, 217 | { 218 | chunk: 0, 219 | shape: {rows: 1, columns: 1}, 220 | chunkRows: 1, 221 | expected: { 222 | startRow: 0, 223 | startColumn: 0, 224 | rowCount: 1, 225 | columnCount: 1, 226 | }, 227 | }, 228 | { 229 | chunk: 0, 230 | shape: {rows: 1, columns: 1}, 231 | chunkRows: 2, 232 | expected: { 233 | startRow: 0, 234 | startColumn: 0, 235 | rowCount: 1, 236 | columnCount: 1, 237 | }, 238 | }, 239 | { 240 | chunk: 1, 241 | shape: {rows: 2, columns: 1}, 242 | chunkRows: 1, 243 | expected: { 244 | startRow: 1, 245 | startColumn: 0, 246 | rowCount: 1, 247 | columnCount: 1, 248 | }, 249 | }, 250 | // chunk argument is never greater than or equal to shape.rows 251 | ]; 252 | 253 | for (const test of tests) { 254 | const result = chunkRange(test.chunk, test.shape, test.chunkRows); 255 | assert.deepStrictEqual(result, test.expected); 256 | } 257 | }); 258 | 259 | test('addQuotes()', () => { 260 | const tests: {row: string[]; delimiter: string; expected: string[]}[] = [ 261 | { 262 | row: [''], 263 | delimiter: '', 264 | expected: [''], 265 | }, 266 | { 267 | row: [''], 268 | delimiter: ',', 269 | expected: [''], 270 | }, 271 | { 272 | row: ['a'], 273 | delimiter: '', 274 | expected: ['a'], 275 | }, 276 | { 277 | row: ['"a"'], 278 | delimiter: ',', 279 | expected: ['"""a"""'], 280 | }, 281 | { 282 | row: ['\n'], 283 | delimiter: ',', 284 | expected: ['"\n"'], 285 | }, 286 | { 287 | row: ['a,'], 288 | delimiter: ',', 289 | expected: ['"a,"'], 290 | }, 291 | { 292 | row: ['"'], 293 | delimiter: ',', 294 | expected: ['""""'], 295 | }, 296 | { 297 | row: ['a\t'], 298 | delimiter: '\t', 299 | expected: ['"a\t"'], 300 | }, 301 | { 302 | row: ['aa'], 303 | delimiter: 'aa', 304 | expected: ['"aa"'], 305 | }, 306 | ]; 307 | 308 | for (const test of tests) { 309 | addQuotes(test.row, test.delimiter); 310 | assert.deepStrictEqual(test.row, test.expected); 311 | } 312 | }); 313 | 314 | test('rowString()', () => { 315 | const tests: {row: any[]; exportOptions: ExportOptions; expected: string}[] = [ 316 | { 317 | row: [], 318 | exportOptions: { 319 | delimiter: ',', 320 | newline: NewlineSequence.LF, 321 | }, 322 | expected: '\n', 323 | }, 324 | { 325 | row: ['a', 'b'], 326 | exportOptions: { 327 | delimiter: ',', 328 | newline: NewlineSequence.LF, 329 | }, 330 | expected: 'a,b\n', 331 | }, 332 | { 333 | row: ['a', 'b'], 334 | exportOptions: { 335 | delimiter: ',,', 336 | newline: NewlineSequence.LF, 337 | }, 338 | expected: 'a,,b\n', 339 | }, 340 | { 341 | row: ['a', 'b'], 342 | exportOptions: { 343 | delimiter: ',', 344 | newline: NewlineSequence.CRLF, 345 | }, 346 | expected: 'a,b\r\n', 347 | }, 348 | ]; 349 | 350 | for (const test of tests) { 351 | const result = rowString(test.row, test.exportOptions); 352 | assert.strictEqual(result, test.expected); 353 | } 354 | }); 355 | 356 | test('chunkString()', async () => { 357 | const tests: {values: any[][]; exportOptions: ExportOptions; expected: string}[] = [ 358 | { 359 | values: [[]], 360 | exportOptions: { 361 | delimiter: ',', 362 | newline: NewlineSequence.LF, 363 | }, 364 | expected: '\n', 365 | }, 366 | { 367 | values: [['a'], ['b']], 368 | exportOptions: { 369 | delimiter: ',', 370 | newline: NewlineSequence.LF, 371 | }, 372 | expected: 'a\nb\n', 373 | }, 374 | ]; 375 | 376 | for (const test of tests) { 377 | assert.strictEqual(chunkString(test.values, test.exportOptions), test.expected); 378 | } 379 | }); 380 | 381 | describe('csvString()', () => { 382 | test('normal operation', async () => { 383 | interface Test { 384 | shape: Shape; 385 | chunkRows: number; 386 | exportOptions: ExportOptions; 387 | chunks: any[][][]; 388 | expected: string; 389 | } 390 | 391 | const exportOptions: ExportOptions = { 392 | delimiter: ',', 393 | newline: NewlineSequence.LF, 394 | }; 395 | const tests: Test[] = [ 396 | // shape and chunkRows is never 0 397 | { 398 | shape: { 399 | rows: 1, 400 | columns: 1, 401 | }, 402 | chunkRows: 1, 403 | exportOptions, 404 | chunks: [[['']]], 405 | expected: '\n', 406 | }, 407 | { 408 | shape: { 409 | rows: 1, 410 | columns: 1, 411 | }, 412 | chunkRows: 2, 413 | exportOptions, 414 | chunks: [[['']]], 415 | expected: '\n', 416 | }, 417 | { 418 | shape: { 419 | rows: 2, 420 | columns: 1, 421 | }, 422 | chunkRows: 1, 423 | exportOptions, 424 | chunks: [[['a']], [['b']]], 425 | expected: 'a\nb\n', 426 | }, 427 | ]; 428 | 429 | for (const test of tests) { 430 | let chunk = 0; 431 | const worksheetStub: any = { 432 | getRangeByIndexes: ( 433 | startRow: number, 434 | startColumn: number, 435 | rowCount: number, 436 | columnCount: number, 437 | ) => { 438 | const expectedRange = chunkRange( 439 | chunk, 440 | test.shape, 441 | test.chunkRows, 442 | ); 443 | assert.strictEqual(startRow, expectedRange.startRow); 444 | assert.strictEqual(startColumn, expectedRange.startColumn); 445 | assert.strictEqual(rowCount, expectedRange.rowCount); 446 | assert.strictEqual(columnCount, expectedRange.columnCount); 447 | return {load: () => ({values: test.chunks[chunk++]})}; 448 | }, 449 | context: {sync: async () => {}}, 450 | }; 451 | 452 | const result = await csvString( 453 | worksheetStub, 454 | test.shape, 455 | test.chunkRows, 456 | test.exportOptions, 457 | () => {}, 458 | new AbortFlag(), 459 | ); 460 | assert.strictEqual(result, test.expected); 461 | } 462 | }); 463 | 464 | test('progressCallback', async () => { 465 | const worksheetStub: any = { 466 | getRangeByIndexes: () => ({load: () => ({values: [[]]})}), 467 | context: {sync: async () => {}}, 468 | }; 469 | 470 | const shape: Shape = {rows: 2, columns: 1}; 471 | 472 | const options: ExportOptions = { 473 | delimiter: ',', 474 | newline: NewlineSequence.LF, 475 | }; 476 | let called = 0; 477 | const progressCallback = (progress): void => { 478 | switch (called) { 479 | case 0: 480 | assert.strictEqual(progress, 0.0); 481 | break; 482 | case 1: 483 | assert.strictEqual(progress, 0.5); 484 | break; 485 | default: 486 | assert.fail('called too many times'); 487 | } 488 | ++called; 489 | }; 490 | 491 | await csvString( 492 | worksheetStub, 493 | shape, 494 | 1, 495 | options, 496 | progressCallback, 497 | new AbortFlag(), 498 | ); 499 | assert.strictEqual(called, 2); 500 | }); 501 | 502 | test('abort', async () => { 503 | const worksheetStub: any = { 504 | getRangeByIndexes: () => ({load: () => ({values: [['a']]})}), 505 | context: {sync: async () => {}}, 506 | }; 507 | 508 | const shape: Shape = {rows: 1, columns: 1}; 509 | 510 | const options = { 511 | delimiter: ',', 512 | newline: NewlineSequence.LF, 513 | }; 514 | 515 | const flag0 = new AbortFlag(); 516 | const result0 = await csvString(worksheetStub, shape, 1, options, () => {}, flag0); 517 | assert.strictEqual(result0, 'a\n'); 518 | 519 | const flag1 = new AbortFlag(); 520 | flag1.abort(); 521 | const result1 = await csvString(worksheetStub, shape, 1, options, () => {}, flag1); 522 | assert.strictEqual(result1, ''); 523 | }); 524 | }); 525 | 526 | test('nameToUse()', () => { 527 | const tests: {workbookName: string; worksheetName: string; expected: string}[] = [ 528 | { 529 | workbookName: '', 530 | worksheetName: '', 531 | expected: '', 532 | }, 533 | { 534 | workbookName: 'workbook', 535 | worksheetName: 'Sheet11', 536 | expected: 'workbook', 537 | }, 538 | { 539 | workbookName: 'workbook.', 540 | worksheetName: 'Sheet11', 541 | expected: 'workbook', 542 | }, 543 | { 544 | workbookName: '.xlsx', 545 | worksheetName: 'Sheet11', 546 | expected: '', 547 | }, 548 | { 549 | workbookName: 'workbook', 550 | worksheetName: 'Sheet', 551 | expected: 'Sheet', 552 | }, 553 | ]; 554 | 555 | for (const test of tests) { 556 | assert.strictEqual(nameToUse(test.workbookName, test.worksheetName), test.expected); 557 | } 558 | }); 559 | }); 560 | --------------------------------------------------------------------------------