Improve spreadsheet import flow (#839)

* feat: add dropdown to worksheet selection

* feat: move revoke to separate button

* refactor: extract excel flow to separate controller and service

* chore: show spinner when uploading sheet and disable the other option

* chore: don't show "success" when canceling the import

* chore: export boolean types properly to sheets
This commit is contained in:
Alex Christoffer Rasmussen
2024-03-22 21:33:11 +01:00
committed by GitHub
parent f4692db021
commit f5e9a2549e
33 changed files with 528 additions and 235 deletions
@@ -8,7 +8,6 @@ import {
} from 'ontime-types';
import type { Request, Response } from 'express';
import fs from 'fs';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { resolveDbPath, resolveProjectsDirectory } from '../../setup/index.js';
@@ -17,7 +16,6 @@ import * as projectService from '../../services/project-service/ProjectService.j
import { ensureJsonExtension } from '../../utils/fileManagement.js';
import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js';
import { appStateService } from '../../services/app-state-service/AppStateService.js';
import { handleMaybeExcel } from '../../utils/parser.js';
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
// all fields are optional in validation
@@ -245,27 +243,3 @@ export async function getInfo(_req: Request, res: Response<GetInfo>) {
const info = await projectService.getInfo();
res.status(200).send(info);
}
/**
* uploads and parses an excel spreadsheet
* @returns parsed result
*/
export async function previewSpreadsheet(req: Request, res: Response) {
if (!req.file) {
res.status(400).send({ message: 'File not found' });
return;
}
try {
const filePath = req.file.path;
if (!fs.existsSync(filePath)) {
throw new Error('Upload failed');
}
const options = JSON.parse(req.body.options);
const { data } = handleMaybeExcel(filePath, options);
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
+1 -14
View File
@@ -1,7 +1,7 @@
import { Request } from 'express';
import multer, { FileFilterCallback } from 'multer';
import { EXCEL_MIME, JSON_MIME } from '../../utils/parser.js';
import { JSON_MIME } from '../../utils/parser.js';
import { storage } from '../../utils/upload.js';
const filterProjectFile = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
@@ -12,21 +12,8 @@ const filterProjectFile = (_req: Request, file: Express.Multer.File, cb: FileFil
}
};
const filterSpreadsheet = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(EXCEL_MIME)) {
cb(null, true);
} else {
cb(null, false);
}
};
// Build multer uploader for a single file
export const uploadProjectFile = multer({
storage,
fileFilter: filterProjectFile,
}).single('project');
export const uploadSpreadsheet = multer({
storage,
fileFilter: filterSpreadsheet,
}).single('spreadsheet');
+5 -9
View File
@@ -2,18 +2,17 @@ import express from 'express';
import {
createProjectFile,
projectDownload,
deleteProjectFile,
duplicateProjectFile,
getInfo,
listProjects,
patchPartialProjectFile,
previewSpreadsheet,
loadProject,
duplicateProjectFile,
renameProjectFile,
patchPartialProjectFile,
postProjectFile,
projectDownload,
renameProjectFile,
} from './db.controller.js';
import { uploadProjectFile, uploadSpreadsheet } from './db.middleware.js';
import { uploadProjectFile } from './db.middleware.js';
import {
projectSanitiser,
sanitizeProjectFilename,
@@ -39,6 +38,3 @@ router.put('/:filename/rename', validateProjectRename, sanitizeProjectFilename,
router.delete('/:filename', sanitizeProjectFilename, deleteProjectFile);
router.get('/info', getInfo);
// TODO: validate import map
router.post('/spreadsheet/preview', uploadSpreadsheet, previewSpreadsheet);
@@ -0,0 +1,40 @@
/**
* This module encapsulates logic related to
* Google Sheets
*/
import { Request, Response } from 'express';
import { generateRundownPreview, listWorksheets, saveExcelFile } from './excel.service.js';
export async function postExcel(req: Request, res: Response) {
try {
const filePath = req.file.path;
await saveExcelFile(filePath);
res.status(200).send();
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
export async function getWorksheets(req: Request, res: Response) {
try {
const names = listWorksheets();
res.status(200).send(names);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
/**
* parses an Excel spreadsheet
* @returns parsed result
*/
export async function previewExcel(req: Request, res: Response) {
try {
const { options } = req.body;
const data = generateRundownPreview(options);
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
@@ -0,0 +1,17 @@
import { Request } from 'express';
import multer, { FileFilterCallback } from 'multer';
import { EXCEL_MIME } from '../../utils/parser.js';
import { storage } from '../../utils/upload.js';
const filterExcel = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(EXCEL_MIME)) {
cb(null, true);
} else {
cb(null, false);
}
};
export const uploadExcel = multer({
storage,
fileFilter: filterExcel,
}).single('excel');
@@ -0,0 +1,16 @@
/**
* This is a feature specific router for integration with Excel
*/
import express from 'express';
import { uploadExcel } from './excel.middleware.js';
import { getWorksheets, postExcel, previewExcel } from './excel.controller.js';
import { validateFileExists, validateImportMapOptions } from './excel.validation.js';
export const router = express.Router();
router.post('/upload', uploadExcel, validateFileExists, postExcel);
router.get('/worksheets', getWorksheets);
router.post('/preview', validateImportMapOptions, previewExcel);
// TODO: validate import map
@@ -0,0 +1,53 @@
/**
* This module encapsulates logic related to
* Google Sheets
*/
import { extname } from 'path';
import { existsSync } from 'fs';
import { ImportMap } from 'ontime-utils';
import xlsx from 'node-xlsx';
import { parseExcel } from '../../utils/parser.js';
import { parseCustomFields, parseRundown } from '../../utils/parserFunctions.js';
import { deleteFile } from '../../utils/parserUtils.js';
let excelData: { name: string; data: unknown[][] }[] = [];
export async function saveExcelFile(filePath: string) {
if (!existsSync(filePath)) {
throw new Error('Upload of excel file failed');
}
if (extname(filePath) != '.xlsx') {
throw new Error('Wrong file format');
}
excelData = xlsx.parse(filePath, { cellDates: true });
await deleteFile(filePath);
}
export function listWorksheets() {
return excelData.map((value) => value.name);
}
export function generateRundownPreview(options: ImportMap) {
const data = excelData.find(({ name }) => name.toLowerCase() === options.worksheet.toLowerCase())?.data;
if (!data) {
throw new Error(`Could not find data to import, maybe the worksheet name is incorrect: ${options.worksheet}`);
}
const dataFromExcel = parseExcel(data, options);
// we run the parsed data through an extra step to ensure the objects shape
const result = { rundown: [], customFields: {} };
result.rundown = parseRundown(dataFromExcel);
if (result.rundown.length < 1) {
throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`);
}
result.customFields = parseCustomFields(dataFromExcel);
//clear the data
excelData = [];
return result;
}
@@ -0,0 +1,28 @@
import { isImportMap } from 'ontime-utils';
import { body, validationResult } from 'express-validator';
import { NextFunction, Request, Response } from 'express';
export const validateFileExists = [
(req: Request, res: Response, next: NextFunction) => {
if (!req.file) {
return res.status(422).json({ errors: 'File not found' });
}
next();
},
];
export const validateImportMapOptions = [
body('options')
.exists()
.isObject()
.custom((content) => {
return isImportMap(content);
}),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
+2
View File
@@ -9,6 +9,7 @@ import { router as projectRouter } from './project/project.router.js';
import { router as rundownRouter } from './rundown/rundown.router.js';
import { router as settingsRouter } from './settings/settings.router.js';
import { router as sheetsRouter } from './sheets/sheets.router.js';
import { router as excelRouter } from './excel/excel.router.js';
import { router as viewSettingsRouter } from './view-settings/viewSettings.router.js';
export const appRouter = express.Router();
@@ -21,5 +22,6 @@ appRouter.use('/project', projectRouter);
appRouter.use('/rundown', rundownRouter);
appRouter.use('/settings', settingsRouter);
appRouter.use('/sheets', sheetsRouter);
appRouter.use('/excel', excelRouter);
appRouter.use('/url-presets', urlPresetsRouter);
appRouter.use('/view-settings', viewSettingsRouter);
@@ -14,6 +14,7 @@ import {
hasAuth,
download,
upload,
getWorksheetOptions,
} from '../../services/sheet-service/SheetService.js';
export async function requestConnection(req: Request, res: Response) {
@@ -56,6 +57,16 @@ export async function revokeAuthentication(_req: Request, res: Response) {
}
}
export async function getWorksheetNamesFromSheet(req: Request, res: Response) {
try {
const { sheetId } = req.params;
const { worksheetOptions } = await getWorksheetOptions(sheetId);
res.status(200).send(worksheetOptions);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
export async function readFromSheet(req: Request, res: Response) {
try {
const { sheetId } = req.params;
@@ -5,6 +5,7 @@
import express from 'express';
import {
getWorksheetNamesFromSheet,
readFromSheet,
requestConnection,
revokeAuthentication,
@@ -12,7 +13,7 @@ import {
writeToSheet,
} from './sheets.controller.js';
import { uploadClientSecret } from './sheets.middleware.js';
import { validateRequestConnection, validateSheetOptions } from './sheets.validation.js';
import { validateRequestConnection, validateSheetId, validateSheetOptions } from './sheets.validation.js';
export const router = express.Router();
@@ -21,5 +22,7 @@ router.post('/:sheetId/connect', uploadClientSecret, validateRequestConnection,
router.post('/revoke', revokeAuthentication);
router.post('/:sheetId/worksheets', validateSheetId, getWorksheetNamesFromSheet);
router.post('/:sheetId/read', validateSheetOptions, readFromSheet);
router.post('/:sheetId/write', validateSheetOptions, writeToSheet);
@@ -20,6 +20,16 @@ export const validateRequestConnection = [
},
];
export const validateSheetId = [
param('sheetId').exists().isString(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateSheetOptions = [
param('sheetId').exists().isString(),
body('options')
@@ -12,7 +12,7 @@ import got from 'got';
import { resolveSheetsDirectory } from '../../setup/index.js';
import { ensureDirectory } from '../../utils/fileManagement.js';
import { type ClientSecret, cellRequestFromEvent, getA1Notation, validateClientSecret } from './sheetUtils.js';
import { cellRequestFromEvent, type ClientSecret, getA1Notation, validateClientSecret } from './sheetUtils.js';
import { ImportMap } from 'ontime-utils';
import { parseExcel } from '../../utils/parser.js';
import { logger } from '../../classes/Logger.js';
@@ -190,11 +190,11 @@ function verifyConnection(
}
}
export function hasAuth(): { authenticated: AuthenticationStatus } {
export function hasAuth(): { authenticated: AuthenticationStatus; sheetId: string } {
if (cleanupTimeout) {
return { authenticated: 'pending' };
return { authenticated: 'pending', sheetId: currentSheetId };
}
return { authenticated: currentAuthClient ? 'authenticated' : 'not_authenticated' };
return { authenticated: currentAuthClient ? 'authenticated' : 'not_authenticated', sheetId: currentSheetId };
}
async function verifySheet(
@@ -108,7 +108,7 @@ describe('cellRequestFromEvent()', () => {
expect(result).toStrictEqual(millisToString(event.duration));
});
test('boolean to x', () => {
test('boolean to TRUE', () => {
const event: OntimeEvent = {
type: SupportedEvent.Event,
cue: '1',
@@ -149,8 +149,8 @@ describe('cellRequestFromEvent()', () => {
timeDanger: { row: 1, col: 41 },
};
const result = cellRequestFromEvent(event, 1, 1234, metadata);
expect(result.updateCells.rows[0].values[11].userEnteredValue.stringValue).toStrictEqual('x');
expect(result.updateCells.rows[0].values[12].userEnteredValue.stringValue).toStrictEqual('');
expect(result.updateCells.rows[0].values[11].userEnteredValue.boolValue).toStrictEqual(true);
expect(result.updateCells.rows[0].values[12].userEnteredValue.boolValue).toStrictEqual(false);
});
test('spacing in metadata', () => {
@@ -1,4 +1,4 @@
import { OntimeRundownEntry, isOntimeBlock, isOntimeEvent } from 'ontime-types';
import { isOntimeBlock, isOntimeEvent, OntimeRundownEntry } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { sheets_v4 } from '@googleapis/sheets';
@@ -111,7 +111,7 @@ export function cellRequestFromEvent(
});
} else if (typeof event[key] === 'boolean') {
returnRows.push({
userEnteredValue: { stringValue: event[key] ? 'x' : '' },
userEnteredValue: { boolValue: event[key] },
});
} else {
returnRows.push({});
+64 -20
View File
@@ -5,20 +5,20 @@ import {
DatabaseModel,
EndAction,
OntimeEvent,
OntimeRundown,
ProjectData,
Settings,
SupportedEvent,
TimeStrategy,
TimerType,
TimeStrategy,
ViewSettings,
OntimeRundown,
} from 'ontime-types';
import { dbModel } from '../../models/dataModel.js';
import { parseExcel, parseJson, createEvent, getCustomFieldData } from '../parser.js';
import { createEvent, getCustomFieldData, parseExcel, parseJson } from '../parser.js';
import { makeString } from '../parserUtils.js';
import { parseUrlPresets, parseViewSettings } from '../parserFunctions.js';
import { parseRundown, parseUrlPresets, parseViewSettings } from '../parserFunctions.js';
describe('test json parser with valid def', () => {
const testData: Partial<DatabaseModel> = {
@@ -795,8 +795,8 @@ describe('parseExcel()', () => {
'Public',
'Skip',
'Notes',
'test0',
'test1',
't0',
'UpperCaseFromSheet',
'test2',
'test3',
'test4',
@@ -809,8 +809,8 @@ describe('parseExcel()', () => {
'cue',
],
[
'1899-12-30T07:00:00.000Z',
'1899-12-30T08:00:10.000Z',
'07:00:00',
'08:00:10',
'Guest Welcome',
'',
'',
@@ -831,8 +831,8 @@ describe('parseExcel()', () => {
101,
],
[
'1899-12-30T08:00:00.000Z',
'1899-12-30T08:30:00.000Z',
'08:00:00',
'08:30:00',
'A song from the hearth',
'load-next',
'clock',
@@ -858,9 +858,9 @@ describe('parseExcel()', () => {
// partial import map with only custom fields
const importMap = {
custom: {
user0: 'test0',
user1: 'test1',
user2: 'test2',
user0: 't0',
user1: 'UpperCaseFromSheet',
UpperCaseFromOntime: 'test2',
user3: 'test3',
user4: 'test4',
user5: 'test5',
@@ -874,8 +874,8 @@ describe('parseExcel()', () => {
// TODO: update tests once import is resolved
const expectedParsedRundown = [
{
//timeStart: 28800000,
//timeEnd: 32410000,
timeStart: 25200000,
timeEnd: 28810000,
title: 'Guest Welcome',
timerType: 'count-down',
endAction: 'none',
@@ -885,7 +885,7 @@ describe('parseExcel()', () => {
custom: {
user0: { value: 'a0' },
user1: { value: 'a1' },
user2: { value: 'a2' },
UpperCaseFromOntime: { value: 'a2' },
user3: { value: 'a3' },
user4: { value: 'a4' },
user5: { value: 'a5' },
@@ -899,8 +899,8 @@ describe('parseExcel()', () => {
cue: '101',
},
{
//timeStart: 32400000,
//timeEnd: 34200000,
timeStart: 28800000,
timeEnd: 30600000,
title: 'A song from the hearth',
timerType: 'clock',
endAction: 'load-next',
@@ -929,10 +929,10 @@ describe('parseExcel()', () => {
colour: '',
label: 'user1',
},
user2: {
UpperCaseFromOntime: {
type: 'string',
colour: '',
label: 'user2',
label: 'UpperCaseFromOntime',
},
user3: {
type: 'string',
@@ -1363,4 +1363,48 @@ describe('parseExcel()', () => {
expect(result.rundown.at(1).type).toBe(SupportedEvent.Event);
expect((result.rundown.at(1) as OntimeEvent).timerType).toBe(TimerType.CountDown);
});
it('am/pm conversion to 24h', () => {
const testData = [
['Time Start', 'Time End', 'Title', 'End Action', 'Public', 'Skip', 'Notes', 'Colour', 'cue'],
['4:30:00', '4:36:00', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102],
['9:45:00', '10:56:00', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103],
['16:30:00', '16:36:00', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102],
['21:45:00', '22:56:00', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103],
['4:30:00AM', '4:36:00AM', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102],
['9:45:00AM', '10:56:00AM', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103],
['4:30:00PM', '4:36:00PM', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102],
['9:45:00PM', '10:56:00PM', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103],
[],
];
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
timeEnd: 'time end',
duration: 'duration',
cue: 'cue',
title: 'title',
isPublic: 'public',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {},
};
const result = parseExcel(testData, importMap);
const rundown = parseRundown(result);
const events = rundown.filter((e) => e.type === SupportedEvent.Event) as OntimeEvent[];
expect(events.at(0).timeStart).toEqual(16200000);
expect(events.at(1).timeStart).toEqual(35100000);
expect(events.at(2).timeStart).toEqual(59400000);
expect(events.at(3).timeStart).toEqual(78300000);
expect(events.at(4).timeStart).toEqual(16200000);
expect(events.at(5).timeStart).toEqual(35100000);
expect(events.at(6).timeStart).toEqual(59400000);
expect(events.at(7).timeStart).toEqual(78300000);
});
});
+1 -1
View File
@@ -37,7 +37,7 @@ describe('parseExcelDate', () => {
});
describe('parses a time string that passes validation', () => {
const validFields = ['10:00:00', '10:00'];
const validFields = ['10:00:00', '10:00', '10:00AM', '10:00am', '10:00PM', '10:00pm'];
validFields.forEach((field) => {
it(`handles ${field}`, () => {
const millis = parseExcelDate(field);
+25 -68
View File
@@ -1,40 +1,37 @@
import {
generateId,
isImportMap,
type ImportMap,
defaultImportMap,
validateEndAction,
validateTimerType,
type ImportOptions,
validateTimes,
generateId,
type ImportMap,
isKnownTimerType,
validateEndAction,
validateLinkStart,
validateTimerType,
validateTimes,
} from 'ontime-utils';
import {
CustomFields,
DatabaseModel,
EventCustomFields,
OntimeBlock,
OntimeEvent,
OntimeRundown,
SupportedEvent,
TimeStrategy,
CustomFields,
EventCustomFields,
TimerType,
TimeStrategy,
} from 'ontime-types';
import xlsx from 'node-xlsx';
import { event as eventDef } from '../models/eventsDefinition.js';
import { dbModel } from '../models/dataModel.js';
import { deleteFile, makeString } from './parserUtils.js';
import { makeString } from './parserUtils.js';
import {
parseUrlPresets,
parseProject,
parseOsc,
parseCustomFields,
parseHttp,
parseOsc,
parseProject,
parseRundown,
parseSettings,
parseUrlPresets,
parseViewSettings,
parseCustomFields,
} from './parserFunctions.js';
import { parseExcelDate } from './time.js';
import { coerceBoolean } from './coerceType.js';
@@ -192,10 +189,6 @@ export const parseExcel = (excelData: unknown[][], options?: Partial<ImportMap>)
}
} else if (j === titleIndex) {
event.title = makeString(column, '');
// if this is a block, we have nothing else to import
if (event.type === SupportedEvent.Block) {
continue;
}
} else if (j === timeStartIndex) {
event.timeStart = parseExcelDate(column);
} else if (j === timeEndIndex) {
@@ -237,8 +230,8 @@ export const parseExcel = (excelData: unknown[][], options?: Partial<ImportMap>)
}
// check if it is a custom field
if (columnText in customFieldImportKeys) {
handlers.custom(rowIndex, j, columnText);
if (column in customFieldImportKeys) {
handlers.custom(rowIndex, j, column);
}
// else. we don't know how to handle this column
@@ -250,11 +243,16 @@ export const parseExcel = (excelData: unknown[][], options?: Partial<ImportMap>)
// if any data was found in row, push to array
const keysFound = Object.keys(event).length + Object.keys(eventCustomFields).length;
if (keysFound > 0) {
if (timerTypeIndex === null) {
event.timerType = TimerType.CountDown;
event.type = SupportedEvent.Event;
// if it is a Block type drop all other filed
if (event.type === SupportedEvent.Block) {
rundown.push({ type: event.type, id: event.id, title: event.title } as OntimeBlock);
} else {
if (timerTypeIndex === null) {
event.timerType = TimerType.CountDown;
event.type = SupportedEvent.Event;
}
rundown.push({ ...event, custom: { ...eventCustomFields } });
}
rundown.push({ ...event, custom: { ...eventCustomFields } });
}
});
@@ -363,44 +361,3 @@ export const createEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: string
const event = createPatch(baseEvent, eventArgs);
return event;
};
type ResponseOK = {
data: Partial<DatabaseModel>;
};
/**
* Validates and calls parse on an excel file
*/
export function handleMaybeExcel(file: string, options: ImportOptions) {
const res: Partial<ResponseOK> = {};
if (!file.endsWith('.xlsx')) {
throw new Error('unexpected extension for spreadsheet');
}
// we need to check that the options are applicable
if (!isImportMap(options)) {
throw new Error('Got incorrect options for spreadsheet import');
}
const excelData = xlsx
.parse(file, { cellDates: true })
.find(({ name }) => name.toLowerCase() === options.worksheet.toLowerCase());
if (!excelData?.data) {
throw new Error(`Could not find data to import, maybe the worksheet name is incorrect: ${options.worksheet}`);
}
const dataFromExcel = parseExcel(excelData.data, options);
// we run the parsed data through an extra step to ensure the objects shape
res.data = {};
res.data.rundown = parseRundown(dataFromExcel);
if (res.data.rundown.length < 1) {
throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`);
}
res.data.customFields = parseCustomFields(dataFromExcel);
deleteFile(file);
return res;
}
+25 -7
View File
@@ -35,22 +35,40 @@ const parse = (valueAsString: string): number => {
return Math.abs(parsed);
};
const stripAMPM = (value: string) => {
const lowerValue = value.toLowerCase();
if (lowerValue.endsWith('am')) {
return { sansPostfix: lowerValue.substring(0, lowerValue.length - 2), pastNoon: false };
} else if (lowerValue.endsWith('pm')) {
return { sansPostfix: lowerValue.substring(0, lowerValue.length - 2), pastNoon: true };
} else {
return { sansPostfix: lowerValue, pastNoon: false };
}
};
/**
* @description Parses a time string to millis, copied from client code
* @param {string} value - time string
* @param {boolean} fillLeft - autofill left = hours / right = seconds
* @returns {number} - time string in millis
*/
export const forgivingStringToMillis = (value: string, fillLeft = true): number => {
export const forgivingStringToMillis = (value: string, fillLeft: boolean = true): number => {
let millis = 0;
// check for AM/PM indicators
const { sansPostfix, pastNoon } = stripAMPM(value);
//if past noon indicated add 12 hours
if (pastNoon) {
millis = mth * 12;
}
// split string at known separators : , .
const separatorRegex = /[\s,:.]+/;
const [first, second, third] = value.split(separatorRegex);
const [first, second, third] = sansPostfix.split(separatorRegex);
if (first != null && second != null && third != null) {
// if string has three sections, treat as [hours] [minutes] [seconds]
millis = parse(first) * mth;
millis += parse(first) * mth;
millis += parse(second) * mtm;
millis += parse(third) * mts;
} else if (first != null && second == null && third == null) {
@@ -60,23 +78,23 @@ export const forgivingStringToMillis = (value: string, fillLeft = true): number
const hours = first.substring(0, 2);
const minutes = first.substring(2, 4);
const seconds = first.substring(4);
millis = parse(hours) * mth;
millis += parse(hours) * mth;
millis += parse(minutes) * mtm;
millis += parse(seconds) * mts;
} else {
// otherwise lets treat as [minutes]
millis = parse(first) * mtm;
millis += parse(first) * mtm;
}
}
if (first != null && second != null && third == null) {
// if string has two sections
if (fillLeft) {
// treat as [hours] [minutes]
millis = parse(first) * mth;
millis += parse(first) * mth;
millis += parse(second) * mtm;
} else {
// treat as [minutes] [seconds]
millis = parse(first) * mtm;
millis += parse(first) * mtm;
millis += parse(second) * mts;
}
}