Adding CLI; fixed a few glitches.

This commit is contained in:
Rob Greene 2018-05-24 22:31:15 -05:00
parent 307c5eab3f
commit 70b476b561
14 changed files with 575 additions and 25 deletions

125
README.md
View File

@ -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] [<file>]
Display information about an AppleSingle file
Please include a file name or indicate stdin should be read, but not both.
Parameters:
[<file>] 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)

View File

@ -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'
}

8
gradle.properties Normal file
View File

@ -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

View File

@ -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();
}
}

View File

@ -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) {

View File

@ -1 +0,0 @@
package io.github.applecommander.applesingle;

View File

@ -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<Void> {
@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<dataFork.length; i++) {
if (dataFork[i] == '\n') dataFork[i] = 0x0d;
dataFork[i] = (byte)(dataFork[i] | 0x80);
}
}
return dataFork;
}
public byte[] prepResourceFork() throws IOException {
byte[] resourceFork = null;
if (stdinForkType == ForkType.resource) {
resourceFork = AppleSingle.toByteArray(System.in);
} else if (resourceForkFile != null) {
resourceFork = Files.readAllBytes(resourceForkFile);
}
return resourceFork;
}
public AppleSingle buildAppleSingle(byte[] dataFork, byte[] resourceFork) {
AppleSingle.Builder builder = AppleSingle.builder();
if (realName != null) {
builder.realName(realName);
} else if (dataForkFile != null) {
String name = dataForkFile.getFileName().toString();
builder.realName(name);
}
if (access != null) builder.access(access.intValue());
if (filetype != null) builder.fileType(filetype.intValue());
if (auxtype != null) builder.auxType(auxtype.intValue());
if (dataFork != null) builder.dataFork(dataFork);
if (resourceFork != null) builder.resourceFork(resourceFork);
return builder.build();
}
public void writeAppleSingle(AppleSingle applesingle) throws IOException {
if (stdoutFlag) {
applesingle.save(System.out);
} else {
applesingle.save(file);
System.out.printf("Saved to '%s'.\n", file);
}
}
}

View File

@ -0,0 +1,98 @@
package io.github.applecommander.applesingle.tools.asu;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.concurrent.Callable;
import io.github.applecommander.applesingle.AppleSingle;
import picocli.CommandLine.Command;
import picocli.CommandLine.Help.Visibility;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
/**
* Supports extracting components of an AppleSingle archive.
*/
@Command(name = "extract", description = { "Extract contents of an AppleSingle file" },
parameterListHeading = "%nParameters:%n",
descriptionHeading = "%n",
optionListHeading = "%nOptions:%n")
public class ExtractCommand implements Callable<Void> {
@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<data.length; i++) {
data[i] = (byte)(data[i] & 0x7f);
if (data[i] == 0x0d) data[i] = '\n';
}
}
if (baseFilename != null) {
String targetFilename = String.format("%s.%s", baseFilename, forkType.name());
System.out.printf("Writing %s fork to file '%s'...\n", forkType.name(), targetFilename);
Path path = Paths.get(targetFilename);
Files.write(path, data);
}
if (stdoutFlag) {
System.out.write(data);
}
}
}

View File

@ -0,0 +1,8 @@
package io.github.applecommander.applesingle.tools.asu;
/**
* Basic listing of fork types used in the command line tools.
*/
public enum ForkType {
data, resource, both
}

View File

@ -0,0 +1,52 @@
package io.github.applecommander.applesingle.tools.asu;
import java.io.File;
import java.io.IOException;
import java.util.Optional;
import java.util.concurrent.Callable;
import io.github.applecommander.applesingle.AppleSingle;
import io.github.applecommander.applesingle.ProdosFileInfo;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
/**
* Display basic information from an AppleSingle archive.
*/
@Command(name = "info", description = { "Display information about an AppleSingle file",
"Please include a file name or indicate stdin should be read, but not both." },
parameterListHeading = "%nParameters:%n",
descriptionHeading = "%n",
optionListHeading = "%nOptions:%n")
public class InfoCommand implements Callable<Void> {
@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;
}
}

View File

@ -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<Integer> {
@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);
}
}
}

View File

@ -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);
}
}

View File

@ -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() };
}
}

View File

@ -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();
}
}