diff --git a/README.md b/README.md index e771ddc..ab462a8 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,131 @@ This project is an off-shoot of AppleCommander's support for the AppleSingle format in that there are multiple Java-based tools that can benefit from a pre-built library to support the AppleSingle format. +# Java Usage Examples + +## Read AppleSingle + +Use the factory method to... + +Reading from standard input: + +```java +AppleSingle as = AppleSingle.read(System.in); +``` + +Reading from a file: + +```java +File file = new File("myfile.as"); +AppleSingle as = AppleSingle.read(file); +``` + +The AppleSingle file can be read from an `InputStream`, `File`, `Path`, or just a byte array. + +## Create AppleSingle + +Use the builder to create a new AppleSingle file and then save it... + +```java +AppleSingle as = AppleSingle.builder() + .dataFork(dataFork) + .realName(realName) + .build(); + +Path file = Paths.get("mynewfile.as"); +as.save(file); +``` + +The `save(...)` method can save to a `File`, `Path`, or an `OutputStream`. + +# Command-Line Examples + +For the included command-line utility, we are using `asu` for the name. +`as` is the GNU Assembler while `applesingle` is already on Macintoshes. +Hopefully that will prevent some confusion! + +Note that all runs are with the `asu` alias defined as `alias asu='java -jar build/libs/applesingle-1.0.0.jar'` +(adjust as necessary). + +## Basic usage + +```shell +$ asu +Usage: asu [-hV] [--debug] [COMMAND] + +AppleSingle utility + +Options: + --debug Dump full stack trackes if an error occurs + -h, --help Show this help message and exit. + -V, --version Print version information and exit. + +Commands: + help Displays help information about the specified command + info Display information about an AppleSingle file + create Create an AppleSingle file + extract Extract contents of an AppleSingle file +``` + +## Subcommand help + +```shell +$ asu info --help +Usage: asu info [-h] [--stdin] [] + +Display information about an AppleSingle file +Please include a file name or indicate stdin should be read, but not both. + +Parameters: + [] File to process + +Options: + --stdin Read AppleSingle from stdin. + -h, --help Show help for subcommand +``` + +## Info subcommand + +```shell +$ asu info src/test/resources/hello.applesingle.bin +Real Name: -Unknown- +ProDOS info: + Access: 0xC3 + File Type: 0x06 + Auxtype: 0x0803 +Data Fork: Present, 2,912 bytes +Resource Fork: Not present +``` + +## Sample runs + +Using pipes to create a text file and display information. Note that the invalid `my-text-file` was changed to `MY.TEXT.FILE`. + +```shell +$ echo "Hello World!" | asu create --name my-text-file --stdout --filetype 0x06 --stdin-fork=data --fix-text | asu info --stdin +Real Name: MY.TEXT.FILE +ProDOS info: + Access: 0xC3 + File Type: 0x06 + Auxtype: 0x0000 +Data Fork: Present, 13 bytes +Resource Fork: Not present +``` + +The `--fix-text` file flips the high-bit and translates the newline character. + +```shell +$ echo "Hello World!" | asu create --name my-text-file --stdout --filetype 0x06 --stdin-fork=data --fix-text | hexdump -C +00000000 00 05 16 00 00 02 00 00 00 00 00 00 00 00 00 00 |................| +00000010 00 00 00 00 00 00 00 00 00 03 00 00 00 03 00 00 |................| +00000020 00 3e 00 00 00 0c 00 00 00 0b 00 00 00 4a 00 00 |.>...........J..| +00000030 00 08 00 00 00 01 00 00 00 52 00 00 00 0d 4d 59 |.........R....MY| +00000040 2e 54 45 58 54 2e 46 49 4c 45 00 c3 00 06 00 00 |.TEXT.FILE......| +00000050 00 00 c8 e5 ec ec ef a0 d7 ef f2 ec e4 a1 8d |...............| +0000005f +``` +(The message is at 0x52 through 0x5e.) + # Resources * [AppleSingle spec](http://kaiser-edv.de/documents/AppleSingle_AppleDouble.pdf) diff --git a/build.gradle b/build.gradle index 350779b..c72297b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +1,7 @@ +plugins { + id 'org.springframework.boot' version '2.0.2.RELEASE' +} + apply plugin: 'java' apply plugin: 'application' apply plugin: 'maven' @@ -7,7 +11,18 @@ repositories { jcenter() } -dependencies { - testImplementation 'junit:junit:4.12' +mainClassName = "io.github.applecommander.applesingle.tools.asu.Main" + +bootJar { + manifest { + attributes( + 'Implementation-Title': 'applesingle', + 'Implementation-Version': "${version} (${new Date().format('yyyy-MM-dd HH:mm')})" + ) + } } +dependencies { + compile 'info.picocli:picocli:3.0.2' + testImplementation 'junit:junit:4.12' +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7c99162 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,8 @@ +# Universal applesingle version number. Used for: +# - Naming JAR file. +# - The build will insert this into a file that is read at run time as well. +version=1.0.0 + +# Maven Central Repository G and A of GAV coordinate. :-) +group=net.sf.applecommander +archivesBaseName=AppleCommander diff --git a/src/main/java/io/github/applecommander/applesingle/AppleSingle.java b/src/main/java/io/github/applecommander/applesingle/AppleSingle.java index 7124ce2..a4ba266 100644 --- a/src/main/java/io/github/applecommander/applesingle/AppleSingle.java +++ b/src/main/java/io/github/applecommander/applesingle/AppleSingle.java @@ -114,22 +114,22 @@ public class AppleSingle { } public void save(OutputStream outputStream) throws IOException { - // Support real name, prodos file info, resource fork (if present), data fork (assumed) final boolean hasResourceFork = resourceFork == null ? false : true; - final int entries = 3 + (hasResourceFork ? 1 : 0); + final boolean hasRealName = realName == null ? false : true; + final int entries = 2 + (hasRealName ? 1 : 0) + (hasResourceFork ? 1 : 0); - int realNameOffset = 26 + (12 * entries);; - int prodosFileInfoOffset = realNameOffset + realName.length(); + int realNameOffset = 26 + (12 * entries); + int prodosFileInfoOffset = realNameOffset + (hasRealName ? realName.length() : 0); int resourceForkOffset = prodosFileInfoOffset + 8; int dataForkOffset = resourceForkOffset + (hasResourceFork ? resourceFork.length : 0); writeFileHeader(outputStream, entries); - writeHeader(outputStream, 3, realNameOffset, realName.length()); + if (hasRealName) writeHeader(outputStream, 3, realNameOffset, realName.length()); writeHeader(outputStream, 11, prodosFileInfoOffset, 8); if (hasResourceFork) writeHeader(outputStream, 2, resourceForkOffset, resourceFork.length); writeHeader(outputStream, 1, dataForkOffset, dataFork.length); - writeRealName(outputStream); + if (hasRealName) writeRealName(outputStream); writeProdosFileInfo(outputStream); if (hasResourceFork) writeResourceFork(outputStream); writeDataFork(outputStream); @@ -168,7 +168,7 @@ public class AppleSingle { ByteBuffer buf = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN); buf.putShort((short)prodosFileInfo.access); buf.putShort((short)prodosFileInfo.fileType); - buf.putInt(prodosFileInfo.fileType); + buf.putInt(prodosFileInfo.auxType); outputStream.write(buf.array()); } private void writeResourceFork(OutputStream outputStream) throws IOException { @@ -179,22 +179,15 @@ public class AppleSingle { } public static AppleSingle read(InputStream inputStream) throws IOException { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - while (true) { - byte[] buf = new byte[1024]; - int len = inputStream.read(buf); - if (len == -1) break; - outputStream.write(buf, 0, len); - } - outputStream.flush(); - return read(outputStream.toByteArray()); + Objects.requireNonNull(inputStream, "Please supply an input stream"); + return read(AppleSingle.toByteArray(inputStream)); } public static AppleSingle read(File file) throws IOException { - Objects.requireNonNull(file); + Objects.requireNonNull(file, "Please supply a file"); return read(file.toPath()); } public static AppleSingle read(Path path) throws IOException { - Objects.requireNonNull(path); + Objects.requireNonNull(path, "Please supply a file"); return new AppleSingle(Files.readAllBytes(path)); } public static AppleSingle read(byte[] data) throws IOException { @@ -208,9 +201,22 @@ public class AppleSingle { public static class Builder { private AppleSingle as = new AppleSingle(); public Builder realName(String realName) { - as.realName = realName; + if (!Character.isAlphabetic(realName.charAt(0))) { + throw new IllegalArgumentException("ProDOS file names must begin with a letter"); + } + as.realName = realName.chars() + .map(this::sanitize) + .limit(15) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString(); return this; } + private int sanitize(int ch) { + if (Character.isAlphabetic(ch) || Character.isDigit(ch)) { + return Character.toUpperCase(ch); + } + return '.'; + } public Builder dataFork(byte[] dataFork) { as.dataFork = dataFork; return this; @@ -235,4 +241,17 @@ public class AppleSingle { return as; } } + + /** Utility method to read all bytes from an InputStream. May move if more utility methods appear. */ + public static byte[] toByteArray(InputStream inputStream) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + while (true) { + byte[] buf = new byte[1024]; + int len = inputStream.read(buf); + if (len == -1) break; + outputStream.write(buf, 0, len); + } + outputStream.flush(); + return outputStream.toByteArray(); + } } diff --git a/src/main/java/io/github/applecommander/applesingle/ProdosFileInfo.java b/src/main/java/io/github/applecommander/applesingle/ProdosFileInfo.java index 1a516fa..7497f7c 100644 --- a/src/main/java/io/github/applecommander/applesingle/ProdosFileInfo.java +++ b/src/main/java/io/github/applecommander/applesingle/ProdosFileInfo.java @@ -14,7 +14,7 @@ public class ProdosFileInfo { int auxType; public static ProdosFileInfo standardBIN() { - return new ProdosFileInfo(0xc3, 0x04, 0x000); + return new ProdosFileInfo(0xc3, 0x06, 0x0000); } public ProdosFileInfo(int access, int fileType, int auxType) { diff --git a/src/main/java/io/github/applecommander/applesingle/package-info.java b/src/main/java/io/github/applecommander/applesingle/package-info.java deleted file mode 100644 index fde1de2..0000000 --- a/src/main/java/io/github/applecommander/applesingle/package-info.java +++ /dev/null @@ -1 +0,0 @@ -package io.github.applecommander.applesingle; \ No newline at end of file diff --git a/src/main/java/io/github/applecommander/applesingle/tools/asu/CreateCommand.java b/src/main/java/io/github/applecommander/applesingle/tools/asu/CreateCommand.java new file mode 100644 index 0000000..556dc32 --- /dev/null +++ b/src/main/java/io/github/applecommander/applesingle/tools/asu/CreateCommand.java @@ -0,0 +1,135 @@ +package io.github.applecommander.applesingle.tools.asu; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.Callable; + +import io.github.applecommander.applesingle.AppleSingle; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Supports creation of AppleSingle archives. + */ +@Command(name = "create", description = { "Create an AppleSingle file" }, + parameterListHeading = "%nParameters:%n", + descriptionHeading = "%n", + optionListHeading = "%nOptions:%n") +public class CreateCommand implements Callable { + @Option(names = { "-h", "--help" }, description = "Show help for subcommand", usageHelp = true) + private boolean helpFlag; + + @Option(names = "--stdout", description = "Write AppleSingle file to stdout") + private boolean stdoutFlag; + + @Option(names = "--stdin-fork", description = "Read fork from stdin (specify data or resource)") + private ForkType stdinForkType; + + @Option(names = "--fix-text", description = "Set the high bit and fix line endings") + private boolean fixTextFlag; + + @Option(names = "--data-fork", description = "Read data fork from file") + private Path dataForkFile; + + @Option(names = "--resource-fork", description = "Read resource fork from file") + private Path resourceForkFile; + + @Option(names = "--name", description = "Set the filename (defaults to name of data fork, if supplied)") + private String realName; + + @Option(names = "--access", description = "Set the ProDOS access flags", converter = IntegerTypeConverter.class) + private Integer access; + + @Option(names = "--filetype", description = "Set the ProDOS file type", converter = IntegerTypeConverter.class) + private Integer filetype; + + @Option(names = "--auxtype", description = "Set the ProDOS auxtype", converter = IntegerTypeConverter.class) + private Integer auxtype; + + @Parameters(arity = "0..1", description = "AppleSingle file to create") + private Path file; + + @Override + public Void call() throws IOException { + validateArguments(); + + byte[] dataFork = prepDataFork(); + byte[] resourceFork = prepResourceFork(); + + AppleSingle applesingle = buildAppleSingle(dataFork, resourceFork); + writeAppleSingle(applesingle); + + return null; + } + + public void validateArguments() throws IOException { + if ((stdoutFlag && file != null) || (!stdoutFlag && file == null)) { + throw new IOException("Please choose one of stdout or output file"); + } + if ((dataForkFile != null && stdinForkType == ForkType.data) + || (resourceForkFile != null && stdinForkType == ForkType.resource)) { + throw new IOException("Stdin only supports one type of fork for input"); + } + if (dataForkFile == null && resourceForkFile == null && stdinForkType == null) { + throw new IOException("Please select at least one fork type"); + } + if (stdinForkType == ForkType.both) { + throw new IOException("Unable to read two forks from stdin"); + } + } + + public byte[] prepDataFork() throws IOException { + byte[] dataFork = null; + if (stdinForkType == ForkType.data) { + dataFork = AppleSingle.toByteArray(System.in); + } else if (dataForkFile != null) { + dataFork = Files.readAllBytes(dataForkFile); + } + + if (fixTextFlag && dataFork != null) { + for (int i=0; i { + @Option(names = { "-h", "--help" }, description = "Show help for subcommand", usageHelp = true) + private boolean helpFlag; + + @Option(names = "--stdout", description = "Write selected fork to stdout") + private boolean stdoutFlag; + + @Option(names = "--stdin", description = "Read AppleSingle from stdin") + private boolean stdinFlag; + + @Option(names = "--fix-text", description = "Clear the high bit and fix line endings") + private boolean fixTextFlag; + + @Option(names = "--fork", description = "Extract which fork type (specify data, resource, or both", + showDefaultValue = Visibility.ALWAYS) + private ForkType forkType = ForkType.data; + + @Option(names = { "-o", "--output" }, description = "Write fork(s) to base filename") + private String baseFilename; + + @Parameters(arity = "0..1", description = "File to process") + private Path file; + + @Override + public Void call() throws IOException { + validateArguments(); + + AppleSingle applesingle = stdinFlag ? AppleSingle.read(System.in) : AppleSingle.read(file); + if (!stdoutFlag && baseFilename == null && applesingle.getRealName() == null) { + throw new IOException("Please include an output base filename; this AppleSingle file does not contain a name"); + } + if (baseFilename == null) { + baseFilename = applesingle.getRealName(); + } + + writeFork(ForkType.data, applesingle.getDataFork()); + writeFork(ForkType.resource, applesingle.getResourceFork()); + return null; + } + + public void validateArguments() throws IOException { + if (stdoutFlag && baseFilename != null) { + throw new IOException("Please choose one of stdout or output file"); + } + if (stdoutFlag && forkType == ForkType.both) { + throw new IOException("Stdout only supports one type of fork for output"); + } + if ((stdinFlag && file != null) || (!stdinFlag && file == null)) { + throw new IOException("Please select ONE of stdin or file"); + } + } + + public void writeFork(ForkType forkType, byte[] data) throws IOException { + if (this.forkType != forkType && this.forkType != ForkType.both) return; + + if (data == null || data.length == 0) { + throw new IOException(String.format("There is no data in the %s fork, aborting", forkType)); + } + + if (fixTextFlag) { + for (int i=0; i { + @Option(names = { "-h", "--help" }, description = "Show help for subcommand", usageHelp = true) + private boolean helpFlag; + + @Option(names = "--stdin", description = "Read AppleSingle from stdin.") + private boolean stdinFlag; + + @Parameters(arity = "0..1", description = "File to process") + private File file; + + @Override + public Void call() throws IOException { + AppleSingle applesingle = stdinFlag ? AppleSingle.read(System.in) : AppleSingle.read(file); + System.out.printf("Real Name: %s\n", Optional.ofNullable(applesingle.getRealName()).orElse("-Unknown-")); + System.out.printf("ProDOS info:\n"); + if (applesingle.getProdosFileInfo() == null) { + System.out.println(" Not supplied."); + } else { + ProdosFileInfo prodosFileInfo = applesingle.getProdosFileInfo(); + System.out.printf(" Access: 0x%02X\n", prodosFileInfo.getAccess()); + System.out.printf(" File Type: 0x%02X\n", prodosFileInfo.getFileType()); + System.out.printf(" Auxtype: 0x%04X\n", prodosFileInfo.getAuxType()); + } + System.out.printf("Data Fork: Present, %,d bytes\n", applesingle.getDataFork().length); + System.out.printf("Resource Fork: %s\n", + Optional.ofNullable(applesingle.getResourceFork()) + .map(d -> String.format("Present, %,d bytes", d.length)) + .orElse("Not present")); + return null; + } +} diff --git a/src/main/java/io/github/applecommander/applesingle/tools/asu/IntegerTypeConverter.java b/src/main/java/io/github/applecommander/applesingle/tools/asu/IntegerTypeConverter.java new file mode 100644 index 0000000..66d6d11 --- /dev/null +++ b/src/main/java/io/github/applecommander/applesingle/tools/asu/IntegerTypeConverter.java @@ -0,0 +1,19 @@ +package io.github.applecommander.applesingle.tools.asu; + +import picocli.CommandLine.ITypeConverter; + +/** Add support for "$801" and "0x801" instead of just decimal like 2049. */ +public class IntegerTypeConverter implements ITypeConverter { + @Override + public Integer convert(String value) { + if (value == null) { + return null; + } else if (value.startsWith("$")) { + return Integer.valueOf(value.substring(1), 16); + } else if (value.startsWith("0x") || value.startsWith("0X")) { + return Integer.valueOf(value.substring(2), 16); + } else { + return Integer.valueOf(value); + } + } +} diff --git a/src/main/java/io/github/applecommander/applesingle/tools/asu/Main.java b/src/main/java/io/github/applecommander/applesingle/tools/asu/Main.java new file mode 100644 index 0000000..7091682 --- /dev/null +++ b/src/main/java/io/github/applecommander/applesingle/tools/asu/Main.java @@ -0,0 +1,45 @@ +package io.github.applecommander.applesingle.tools.asu; + +import java.util.Optional; + +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.HelpCommand; +import picocli.CommandLine.Option; + +/** + * Primary entry point into the AppleSingle utility. + */ +@Command(name = "asu", mixinStandardHelpOptions = true, versionProvider = VersionProvider.class, + descriptionHeading = "%n", + commandListHeading = "%nCommands:%n", + optionListHeading = "%nOptions:%n", + description = "AppleSingle utility", + subcommands = { HelpCommand.class, InfoCommand.class, CreateCommand.class, ExtractCommand.class }) +public class Main implements Runnable { + @Option(names = "--debug", description = "Dump full stack trackes if an error occurs") + private static boolean debugFlag; + + public static void main(String[] args) { + try { + CommandLine.run(new Main(), args); + } catch (Throwable t) { + if (Main.debugFlag) { + t.printStackTrace(System.err); + } else { + String message = t.getMessage(); + while (t != null) { + message = t.getMessage(); + t = t.getCause(); + } + System.err.printf("Error: %s\n", Optional.ofNullable(message).orElse("An error occurred.")); + } + System.exit(1); + } + } + + @Override + public void run() { + CommandLine.usage(this, System.out); + } +} diff --git a/src/main/java/io/github/applecommander/applesingle/tools/asu/VersionProvider.java b/src/main/java/io/github/applecommander/applesingle/tools/asu/VersionProvider.java new file mode 100644 index 0000000..96b3ac6 --- /dev/null +++ b/src/main/java/io/github/applecommander/applesingle/tools/asu/VersionProvider.java @@ -0,0 +1,10 @@ +package io.github.applecommander.applesingle.tools.asu; + +import picocli.CommandLine.IVersionProvider; + +/** Display version information. Note that this is dependent on the Spring Boot Gradle plugin configuration. */ +public class VersionProvider implements IVersionProvider { + public String[] getVersion() { + return new String[] { Main.class.getPackage().getImplementationVersion() }; + } +} \ No newline at end of file diff --git a/src/test/java/io/github/applecommander/applesingle/AppleSingleTest.java b/src/test/java/io/github/applecommander/applesingle/AppleSingleTest.java index d6c79b2..63fadcf 100644 --- a/src/test/java/io/github/applecommander/applesingle/AppleSingleTest.java +++ b/src/test/java/io/github/applecommander/applesingle/AppleSingleTest.java @@ -39,7 +39,7 @@ public class AppleSingleTest { .realName(realName) .build(); assertNotNull(createdAS); - assertEquals(realName, createdAS.getRealName()); + assertEquals(realName.toUpperCase(), createdAS.getRealName()); assertArrayEquals(dataFork, createdAS.getDataFork()); assertNull(createdAS.getResourceFork()); assertNotNull(createdAS.getProdosFileInfo()); @@ -50,9 +50,26 @@ public class AppleSingleTest { AppleSingle readAS = AppleSingle.read(actualBytes.toByteArray()); assertNotNull(readAS); - assertEquals(realName, readAS.getRealName()); + assertEquals(realName.toUpperCase(), readAS.getRealName()); assertArrayEquals(dataFork, readAS.getDataFork()); assertNull(readAS.getResourceFork()); assertNotNull(readAS.getProdosFileInfo()); } + + @Test + public void testProdosFileNameLengthRequirements() { + AppleSingle as = AppleSingle.builder().realName("superlongnamethatneedstobetruncated").build(); + assertEquals(15, as.getRealName().length()); + } + + @Test + public void testProdosFileNameCharacterRequirements() { + AppleSingle as = AppleSingle.builder().realName("bad-~@").build(); + assertEquals("BAD...", as.getRealName()); + } + + @Test(expected = IllegalArgumentException.class) + public void testProdosFileNameFirstCharacter() { + AppleSingle.builder().realName("1st-file").build(); + } }