applesingle/api/src/main/java/io/github/applecommander/applesingle/AppleSingle.java

334 lines
11 KiB
Java

package io.github.applecommander.applesingle;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;
/**
* Support reading of data from and AppleSingle source.
* Does not implement all components at this time, extend as required and/or understood.
* All construction has been deferred to the <code>read(...)</code> or {@link #builder()} methods.
* <p>
* Currently supports entries:<br/>
* 1. Data Fork<br/>
* 2. Resource Fork<br/>
* 3. Real Name<br/>
* 8. File Dates Info<br/>
* 11. ProDOS File Info<br/>
*
* @see <a href="https://github.com/AppleCommander/AppleCommander/issues/20">AppleCommander issue #20</a>
*/
public class AppleSingle {
public static final int MAGIC_NUMBER = 0x0051600;
public static final int VERSION_NUMBER1 = 0x00010000;
public static final int VERSION_NUMBER2 = 0x00020000;
public static final String VERSION;
static {
VERSION = AppleSingle.class.getPackage().getImplementationVersion();
}
private Map<Integer,Consumer<Entry>> entryConsumers = new HashMap<>();
{
entryConsumers.put(1, entry -> this.dataFork = entry.getData());
entryConsumers.put(2, entry -> this.resourceFork = entry.getData());
entryConsumers.put(3, entry -> this.realName = Utilities.entryToAsciiString(entry));
entryConsumers.put(8, entry -> this.fileDatesInfo = FileDatesInfo.fromEntry(entry));
entryConsumers.put(11, entry -> this.prodosFileInfo = ProdosFileInfo.fromEntry(entry));
}
private byte[] dataFork;
private byte[] resourceFork;
private String realName;
private ProdosFileInfo prodosFileInfo = ProdosFileInfo.standardBIN();
private FileDatesInfo fileDatesInfo = new FileDatesInfo();
private AppleSingle() {
// Allow Builder construction
}
private AppleSingle(List<Entry> entries) throws IOException {
entries.forEach(entry -> {
Optional.ofNullable(entry)
.map(Entry::getEntryId)
.map(entryConsumers::get)
.ifPresent(c -> c.accept(entry));
});
}
public byte[] getDataFork() {
return dataFork;
}
public byte[] getResourceFork() {
return resourceFork;
}
public String getRealName() {
return realName;
}
public ProdosFileInfo getProdosFileInfo() {
return prodosFileInfo;
}
public FileDatesInfo getFileDatesInfo() {
return fileDatesInfo;
}
/** Write this AppleSingle to the given output stream. Note that it only supports the "understood" components. */
public void save(OutputStream outputStream) throws IOException {
List<Entry> entries = new ArrayList<>();
Optional.ofNullable(this.realName)
.map(String::getBytes)
.map(Entry::realName)
.ifPresent(entries::add);
Optional.ofNullable(this.prodosFileInfo)
.map(ProdosFileInfo::toEntry)
.ifPresent(entries::add);
Optional.ofNullable(this.fileDatesInfo)
.map(FileDatesInfo::toEntry)
.ifPresent(entries::add);
Optional.ofNullable(this.resourceFork)
.map(Entry::resourceFork)
.ifPresent(entries::add);
Optional.ofNullable(this.dataFork)
.map(Entry::dataFork)
.ifPresent(entries::add);
write(outputStream, entries);
}
/** Save this AppleSingle to a File. */
public void save(File file) throws IOException {
try (FileOutputStream outputStream = new FileOutputStream(file)) {
save(outputStream);
}
}
/** Save this AppleSingle to a Path. */
public void save(Path path) throws IOException {
try (OutputStream outputStream = Files.newOutputStream(path)) {
save(outputStream);
}
}
/**
* Common write capability for an AppleSingle based on entries. Also can be used by external
* entities to write a properly formatted AppleSingle file without the ProDOS assumptions of AppleSingle.
*/
public static void write(OutputStream outputStream, List<Entry> entries) throws IOException {
final byte[] filler = new byte[16];
ByteBuffer buf = ByteBuffer.allocate(26).order(ByteOrder.BIG_ENDIAN);
buf.putInt(MAGIC_NUMBER);
buf.putInt(VERSION_NUMBER2);
buf.put(filler);
buf.putShort((short)entries.size());
outputStream.write(buf.array());
int offset = 26 + (Entry.BYTES * entries.size());
for (Entry entry : entries) {
entry.writeHeader(outputStream, offset);
offset += entry.getLength();
}
for (Entry entry : entries) {
entry.writeData(outputStream);
}
}
public static AppleSingle read(InputStream inputStream) throws IOException {
Objects.requireNonNull(inputStream, "Please supply an input stream");
return read(Utilities.toByteArray(inputStream));
}
public static AppleSingle read(File file) throws IOException {
Objects.requireNonNull(file, "Please supply a file");
return read(file.toPath());
}
public static AppleSingle read(Path path) throws IOException {
Objects.requireNonNull(path, "Please supply a file");
return read(Files.readAllBytes(path));
}
public static AppleSingle read(byte[] data) throws IOException {
Objects.requireNonNull(data);
return new AppleSingle(asEntries(data));
}
public static List<Entry> asEntries(InputStream inputStream) throws IOException {
Objects.requireNonNull(inputStream);
return asEntries(Utilities.toByteArray(inputStream));
}
public static List<Entry> asEntries(File file) throws IOException {
Objects.requireNonNull(file);
return asEntries(file.toPath());
}
public static List<Entry> asEntries(Path path) throws IOException {
Objects.requireNonNull(path);
return asEntries(Files.readAllBytes(path));
}
public static List<Entry> asEntries(byte[] data) throws IOException {
Objects.requireNonNull(data);
return asEntries(AppleSingleReader.builder(data).build());
}
public static List<Entry> asEntries(AppleSingleReader reader) throws IOException {
Objects.requireNonNull(reader);
List<Entry> entries = new ArrayList<>();
required(reader, "Magic number", "Not an AppleSingle file - magic number does not match.", MAGIC_NUMBER);
int version = required(reader, "Version", "Only AppleSingle version 1 and 2 supported.", VERSION_NUMBER1, VERSION_NUMBER2);
reader.reportVersion(version);
reader.read(16, "Filler");
int numberOfEntries = reader.read(Short.BYTES, "Number of entries").getShort();
reader.reportNumberOfEntries(numberOfEntries);
for (int i = 0; i < numberOfEntries; i++) {
Entry entry = Entry.create(reader);
entries.add(entry);
reader.reportEntry(entry);
}
return entries;
}
private static int required(AppleSingleReader reader, String description, String message, int... expecteds) throws IOException {
int actual = reader.read(Integer.BYTES, description).getInt();
for (int expected : expecteds) {
if (actual == expected) return actual;
}
List<String> versions = new ArrayList<>();
for (int expected : expecteds) versions.add(String.format("0x%08x", expected));
throw new IOException(String.format("%s Expected %s but read 0x%08x.",
message, String.join(",", versions), actual));
}
/** Perform a quick test against a File to see if it is an AppleSingle file. */
public static boolean test(File file) throws IOException {
Objects.requireNonNull(file);
return test(file.toPath());
}
/** Perform a quick test against a Path to see if it is an AppleSingle file. */
public static boolean test(Path path) throws IOException {
Objects.requireNonNull(path);
return test(Files.readAllBytes(path));
}
/** Perform a quick test against an InputStream to see if it is an AppleSingle file. */
public static boolean test(InputStream inputStream) throws IOException {
Objects.requireNonNull(inputStream);
return test(Utilities.toByteArray(inputStream));
}
/** Perform a quick test against a byte array to see if it is an AppleSingle file. */
public static boolean test(byte[] data) {
Objects.requireNonNull(data);
return test(AppleSingleReader.builder(data).build());
}
/** Perform a quick test against a reader to see if it is an AppleSingle file. */
public static boolean test(AppleSingleReader reader) {
Objects.requireNonNull(reader);
return check(reader, MAGIC_NUMBER) && check(reader, VERSION_NUMBER1, VERSION_NUMBER2);
}
private static boolean check(AppleSingleReader reader, int... expecteds) {
try {
final String message = ""; // Just needed for read.
int actual = reader.read(Integer.BYTES, message).getInt();
for (int expected : expecteds) {
if (actual == expected) return true;
}
} catch (ArrayIndexOutOfBoundsException ignored) {
// Bad file! Fall through.
}
return false;
}
public static Builder builder() {
return new Builder();
}
public static Builder builder(AppleSingle original) {
return new Builder(original);
}
public static class Builder {
final private AppleSingle as;
private Builder() {
this.as = new AppleSingle();
}
private Builder(AppleSingle original) {
this.as = original;
}
public Builder realName(String 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;
}
public Builder resourceFork(byte[] resourceFork) {
as.resourceFork = resourceFork;
return this;
}
public Builder access(int access) {
as.prodosFileInfo.access = access;
return this;
}
public Builder fileType(int fileType) {
as.prodosFileInfo.fileType = fileType;
return this;
}
public Builder auxType(int auxType) {
as.prodosFileInfo.auxType = auxType;
return this;
}
public Builder creationDate(int creation) {
as.fileDatesInfo.creation = creation;
return this;
}
public Builder creationDate(Instant creation) {
as.fileDatesInfo.creation = FileDatesInfo.fromInstant(creation);
return this;
}
public Builder modificationDate(int modification) {
as.fileDatesInfo.modification = modification;
return this;
}
public Builder modificationDate(Instant modification) {
as.fileDatesInfo.modification = FileDatesInfo.fromInstant(modification);
return this;
}
public Builder backupDate(int backup) {
as.fileDatesInfo.backup = backup;
return this;
}
public Builder backupDate(Instant backup) {
as.fileDatesInfo.backup = FileDatesInfo.fromInstant(backup);
return this;
}
public Builder accessDate(int access) {
as.fileDatesInfo.access = access;
return this;
}
public Builder accessDate(Instant access) {
as.fileDatesInfo.access = FileDatesInfo.fromInstant(access);
return this;
}
public Builder allDates(Instant instant) {
return creationDate(instant).modificationDate(instant).backupDate(instant).accessDate(instant);
}
public AppleSingle build() {
return as;
}
}
}