Skip to content

Commit 216bd23

Browse files
jcputneyclaude
andcommitted
Add comprehensive streaming support and enhanced error handling
Major improvements to the elearning module parser with focus on large file handling and better error diagnostics: STREAMING SUPPORT: - Add StreamingProgressListener interface for progress tracking - Implement StreamingUtils with buffering and progress reporting - Enhance all FileAccess implementations (Local, ZIP, S3) with streaming support - Add configurable buffer sizes and progress intervals - Support both small file caching and large file streaming ERROR MESSAGE ENHANCEMENTS: - LocalFileAccess: Add full paths, file attributes, and root context - ZipFileAccess: Add intelligent file suggestions and ZIP context - S3FileAccessV2: Add bucket, key, and size information to all errors - XML parsing: Include file paths and encoding information - AICC parser: Add module context and file suggestions - All error messages now provide actionable debugging information SCORM 1.2 PREREQUISITES: - Fix prerequisites parsing with @JacksonXmlText annotation - Add comprehensive test coverage with complex prerequisite scenarios - Extract item-level metadata including mastery scores and custom data CMI5 ENHANCEMENTS: - Complete metadata extraction for all AU attributes - Add support for nested blocks and recursive AU extraction - Include mastery scores, moveOn criteria, launch parameters - Maintain backward compatibility with legacy fields CHARACTER ENCODING: - Add BOM detection and encoding-aware stream processing - Support UTF-8, UTF-16, and other encodings automatically - Enhance XML parsing with proper encoding handling RESOURCE MANAGEMENT: - Improve stream cleanup and resource management - Add proper exception handling in cleanup paths - Ensure all FileAccess implementations properly close resources TESTS: - Add comprehensive test coverage for all new features - Create test modules for SCORM 1.2 prerequisites - Verify enhanced error messages provide useful context - All existing tests continue to pass 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 856140b commit 216bd23

24 files changed

Lines changed: 1817 additions & 134 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright (c) 2024-2025. Jonathan Putney
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU Lesser General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU Lesser General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU Lesser General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
package dev.jcputney.elearning.parser.api;
19+
20+
/**
21+
* Interface for listening to streaming progress events.
22+
* Implementations can use this to track the progress of streaming operations
23+
* such as file downloads, uploads, or processing.
24+
*/
25+
public interface StreamingProgressListener {
26+
27+
/**
28+
* Called periodically during a streaming operation to report progress.
29+
*
30+
* @param bytesRead The number of bytes read so far
31+
* @param totalSize The total size of the stream in bytes, or -1 if unknown
32+
*/
33+
void onProgress(long bytesRead, long totalSize);
34+
35+
/**
36+
* Called when the streaming operation is complete.
37+
*
38+
* @param totalBytesRead The total number of bytes that were read
39+
*/
40+
void onComplete(long totalBytesRead);
41+
}

src/main/java/dev/jcputney/elearning/parser/impl/LocalFileAccess.java

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
package dev.jcputney.elearning.parser.impl;
1919

2020
import dev.jcputney.elearning.parser.api.FileAccess;
21+
import dev.jcputney.elearning.parser.api.StreamingProgressListener;
22+
import dev.jcputney.elearning.parser.util.StreamingUtils;
2123
import java.io.IOException;
2224
import java.io.InputStream;
2325
import java.nio.file.Files;
@@ -57,7 +59,12 @@ public LocalFileAccess(String rootPath) {
5759
}
5860
this.rootPath = rootPath;
5961
if (!Files.isDirectory(Paths.get(rootPath))) {
60-
throw new IllegalArgumentException("Provided path is not a valid directory: " + rootPath);
62+
Path path = Paths.get(rootPath);
63+
String context = Files.exists(path) ?
64+
(Files.isRegularFile(path) ? "path points to a file" : "path exists but is not a directory") :
65+
"path does not exist";
66+
throw new IllegalArgumentException(
67+
"Invalid root directory for LocalFileAccess: '" + rootPath + "' (" + context + ")");
6168
}
6269
}
6370

