Skip to content

Commit 9d455cf

Browse files
committed
feat(state): support standalone JSON state files without zip wrapper
State files can now be loaded as plain JSON files, not just zipped. This enables simpler workflows when data is referenced via URIs.
1 parent 9613f05 commit 9d455cf

3 files changed

Lines changed: 66 additions & 16 deletions

File tree

src/io/import/processors/restoreStateFile.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -146,20 +146,34 @@ export async function completeStateFileRestore(
146146
useToolStore().deserialize(manifest, segmentGroupIDMap, stateIDToStoreID);
147147
}
148148

149-
export const restoreStateFile: ImportHandler = async (dataSource) => {
150-
if (dataSource.type === 'file' && (await isStateFile(dataSource.file))) {
151-
const stateFileContents = await extractFilesFromZip(dataSource.file);
149+
async function parseManifestFromZip(file: File) {
150+
const stateFileContents = await extractFilesFromZip(file);
152151

153-
const [manifests, restOfStateFile] = partition(
154-
(dataFile) => dataFile.file.name === MANIFEST,
155-
stateFileContents
156-
);
152+
const [manifests, restOfStateFile] = partition(
153+
(dataFile) => dataFile.file.name === MANIFEST,
154+
stateFileContents
155+
);
157156

158-
if (manifests.length !== 1) {
159-
throw new Error('State file does not have exactly 1 manifest');
160-
}
157+
if (manifests.length !== 1) {
158+
throw new Error('State file does not have exactly 1 manifest');
159+
}
160+
161+
const manifestString = await manifests[0].file.text();
162+
return { manifestString, stateFiles: restOfStateFile };
163+
}
164+
165+
async function parseManifestFromJson(file: File) {
166+
const manifestString = await file.text();
167+
return { manifestString, stateFiles: [] as FileEntry[] };
168+
}
169+
170+
export const restoreStateFile: ImportHandler = async (dataSource) => {
171+
if (dataSource.type === 'file' && (await isStateFile(dataSource.file))) {
172+
const isJson = dataSource.fileType === 'application/json';
173+
const { manifestString, stateFiles } = isJson
174+
? await parseManifestFromJson(dataSource.file)
175+
: await parseManifestFromZip(dataSource.file);
161176

162-
const manifestString = await manifests[0].file.text();
163177
const migrated = migrateManifest(manifestString);
164178
let manifest: Manifest;
165179
try {
@@ -175,9 +189,9 @@ export const restoreStateFile: ImportHandler = async (dataSource) => {
175189

176190
return {
177191
type: 'stateFileSetup',
178-
dataSources: prepareLeafDataSources(manifest, restOfStateFile),
192+
dataSources: prepareLeafDataSources(manifest, stateFiles),
179193
manifest,
180-
stateFiles: restOfStateFile,
194+
stateFiles,
181195
} as StateFileSetupResult;
182196
}
183197
return Skip;

src/io/state-file/index.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import { useLayersStore } from '@/src/store/datasets-layers';
55
import { useToolStore } from '@/src/store/tools';
66
import { Tools } from '@/src/store/tools/types';
77
import { useViewStore } from '@/src/store/views';
8-
import { Manifest } from '@/src/io/state-file/schema';
8+
import { Manifest, ManifestSchema } from '@/src/io/state-file/schema';
99

1010
import { retypeFile } from '@/src/io';
1111
import { ARCHIVE_FILE_TYPES } from '@/src/io/mimeTypes';
12+
import { migrateManifest } from '@/src/io/state-file/migrations';
1213
import { useViewConfigStore } from '@/src/store/view-configs';
1314

1415
export const MANIFEST = 'manifest.json';
@@ -70,11 +71,22 @@ export async function serialize() {
7071

7172
export async function isStateFile(file: File) {
7273
const typedFile = await retypeFile(file);
74+
7375
if (ARCHIVE_FILE_TYPES.has(typedFile.type)) {
7476
const zip = await JSZip.loadAsync(typedFile);
75-
7677
return zip.file(MANIFEST) !== null;
7778
}
7879

80+
if (typedFile.type === 'application/json') {
81+
try {
82+
const text = await file.text();
83+
const migrated = migrateManifest(text);
84+
ManifestSchema.parse(migrated);
85+
return true;
86+
} catch {
87+
return false;
88+
}
89+
}
90+
7991
return false;
8092
}

tests/specs/sparse-manifest.e2e.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { MINIMAL_DICOM } from './configTestUtils';
2-
import { downloadFile, openVolViewPage, writeManifestToZip } from './utils';
2+
import {
3+
downloadFile,
4+
openVolViewPage,
5+
writeManifestToFile,
6+
writeManifestToZip,
7+
} from './utils';
38

49
describe('Sparse manifest.json', () => {
510
it('loads manifest with only URL data source', async () => {
@@ -85,4 +90,23 @@ describe('Sparse manifest.json', () => {
8590
}
8691
);
8792
});
93+
94+
it('loads standalone JSON state file (not zipped)', async () => {
95+
await downloadFile(MINIMAL_DICOM.url, MINIMAL_DICOM.name);
96+
97+
const sparseManifest = {
98+
version: '6.1.0',
99+
dataSources: [
100+
{
101+
id: 0,
102+
type: 'uri',
103+
uri: `/tmp/${MINIMAL_DICOM.name}`,
104+
},
105+
],
106+
};
107+
108+
const fileName = 'standalone-state.volview.json';
109+
await writeManifestToFile(sparseManifest, fileName);
110+
await openVolViewPage(fileName);
111+
});
88112
});

0 commit comments

Comments
 (0)