Skip to content

Commit c303586

Browse files
committed
2 parents b585e56 + eb79d55 commit c303586

6 files changed

Lines changed: 136 additions & 15 deletions

File tree

src/core/fileProcessor.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ class FileProcessor {
2626
static detectFormat(filePathOrBuffer: string | Buffer): FileFormat {
2727
if (typeof filePathOrBuffer === 'string') {
2828
const ext = path.extname(filePathOrBuffer).toLowerCase();
29+
const fileName = path.basename(filePathOrBuffer).toLowerCase();
30+
31+
// Handle double extensions like .sub.zip
32+
if (fileName.endsWith('.sub.zip') || ext === '.sub') {
33+
return 'snap';
34+
}
35+
2936
switch (ext) {
3037
case '.gridset':
3138
case '.gridsetx':

src/processors/obfProcessor.ts

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,16 @@ interface ObfButton {
5151
image_id?: string; // Reference to image in the images array
5252
}
5353

54+
interface ObfManifest {
55+
format?: string;
56+
root?: string;
57+
paths?: {
58+
boards?: { [key: string]: string };
59+
images?: { [key: string]: string };
60+
sounds?: { [key: string]: string };
61+
};
62+
}
63+
5464
/**
5565
* Map OBF hidden value to AAC standard visibility
5666
* OBF: true = hidden, false/undefined = visible
@@ -201,15 +211,14 @@ class ObfProcessor extends BaseProcessor {
201211
}
202212
}
203213

204-
private async processBoard(boardData: ObfBoard, _boardPath: string): Promise<AACPage> {
214+
private async processBoard(
215+
boardData: ObfBoard,
216+
_boardPath: string,
217+
isZipEntry: boolean
218+
): Promise<AACPage> {
205219
const sourceButtons = boardData.buttons || [];
206220

207221
// Calculate page ID first (used to make button IDs unique)
208-
const isZipEntry =
209-
_boardPath &&
210-
_boardPath.endsWith('.obf') &&
211-
!_boardPath.includes('/') &&
212-
!_boardPath.includes('\\');
213222
const pageId = isZipEntry
214223
? _boardPath // Zip entry - use filename to match navigation paths
215224
: boardData?.id
@@ -437,7 +446,7 @@ class ObfProcessor extends BaseProcessor {
437446
const boardData = tryParseObfJson(content);
438447
if (boardData) {
439448
console.log('[OBF] Detected .obf file, parsed as JSON');
440-
const page = await this.processBoard(boardData, filePathOrBuffer);
449+
const page = await this.processBoard(boardData, filePathOrBuffer, false);
441450
tree.addPage(page);
442451

443452
// Set metadata from root board
@@ -475,7 +484,7 @@ class ObfProcessor extends BaseProcessor {
475484
const asJson = tryParseObfJson(filePathOrBuffer);
476485
if (!asJson) throw new Error('Invalid OBF content: not JSON and not ZIP');
477486
console.log('[OBF] Detected buffer/string as OBF JSON');
478-
const page = await this.processBoard(asJson, '[bufferOrString]');
487+
const page = await this.processBoard(asJson, '[bufferOrString]', false);
479488
tree.addPage(page);
480489

481490
// Set metadata from root board
@@ -508,18 +517,43 @@ class ObfProcessor extends BaseProcessor {
508517

509518
console.log('[OBF] Detected zip archive, extracting .obf files');
510519

511-
// Collect all .obf entries
512-
const obfEntries = this.zipFile
513-
.listFiles()
514-
.filter((name) => name.toLowerCase().endsWith('.obf'));
520+
// List manifest and OBF files
521+
const filesInZip = this.zipFile.listFiles();
522+
const manifestFile = filesInZip.filter((name) => name.toLowerCase() === 'manifest.json');
523+
let obfEntries = filesInZip.filter((name) => name.toLowerCase().endsWith('.obf'));
524+
525+
// Attempt to read manifest
526+
if (manifestFile && manifestFile.length === 1) {
527+
try {
528+
const content = await this.zipFile.readFile(manifestFile[0]);
529+
const data = decodeText(content);
530+
const str = typeof data === 'string' ? data : readTextFromInput(data);
531+
if (!str.trim()) throw new Error('Manifest object missing');
532+
const manifestObject = JSON.parse(str) as ObfManifest;
533+
if (!manifestObject) throw new Error('Manifest object is empty');
534+
535+
// Replace OBF file list
536+
if (manifestObject.paths && manifestObject.paths.boards) {
537+
obfEntries = Object.values(manifestObject.paths.boards);
538+
}
539+
540+
// Move root board to top of list
541+
if (manifestObject.root) {
542+
obfEntries = obfEntries.filter((item) => item !== manifestObject.root);
543+
obfEntries.unshift(manifestObject.root);
544+
}
545+
} catch (err) {
546+
console.warn('[OBF] Error processing mainfest', err);
547+
}
548+
}
515549

516550
// Process each .obf entry
517551
for (const entryName of obfEntries) {
518552
try {
519553
const content = await this.zipFile.readFile(entryName);
520554
const boardData = tryParseObfJson(decodeText(content));
521555
if (boardData) {
522-
const page = await this.processBoard(boardData, entryName);
556+
const page = await this.processBoard(boardData, entryName, true);
523557
tree.addPage(page);
524558

525559
// Set metadata if not already set (use first board as reference)

src/processors/snapProcessor.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ import {
1717
import { generateCloneId } from '../utilities/analytics/utils/idGenerator';
1818
import { SnapValidator } from '../validation/snapValidator';
1919
import { ValidationResult } from '../validation/validationTypes';
20-
import { ProcessorInput, getFs, getNodeRequire, getPath, isNodeRuntime } from '../utils/io';
20+
import { ProcessorInput, getFs, getNodeRequire, getPath, isNodeRuntime, getOs } from '../utils/io';
2121
import { openSqliteDatabase, requireBetterSqlite3 } from '../utils/sqlite';
22+
import { openZipFromInput } from '../utils/zip';
2223

2324
/**
2425
* Convert a Buffer or Uint8Array to base64 string (browser and Node compatible)
@@ -123,8 +124,45 @@ class SnapProcessor extends BaseProcessor {
123124
await Promise.resolve();
124125
const tree = new AACTree();
125126
let dbResult: Awaited<ReturnType<typeof openSqliteDatabase>> | null = null;
127+
let cleanupTempZip: (() => void) | null = null;
128+
126129
try {
127-
dbResult = await openSqliteDatabase(filePathOrBuffer, { readonly: true });
130+
// Handle .sub.zip files (Snap pageset backups containing .sps files)
131+
let inputFile = filePathOrBuffer;
132+
133+
if (typeof filePathOrBuffer === 'string') {
134+
const fileName = getPath().basename(filePathOrBuffer).toLowerCase();
135+
if (fileName.endsWith('.sub.zip') || filePathOrBuffer.endsWith('.sub')) {
136+
const fs = getFs();
137+
const path = getPath();
138+
const os = getOs();
139+
140+
// Extract .sub.zip to find the embedded .sps file
141+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snap-sub-'));
142+
const { zip } = await openZipFromInput(filePathOrBuffer);
143+
144+
// Find the .sps file in the archive
145+
const files = zip.listFiles();
146+
const spsFile = files.find((f) => f.endsWith('.sps'));
147+
148+
if (!spsFile) {
149+
fs.rmSync(tempDir, { recursive: true, force: true });
150+
throw new Error('No .sps file found in .sub.zip archive');
151+
}
152+
153+
// Extract the .sps file
154+
const spsData = await zip.readFile(spsFile);
155+
const extractedSpsPath = path.join(tempDir, path.basename(spsFile));
156+
fs.writeFileSync(extractedSpsPath, Buffer.from(spsData));
157+
158+
inputFile = extractedSpsPath;
159+
cleanupTempZip = () => {
160+
fs.rmSync(tempDir, { recursive: true, force: true });
161+
};
162+
}
163+
}
164+
165+
dbResult = await openSqliteDatabase(inputFile, { readonly: true });
128166
const db = dbResult.db;
129167

130168
const getTableColumns = (tableName: string): Set<string> => {
@@ -789,6 +827,14 @@ class SnapProcessor extends BaseProcessor {
789827
} else if (dbResult?.db) {
790828
dbResult.db.close();
791829
}
830+
// Clean up temporary extracted .sps file from .sub.zip
831+
if (cleanupTempZip) {
832+
try {
833+
cleanupTempZip();
834+
} catch (e) {
835+
console.warn('[SnapProcessor] Failed to clean up temporary .sps file:', e);
836+
}
837+
}
792838
}
793839
}
794840

test/assets/snap/example.sub.zip

6.08 MB
Binary file not shown.

test/core/fileProcessor.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ describe('FileProcessor', () => {
125125
it('should detect snap format', async () => {
126126
expect(FileProcessor.detectFormat('test.sps')).toBe('snap');
127127
expect(FileProcessor.detectFormat('test.spb')).toBe('snap');
128+
expect(FileProcessor.detectFormat('test.sub')).toBe('snap');
129+
expect(FileProcessor.detectFormat('test.sub.zip')).toBe('snap');
130+
expect(FileProcessor.detectFormat('/path/to/My Pageset.sub.zip')).toBe('snap');
128131
});
129132

130133
it('should detect dot format', async () => {

test/snapProcessor.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { SnapProcessor } from '../src/processors/snapProcessor';
22
import { AACTree } from '../src/core/treeStructure';
33
import path from 'path';
4+
import fs from 'fs';
5+
import AdmZip from 'adm-zip';
46

57
describe('SnapProcessor', () => {
68
const exampleFile: string = path.join(__dirname, 'assets/snap/example.spb');
79
const exampleSPSFile: string = path.join(__dirname, 'assets/snap/example.sps');
10+
const exampleSubZipFile: string = path.join(__dirname, 'assets/snap/example.sub.zip');
811

912
it('should extract all texts from a .spb file', async () => {
1013
const processor = new SnapProcessor();
@@ -58,6 +61,14 @@ describe('SnapProcessor', () => {
5861
}
5962
});
6063

64+
it('should handle .sub.zip files by extracting and processing the embedded .sps file', async () => {
65+
const processor = new SnapProcessor();
66+
const tree: AACTree = await processor.loadIntoTree(exampleSubZipFile);
67+
expect(tree).toBeTruthy();
68+
const pageIds: string[] = Object.keys(tree.pages);
69+
expect(pageIds.length).toBeGreaterThan(0);
70+
});
71+
6172
describe('Error Handling', () => {
6273
it('should throw error for non-existent file', async () => {
6374
const processor = new SnapProcessor();
@@ -74,6 +85,26 @@ describe('SnapProcessor', () => {
7485
const processor = new SnapProcessor();
7586
await expect(processor.loadIntoTree('')).rejects.toThrow();
7687
});
88+
89+
it('should throw error for .sub.zip file without .sps file inside', async () => {
90+
const processor = new SnapProcessor();
91+
const zip = new AdmZip();
92+
zip.addFile('not-an-sps.txt', Buffer.from('Not a pageset'));
93+
const invalidSubZipPath = path.join(__dirname, 'invalid.sub.zip');
94+
zip.writeZip(invalidSubZipPath);
95+
96+
await expect(processor.loadIntoTree(invalidSubZipPath)).rejects.toThrow(
97+
'No .sps file found in .sub.zip archive'
98+
);
99+
100+
// Cleanup
101+
fs.unlinkSync(invalidSubZipPath);
102+
});
103+
104+
it('should throw error for .sub.zip file that does not exist', async () => {
105+
const processor = new SnapProcessor();
106+
await expect(processor.loadIntoTree('non-existent.sub.zip')).rejects.toThrow();
107+
});
77108
});
78109

79110
describe('Audio Options', () => {

0 commit comments

Comments
 (0)