@@ -82,8 +89,14 @@ public List<String> listFilesInternal(String directoryPath) throws IOException {
8289
Path dirPath = Paths.get(fullPath(directoryPath));
8390

8491
// Validate directory
85-
if (!Files.exists(dirPath) || !Files.isDirectory(dirPath)) {
86-
throw new IOException("Provided path is not a valid directory: " + directoryPath);
92+
if (!Files.exists(dirPath)) {
93+
throw new IOException("Directory not found: '" + directoryPath + "' (full path: '" +
94+
dirPath.toAbsolutePath() + "') in root '" + getRootPath() + "'");
95+
}
96+
if (!Files.isDirectory(dirPath)) {
97+
String fileType = Files.isRegularFile(dirPath) ? "regular file" : "special file";
98+
throw new IOException("Path is not a directory: '" + directoryPath + "' (is a " + fileType +
99+
") in root '" + getRootPath() + "'");
87100
}
88101

89102
try (Stream<Path> paths = Files.list(dirPath)) {
@@ -95,7 +108,9 @@ public List<String> listFilesInternal(String directoryPath) throws IOException {
95108
.map(Path::toString)
96109
.toList();
97110
} catch (IOException e) {
98-
throw new IOException("Failed to list files in directory: " + directoryPath, e);
111+
throw new IOException("Failed to list files in directory: '" + directoryPath +
112+
"' (full path: '" + dirPath.toAbsolutePath() + "') in root '" + getRootPath() +
113+
"': " + e.getMessage(), e);
99114
}
100115
}
101116

@@ -108,16 +123,44 @@ public List<String> listFilesInternal(String directoryPath) throws IOException {
108123
*/
109124
@Override
110125
public InputStream getFileContentsInternal(String path) throws IOException {
126+
return getFileContentsInternal(path, null);
127+
}
128+
129+
/**
130+
* Gets the contents of a file as an InputStream with optional progress tracking.
131+
*
132+
* @param path The path of the file to read.
133+
* @param progressListener Optional progress listener for tracking large file operations.
134+
* @return An InputStream for reading the file contents.
135+
* @throws IOException if an error occurs while reading the file.
136+
*/
137+
public InputStream getFileContentsInternal(String path, StreamingProgressListener progressListener) throws IOException {
111138
Path filePath = Paths.get(fullPath(path));
112139

113140
// Check file existence and read permissions
114141
if (!fileExistsInternal(path)) {
115-
throw new NoSuchFileException("File not found: " + path);
142+
throw new NoSuchFileException("File not found: '" + path + "' (full path: '" +
143+
filePath.toAbsolutePath() + "') in root '" + getRootPath() + "'");
116144
}
117145
if (!Files.isReadable(filePath)) {
118-
throw new IOException("File is not readable: " + path);
146+
String details = "";
147+
try {
148+
long size = Files.size(filePath);
149+
boolean isDirectory = Files.isDirectory(filePath);
150+
details = " (size: " + size + " bytes, type: " + (isDirectory ? "directory" : "file") + ")";
151+
} catch (IOException ignored) {
152+
details = " (unable to read file attributes)";
153+
}
154+
throw new IOException("File is not readable: '" + path + "' (full path: '" +
155+
filePath.toAbsolutePath() + "')" + details + " in root '" + getRootPath() + "'");
119156
}
120157

121-
return Files.newInputStream(filePath, StandardOpenOption.READ);
158+
InputStream inputStream = Files.newInputStream(filePath, StandardOpenOption.READ);
159+
160+
// Get file size for progress tracking
161+
long fileSize = Files.size(filePath);
162+
163+
// Apply streaming enhancements
164+
return StreamingUtils.createEnhancedStream(inputStream, fileSize, progressListener);
122165
}
123166
}

src/main/java/dev/jcputney/elearning/parser/impl/S3FileAccessV2.java

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
package dev.jcputney.elearning.parser.impl;
1919

2020
import dev.jcputney.elearning.parser.api.FileAccess;
21+
import dev.jcputney.elearning.parser.api.StreamingProgressListener;
22+
import dev.jcputney.elearning.parser.util.StreamingUtils;
2123
import java.io.ByteArrayInputStream;
2224
import java.io.IOException;
2325
import java.io.InputStream;
@@ -214,6 +216,18 @@ public List<String> listFilesInternal(String directoryPath) throws IOException {
214216
*/
215217
@Override
216218
public InputStream getFileContentsInternal(String path) throws IOException {
219+
return getFileContentsInternal(path, null);
220+
}
221+
222+
/**
223+
* Gets the contents of a file as an InputStream with optional progress tracking.
224+
*
225+
* @param path The path of the file to get contents from (guaranteed to be non-null).
226+
* @param progressListener Optional progress listener for tracking large file operations.
227+
* @return An InputStream containing the file contents.
228+
* @throws IOException If an error occurs while getting file contents.
229+
*/
230+
public InputStream getFileContentsInternal(String path, StreamingProgressListener progressListener) throws IOException {
217231
// Check cache first for small files
218232
byte[] cachedContent = smallFileCache.get(path);
219233
if (cachedContent != null) {
@@ -248,20 +262,30 @@ public InputStream getFileContentsInternal(String path) throws IOException {
248262
}
249263

250264
smallFileCache.put(path, content);
251-
return new ByteArrayInputStream(content);
265+
InputStream inputStream = new ByteArrayInputStream(content);
266+
267+
// Apply streaming enhancements for progress tracking
268+
return StreamingUtils.createEnhancedStream(inputStream, fileSize, progressListener);
252269
} catch (S3Exception e) {
253-
throw new IOException("Failed to get file contents for path: " + path, e);
270+
throw new IOException("Failed to get file contents from S3: '" + path + "' (bucket: '" +
271+
bucketName + "', key: '" + fullFilePath + "', size: " + fileSize + " bytes) - " +
272+
e.getMessage(), e);
254273
}
255274
} else {
256275
// Stream large files directly
257276
log.debug(LogMarkers.S3_VERBOSE, "Streaming large file: {} ({} bytes)", path, fileSize);
258277
try {
259-
return s3Client.getObject(GetObjectRequest.builder()
278+
InputStream inputStream = s3Client.getObject(GetObjectRequest.builder()
260279
.bucket(bucketName)
261280
.key(fullFilePath)
262281
.build());
282+
283+
// Apply streaming enhancements for large files
284+
return StreamingUtils.createEnhancedStream(inputStream, fileSize, progressListener);
263285
} catch (S3Exception e) {
264-
throw new IOException("Failed to stream file contents for path: " + path, e);
286+
throw new IOException("Failed to stream large file from S3: '" + path + "' (bucket: '" +
287+
bucketName + "', key: '" + fullFilePath + "', size: " + fileSize + " bytes) - " +
288+
e.getMessage(), e);
265289
}
266290
}
267291
}
@@ -324,7 +348,8 @@ private boolean checkFileExistsOnS3(String path) {
324348
} catch (NoSuchKeyException e) {
325349
return false;
326350
} catch (SdkException e) {
327-
log.debug("Failed to check file existence for path: {}", path, e);
351+
log.debug("Failed to check existence of file '{}' in S3 bucket '{}' with key '{}': {}",
352+
path, bucketName, fullPath(path), e.getMessage(), e);
328353
return false;
329354
}
330355
}
@@ -336,7 +361,8 @@ private long getFileSizeOnS3(String path) {
336361
.key(fullPath(path))
337362
.build()).contentLength();
338363
} catch (SdkException e) {
339-
log.debug("Failed to get file size for path: {}", path, e);
364+
log.debug("Failed to get size of file '{}' in S3 bucket '{}' with key '{}': {}",
365+
path, bucketName, fullPath(path), e.getMessage(), e);
340366
return 0;
341367
}
342368
}
@@ -368,7 +394,8 @@ private List<String> listFilesOnS3(String directoryPath) {
368394
log.debug(LogMarkers.S3_VERBOSE, "Listed {} files in directory: {}", allKeys.size(), directoryPath);
369395
return allKeys;
370396
} catch (SdkException e) {
371-
log.debug("Failed to list files in directory: {}", directoryPath, e);
397+
log.debug("Failed to list files in S3 directory '{}' (bucket: '{}', prefix: '{}'): {}",
398+
directoryPath, bucketName, fullPath(directoryPath), e.getMessage(), e);
372399
return List.of();
373400
}
374401
}
@@ -387,7 +414,8 @@ private String detectInternalRootDirectory(String rootPath) {
387414
}
388415
return commonPrefixes.get(0).prefix();
389416
} catch (SdkException e) {
390-
log.debug("Failed to detect internal root directory", e);
417+
log.debug("Failed to detect internal root directory in S3 bucket '{}' with prefix '{}': {}",
418+
bucketName, rootPath, e.getMessage(), e);
391419
return rootPath;
392420
}
393421
}

