Skip to content

Commit 7f8e80b

Browse files
duranbdandelany
authored andcommitted
refactor(workspace): extract file extension utilities for reusability
Extracted file extension matching and replacement logic into reusable utility functions (`doesFilenameMatchExtension` and `replaceFileExtension`) to improve code consistency and maintainability. Changes: - Added `doesFilenameMatchExtension()` utility to standardize extension checking - Added `replaceFileExtension()` utility to handle filename extension replacement - Replaced inline extension matching logic across ImportWorkspaceFileModal, SequenceEditor, and effects - Added error handling for file conversion operations - Simplified filename manipulation in sequence editor download functionality This refactoring reduces code duplication and provides a single source of truth for file extension operations throughout the workspace handling code.
1 parent 2632b0f commit 7f8e80b

5 files changed

Lines changed: 218 additions & 21 deletions

File tree

src/components/modals/ImportWorkspaceFileModal.svelte

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import type { User } from '../../types/app';
88
import type { Workspace, WorkspaceNodeEvent } from '../../types/workspace';
99
import type { WorkspaceTreeNode } from '../../types/workspace-tree-view';
10-
import { joinPath, removeFirstPathSegment } from '../../utilities/workspaces.js';
10+
import { doesFilenameMatchExtension, joinPath, removeFirstPathSegment } from '../../utilities/workspaces.js';
1111
import InputInternal from '../form/Input.svelte';
1212
import WorkspaceTreeView from '../workspace/WorkspaceTreeView/WorkspaceTreeView.svelte';
1313
import Modal from './Modal.svelte';
@@ -52,9 +52,7 @@
5252
selectedFileGroupings = Array.from(files ?? []).reduce(
5353
(previousFileGroupings: { convertableFiles: File[]; uploadableFiles: File[] }, file) => {
5454
if (
55-
outputLanguageExtensions.findIndex(fileExtension =>
56-
file.name.endsWith(`.${fileExtension.replace(/^\./, '')}`),
57-
) > -1
55+
outputLanguageExtensions.findIndex(fileExtension => doesFilenameMatchExtension(fileExtension, file.name)) > -1
5856
) {
5957
return {
6058
...previousFileGroupings,

src/components/sequencing/SequenceEditor.svelte

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import { permissionHandler } from '../../utilities/permissionHandler';
2727
import { phoenixResources } from '../../utilities/sequence-editor/adaptation-resources';
2828
import { showFailureToast, showSuccessToast } from '../../utilities/toast';
29+
import { replaceFileExtension } from '../../utilities/workspaces';
2930
import CssGrid from '../ui/CssGrid.svelte';
3031
import CssGridGutter from '../ui/CssGridGutter.svelte';
3132
import Panel from '../ui/Panel.svelte';
@@ -241,13 +242,12 @@
241242
242243
function downloadOutputFormat(outputLanguage: OutputLanguage): void {
243244
const content = editorOutputView.state.doc.toString();
244-
// Remove any existing extension and add output extension
245-
const outputExt = outputLanguage.fileExtension; // Keep the dot
246-
const lastDotIndex = sequenceName.lastIndexOf('.');
247245
248-
// If there's a dot in the filename, remove everything after it; otherwise keep the whole name
249-
const filenameWithoutExt = lastDotIndex > 0 ? sequenceName.slice(0, lastDotIndex) : sequenceName;
250-
const filename = filenameWithoutExt + outputExt;
246+
const filename = replaceFileExtension(
247+
sequenceName,
248+
sequenceAdaptation.input.fileExtension,
249+
outputLanguage.fileExtension,
250+
);
251251
252252
dispatch('downloadOutput', { content, filePath: sequenceFilePath, filename, outputLanguage });
253253
}

src/utilities/effects.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@ import {
320320
} from './view';
321321
import {
322322
cleanPath,
323+
doesFilenameMatchExtension,
323324
findNodeInDirectory,
324325
flattenWorkspaceTreeWithPaths,
325326
getWorkspaceFileFolderDisplay,
@@ -328,6 +329,7 @@ import {
328329
isFileConflictResponse,
329330
joinPath,
330331
mapWorkspaceTreePaths,
332+
replaceFileExtension,
331333
separateFilenameFromPath,
332334
WorkspaceApi,
333335
type BulkOperationResponses,
@@ -6057,20 +6059,28 @@ const effects = {
60576059
const convertedFiles: File[] = await Promise.all(
60586060
filesToConvert.map(async file => {
60596061
const outputLanguage = sequenceAdaptation.outputs.find(language =>
6060-
file.name.endsWith(`.${language.fileExtension.replace(/^\./, '')}`),
6062+
doesFilenameMatchExtension(language.fileExtension, file.name),
60616063
);
60626064

60636065
if (outputLanguage) {
6064-
const fileName = file.name.replace(
6065-
outputLanguage.fileExtension.replace(/^\./, ''),
6066-
sequenceAdaptation.input.fileExtension.replace(/^\./, ''),
6067-
);
6068-
const lastModified = Date.now();
6069-
const content = await file.text();
6070-
const convertedContent = outputLanguage.toInputFormat(content, phoenixContext, fileName);
6071-
6072-
convertedFileMap[file.name] = fileName;
6073-
return new File([convertedContent], fileName, { lastModified, type: 'text/plain' });
6066+
try {
6067+
const fileName = replaceFileExtension(
6068+
file.name,
6069+
outputLanguage.fileExtension,
6070+
sequenceAdaptation.input.fileExtension,
6071+
);
6072+
const lastModified = Date.now();
6073+
const content = await file.text();
6074+
const convertedContent = outputLanguage.toInputFormat(content, phoenixContext, fileName);
6075+
6076+
convertedFileMap[file.name] = fileName;
6077+
return new File([convertedContent], fileName, { lastModified, type: 'text/plain' });
6078+
} catch (error) {
6079+
throw Error(
6080+
`There was an error trying to convert the file ${file.name}. Please check that it is formatted correctly.`,
6081+
{ cause: error },
6082+
);
6083+
}
60746084
}
60756085

60766086
return file;

src/utilities/workspaces.test.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
computeMovedFilePath,
1010
computeTreeFilter,
1111
defaultTreeSortComparator,
12+
doesFilenameMatchExtension,
1213
findNodeByPath,
1314
findNodeInDirectory,
1415
flattenWorkspaceTreeWithPaths,
@@ -21,6 +22,7 @@ import {
2122
mapWorkspaceTreePaths,
2223
removeFirstPathSegment,
2324
removeRedundantNodes,
25+
replaceFileExtension,
2426
separateFilenameFromPath,
2527
shouldNodeBeVisible,
2628
sortWorkspaceTree,
@@ -1383,6 +1385,95 @@ describe('Workspace utility function tests', () => {
13831385
});
13841386
});
13851387

1388+
describe('doesFileMatchExtension', () => {
1389+
test('Should match extension without leading dot', () => {
1390+
expect(doesFilenameMatchExtension('txt', 'file.txt')).toBe(true);
1391+
expect(doesFilenameMatchExtension('seq', 'test.seq')).toBe(true);
1392+
expect(doesFilenameMatchExtension('json', 'data.json')).toBe(true);
1393+
});
1394+
1395+
test('Should match extension with leading dot', () => {
1396+
expect(doesFilenameMatchExtension('.txt', 'file.txt')).toBe(true);
1397+
expect(doesFilenameMatchExtension('.seq', 'test.seq')).toBe(true);
1398+
expect(doesFilenameMatchExtension('.json', 'data.json')).toBe(true);
1399+
});
1400+
1401+
test('Should be case-insensitive', () => {
1402+
expect(doesFilenameMatchExtension('TXT', 'file.txt')).toBe(true);
1403+
expect(doesFilenameMatchExtension('txt', 'file.TXT')).toBe(true);
1404+
expect(doesFilenameMatchExtension('TxT', 'file.TxT')).toBe(true);
1405+
expect(doesFilenameMatchExtension('SEQ', 'test.seq')).toBe(true);
1406+
});
1407+
1408+
test('Should not match wrong extension', () => {
1409+
expect(doesFilenameMatchExtension('txt', 'file.json')).toBe(false);
1410+
expect(doesFilenameMatchExtension('seq', 'test.txt')).toBe(false);
1411+
expect(doesFilenameMatchExtension('json', 'data.xml')).toBe(false);
1412+
});
1413+
1414+
test('Should only match extension at the end of filename', () => {
1415+
expect(doesFilenameMatchExtension('txt', 'file.txt.bak')).toBe(false);
1416+
expect(doesFilenameMatchExtension('txt', 'txt.json')).toBe(false);
1417+
});
1418+
1419+
test('Should handle files with multiple dots', () => {
1420+
expect(doesFilenameMatchExtension('js', 'script.min.js')).toBe(true);
1421+
expect(doesFilenameMatchExtension('json', 'config.test.json')).toBe(true);
1422+
expect(doesFilenameMatchExtension('txt', 'my.file.name.txt')).toBe(true);
1423+
});
1424+
1425+
test('Should not match files without extension', () => {
1426+
expect(doesFilenameMatchExtension('txt', 'README')).toBe(false);
1427+
expect(doesFilenameMatchExtension('js', 'Makefile')).toBe(false);
1428+
});
1429+
1430+
test('Should not match partial extension', () => {
1431+
expect(doesFilenameMatchExtension('tx', 'file.txt')).toBe(false);
1432+
expect(doesFilenameMatchExtension('t', 'file.txt')).toBe(false);
1433+
expect(doesFilenameMatchExtension('son', 'file.json')).toBe(false);
1434+
});
1435+
1436+
test('Should handle empty filename', () => {
1437+
expect(doesFilenameMatchExtension('txt', '')).toBe(false);
1438+
});
1439+
1440+
test('Should handle empty extension', () => {
1441+
expect(doesFilenameMatchExtension('', 'file.')).toBe(true); // matches files ending with a dot
1442+
expect(doesFilenameMatchExtension('', 'file.txt')).toBe(false);
1443+
expect(doesFilenameMatchExtension('', 'file')).toBe(false);
1444+
});
1445+
1446+
test('Should handle filename with path', () => {
1447+
expect(doesFilenameMatchExtension('txt', 'path/to/file.txt')).toBe(true);
1448+
expect(doesFilenameMatchExtension('seq', 'folder/subfolder/test.seq')).toBe(true);
1449+
expect(doesFilenameMatchExtension('json', 'a/b/c/data.json')).toBe(true);
1450+
});
1451+
1452+
test('Should handle hidden files (starting with dot)', () => {
1453+
expect(doesFilenameMatchExtension('json', '.config.json')).toBe(true);
1454+
expect(doesFilenameMatchExtension('txt', '.gitignore.txt')).toBe(true);
1455+
});
1456+
1457+
test('Should not match if extension appears in middle of filename', () => {
1458+
expect(doesFilenameMatchExtension('txt', 'file-txt.json')).toBe(false);
1459+
expect(doesFilenameMatchExtension('seq', 'sequential.js')).toBe(false);
1460+
});
1461+
1462+
test('Should handle special regex characters in extension', () => {
1463+
// Note: The function does NOT escape regex special characters
1464+
// So these are treated as regex patterns, not literal strings
1465+
expect(doesFilenameMatchExtension('t*t', 'file.tt')).toBe(true); // t*t means "zero or more t" + "t"
1466+
expect(doesFilenameMatchExtension('t+t', 'file.ttt')).toBe(true); // t+t means "one or more t" + "t"
1467+
expect(doesFilenameMatchExtension('t*t', 'file.t')).toBe(true); // matches .t (zero t's + t)
1468+
});
1469+
1470+
test('Should match exact extension only', () => {
1471+
expect(doesFilenameMatchExtension('js', 'file.js')).toBe(true);
1472+
expect(doesFilenameMatchExtension('js', 'file.json')).toBe(false);
1473+
expect(doesFilenameMatchExtension('js', 'file.jsx')).toBe(false);
1474+
});
1475+
});
1476+
13861477
describe('getCommonPathPrefix', () => {
13871478
test('Should return empty string for empty array', () => {
13881479
const result = getCommonPathPrefix([]);
@@ -1434,4 +1525,91 @@ describe('Workspace utility function tests', () => {
14341525
expect(result).toBe('a/b');
14351526
});
14361527
});
1528+
1529+
describe('replaceFileExtension', () => {
1530+
test('Should replace extension without leading dots', () => {
1531+
expect(replaceFileExtension('file.txt', 'txt', 'json')).toBe('file.json');
1532+
expect(replaceFileExtension('test.seq.json', 'seq.json', 'seqN.txt')).toBe('test.seqN.txt');
1533+
expect(replaceFileExtension('data.xml', 'xml', 'json')).toBe('data.json');
1534+
});
1535+
1536+
test('Should replace extension with leading dots', () => {
1537+
expect(replaceFileExtension('file.txt', '.txt', '.json')).toBe('file.json');
1538+
expect(replaceFileExtension('test.seq', '.seq', '.seqN')).toBe('test.seqN');
1539+
expect(replaceFileExtension('data.xml', '.xml', '.json')).toBe('data.json');
1540+
});
1541+
1542+
test('Should replace extension with mixed dot usage', () => {
1543+
expect(replaceFileExtension('file.txt', 'txt', '.json')).toBe('file.json');
1544+
expect(replaceFileExtension('test.seq', '.seq', 'seqN')).toBe('test.seqN');
1545+
});
1546+
1547+
test('Should be case-insensitive when matching extension', () => {
1548+
expect(replaceFileExtension('file.TXT', 'txt', 'json')).toBe('file.json');
1549+
expect(replaceFileExtension('test.SEQ', 'seq', 'seqN')).toBe('test.seqN');
1550+
expect(replaceFileExtension('data.Xml', 'XML', 'json')).toBe('data.json');
1551+
});
1552+
1553+
test('Should not replace if extension does not match', () => {
1554+
expect(replaceFileExtension('file.txt', 'json', 'xml')).toBe('file.txt');
1555+
expect(replaceFileExtension('test.seq', 'txt', 'json')).toBe('test.seq');
1556+
expect(replaceFileExtension('data.xml', 'seq', 'json')).toBe('data.xml');
1557+
});
1558+
1559+
test('Should only replace extension at the end of filename', () => {
1560+
expect(replaceFileExtension('file.txt.bak', 'txt', 'json')).toBe('file.txt.bak');
1561+
expect(replaceFileExtension('test.seq.old', 'seq', 'seqN')).toBe('test.seq.old');
1562+
});
1563+
1564+
test('Should handle files with multiple dots correctly', () => {
1565+
expect(replaceFileExtension('script.min.js', 'min.js', 'ts')).toBe('script.ts');
1566+
expect(replaceFileExtension('config.test.json', 'json', 'xml')).toBe('config.test.xml');
1567+
expect(replaceFileExtension('my.file.name.txt', 'txt', 'md')).toBe('my.file.name.md');
1568+
});
1569+
1570+
test('Should not replace if filename has no extension', () => {
1571+
expect(replaceFileExtension('README', 'txt', 'md')).toBe('README');
1572+
expect(replaceFileExtension('Makefile', 'txt', 'json')).toBe('Makefile');
1573+
});
1574+
1575+
test('Should handle files with path', () => {
1576+
expect(replaceFileExtension('path/to/file.txt', 'txt', 'json')).toBe('path/to/file.json');
1577+
expect(replaceFileExtension('folder/subfolder/test.seq', 'seq', 'seqN')).toBe('folder/subfolder/test.seqN');
1578+
expect(replaceFileExtension('a/b/c/data.xml', 'xml', 'json')).toBe('a/b/c/data.json');
1579+
});
1580+
1581+
test('Should handle hidden files (starting with dot)', () => {
1582+
expect(replaceFileExtension('.config.json', 'json', 'xml')).toBe('.config.xml');
1583+
expect(replaceFileExtension('.gitignore.txt', 'txt', 'bak')).toBe('.gitignore.bak');
1584+
});
1585+
1586+
test('Should not match partial extension', () => {
1587+
expect(replaceFileExtension('file.txt', 'tx', 'json')).toBe('file.txt');
1588+
expect(replaceFileExtension('file.txt', 't', 'json')).toBe('file.txt');
1589+
expect(replaceFileExtension('file.json', 'son', 'xml')).toBe('file.json');
1590+
});
1591+
1592+
test('Should not replace extension in middle of filename', () => {
1593+
expect(replaceFileExtension('file-txt.json', 'txt', 'xml')).toBe('file-txt.json');
1594+
expect(replaceFileExtension('sequential.js', 'seq', 'seqN')).toBe('sequential.js');
1595+
});
1596+
1597+
test('Should handle empty filename', () => {
1598+
expect(replaceFileExtension('', 'txt', 'json')).toBe('');
1599+
});
1600+
1601+
test('Should preserve target extension case', () => {
1602+
expect(replaceFileExtension('file.txt', 'txt', 'JSON')).toBe('file.JSON');
1603+
expect(replaceFileExtension('test.seq', 'seq', 'SeqN')).toBe('test.SeqN');
1604+
});
1605+
1606+
test('Should handle complex file extensions', () => {
1607+
expect(replaceFileExtension('archive.tar.gz', 'gz', 'bz2')).toBe('archive.tar.bz2');
1608+
expect(replaceFileExtension('script.min.js', 'min.js', 'js')).toBe('script.js');
1609+
});
1610+
1611+
test('Should replace when only extension matches without filename', () => {
1612+
expect(replaceFileExtension('.txt', 'txt', 'json')).toBe('.json');
1613+
});
1614+
});
14371615
});

src/utilities/workspaces.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,17 @@ export function isFileConflictResponse(response: BulkOperationResponse) {
366366
return response.status === 409;
367367
}
368368

369+
export function doesFilenameMatchExtension(extension: string, filename: string) {
370+
return new RegExp(`\\.${extension.replace(/^\./, '')}$`, 'i').test(filename);
371+
}
372+
373+
export function replaceFileExtension(filename: string, fromExtension: string, toExtension: string) {
374+
return filename.replace(
375+
new RegExp(`\\.${fromExtension.replace(/^\./, '')}$`, 'i'),
376+
`.${toExtension.replace(/^\./, '')}`,
377+
);
378+
}
379+
369380
export const WorkspaceApi = {
370381
async createFolder(workspaceId: number, folderPath: string, user: User | null) {
371382
return reqWorkspace<Workspace>(`${workspaceId}/${folderPath}?type=directory`, 'PUT', null, user, undefined, false);

0 commit comments

Comments
 (0)