src/main/java/dev/jcputney/elearning/parser/impl/ZipFileAccess.java

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
package dev.jcputney.elearning.parser.impl;
1919

2020
import dev.jcputney.elearning.parser.api.FileAccess;
21+
import dev.jcputney.elearning.parser.api.StreamingProgressListener;
22+
import dev.jcputney.elearning.parser.util.StreamingUtils;
2123
import java.io.IOException;
2224
import java.io.InputStream;
2325
import java.util.ArrayList;
@@ -40,6 +42,7 @@ public class ZipFileAccess implements FileAccess, AutoCloseable {
4042
private final String rootPath;
4143

4244
private final ZipFile zipFile;
45+
private final String zipFilePath;
4346

4447
/**
4548
* Constructs a new {@link ZipFileAccess} instance for the specified ZIP path.
@@ -48,7 +51,12 @@ public class ZipFileAccess implements FileAccess, AutoCloseable {
4851
* @throws IOException If the ZIP file can't be opened.
4952
*/
5053
public ZipFileAccess(String zipFilePath) throws IOException {
51-
this.zipFile = new ZipFile(zipFilePath);
54+
this.zipFilePath = zipFilePath;
55+
try {
56+
this.zipFile = new ZipFile(zipFilePath);
57+
} catch (IOException e) {
58+
throw new IOException("Failed to open ZIP file: '" + zipFilePath + "' (" + e.getMessage() + ")", e);
59+
}
5260
this.rootPath = getInternalRootDirectory();
5361
}
5462

@@ -96,13 +104,35 @@ public List<String> listFilesInternal(String directoryPath) {
96104
*/
97105
@Override
98106
public InputStream getFileContentsInternal(String path) throws IOException {
107+
return getFileContentsInternal(path, null);
108+
}
109+
110+
/**
111+
* Retrieves the contents of a file within the ZIP archive as an InputStream with optional progress tracking.
112+
*
113+
* @param path The path to retrieve contents from (guaranteed to be non-null).
114+
* @param progressListener Optional progress listener for tracking large file operations.
115+
* @return An InputStream of the file contents.
116+
* @throws IOException if the file can't be read.
117+
*/
118+
public InputStream getFileContentsInternal(String path, StreamingProgressListener progressListener) throws IOException {
99119
ZipEntry entry = zipFile.getEntry(fullPath(path));
100120

101121
if (entry == null) {
102-
throw new IOException("File not found in ZIP archive: " + path);
122+
// Provide helpful information about available files
123+
String suggestion = getSimilarFiles(path);
124+
throw new IOException("File not found in ZIP archive: '" + path + "' (full path: '" +
125+
fullPath(path) + "') in ZIP file '" + zipFilePath + "'" +
126+
(rootPath.isEmpty() ? "" : " with internal root '" + rootPath + "'") + suggestion);
103127
}
104128

105-
return zipFile.getInputStream(entry);
129+
InputStream inputStream = zipFile.getInputStream(entry);
130+
131+
// Get file size for progress tracking
132+
long fileSize = entry.getSize();
133+
134+
// Apply streaming enhancements
135+
return StreamingUtils.createEnhancedStream(inputStream, fileSize, progressListener);
106136
}
107137

108138
/**
@@ -111,7 +141,11 @@ public InputStream getFileContentsInternal(String path) throws IOException {
111141
* @throws IOException if an error occurs while closing the ZIP file.
112142
*/
113143
public void close() throws IOException {
114-
zipFile.close();
144+
try {
145+
zipFile.close();
146+
} catch (IOException e) {
147+
throw new IOException("Failed to close ZIP file: '" + zipFilePath + "' (" + e.getMessage() + ")", e);
148+
}
115149
}
116150

117151
/**
@@ -159,4 +193,45 @@ private String getInternalRootDirectory() {
159193
.iterator()
160194
.next() : "";
161195
}
196+
197+
/**
198+
* Provides suggestions for similar files when a file is not found.
199+
*
200+
* @param targetPath The path that was not found.
201+
* @return A string with suggestions or empty string if no suggestions available.
202+
*/
203+
private String getSimilarFiles(String targetPath) {
204+
List<String> allFiles = new ArrayList<>();
205+
Enumeration<? extends ZipEntry> entries = zipFile.entries();
206+
207+
while (entries.hasMoreElements()) {
208+
ZipEntry entry = entries.nextElement();
209+
if (!entry.isDirectory()) {
210+
allFiles.add(entry.getName());
211+
}
212+
}
213+
214+
if (allFiles.isEmpty()) {
215+
return ". ZIP archive appears to be empty or contains only directories.";
216+
}
217+
218+
// Find files with similar names or in same directory
219+
String targetName = targetPath.contains("/") ?
220+
targetPath.substring(targetPath.lastIndexOf("/") + 1) : targetPath;
221+
String targetDir = targetPath.contains("/") ?
222+
targetPath.substring(0, targetPath.lastIndexOf("/")) : "";
223+
224+
List<String> suggestions = allFiles.stream()
225+
.filter(file -> file.toLowerCase().contains(targetName.toLowerCase()) ||
226+
(targetDir.isEmpty() || file.startsWith(targetDir)))
227+
.limit(3)
228+
.toList();
229+
230+
if (!suggestions.isEmpty()) {
231+
return ". Similar files: " + String.join(", ", suggestions);
232+
} else {
233+
return ". Available files: " + allFiles.stream().limit(5).reduce((a, b) -> a + ", " + b).orElse("none") +
234+
(allFiles.size() > 5 ? " (and " + (allFiles.size() - 5) + " more)" : "");
235+
}
236+
}
162237
}

src/main/java/dev/jcputney/elearning/parser/input/scorm12/adl/Scorm12Prerequisites.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import com.fasterxml.jackson.annotation.JsonFormat;
2323
import com.fasterxml.jackson.annotation.JsonProperty;
2424
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
25+
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlText;
2526
import java.io.Serializable;
2627
import lombok.AllArgsConstructor;
2728
import lombok.Builder;
@@ -46,7 +47,7 @@ public class Scorm12Prerequisites implements Serializable {
4647
/**
4748
* The string content of the "prerequisites" element.
4849
*/
49-
@JacksonXmlProperty(localName = "value")
50+
@JacksonXmlText
5051
private String value;
5152
/**
5253
* The type attribute of the prerequisites element, which is optional. Example value:

0 commit comments

Comments
 (0)