diff --git a/app/ac-acx/README.md b/app/ac-acx/README.md new file mode 100644 index 0000000..0fd0399 --- /dev/null +++ b/app/ac-acx/README.md @@ -0,0 +1,150 @@ +# `ac`, extended + +This is a revamp of the venerable `ac` command-line utility with a more modern command-line interface. + +# Sample Run + +All of the commands use `-d` to designate the disk. If there are many commands, setting the environment variable `ACX_DISK_NAME` can be used to simplify commands. + +For example, this sequence: +1. Creates a ProDOS disk image (pulling ProDOS from the 2.4.2 system master). +2. Imports and tokenizes the sample `startup.bas` program and places it on the disk as `STARTUP`. +3. Lists the resulting disk. + +``` +$ cat > startup.bas +10 TEXT:HOME:GR +20 FOR Y=0 TO 3 +30 FOR X=0 TO 3 +40 COLOR=Y*4+X +50 FOR A=0 TO 9 +60 HLIN X*10,X*10+9 AT Y*10+A +70 NEXT A,X,Y +80 END + + +$ export ACX_DISK_NAME=sample.po +$ acx create --format=ProDOS_2_4_2.dsk --name=SAMPLES --size=140k --type=prodos +$ acx import --basic startup.bas --name=STARTUP + +$ acx list --native +File: sample.po +Name: /SAMPLES/ +* PRODOS SYS 035 01/02/2022 01/13/2018 17,128 +* BASIC.SYSTEM SYS 021 01/02/2022 01/13/2018 10,240 A=$2000 + STARTUP BAS 001 01/02/2022 01/02/2022 97 A=$0801 +ProDOS format; 110592 bytes free; 32768 bytes used. +``` + + +# Usage + +``` +$ acx --help +Usage: acx [-hVv] [--debug] [--quiet] [COMMAND] + +'ac' experimental utility + +Options: + --debug Show detailed stack traces. + -h, --help Show this help message and exit. + --quiet Turn off all logging. + -v, --verbose Be verbose. Multiple occurrences increase logging. + -V, --version Print version information and exit. + +Commands: + convert Uncompress a ShrinkIt or Binary II file; + copy, cp Copy files between disks. + create, mkdisk Rename volume of a disk image. + delete, del, rm Delete file(s) from a disk image. + diskmap, map Show disk usage map. + export, x, get Export file(s) from a disk image. + help Displays help information about the specified command + import, put Import file onto disk. + info, i Show information on a disk image(s). + list, ls List directory of disk image(s). + lock Lock file(s) on a disk image. + mkdir, md Create a directory on disk. + rename, ren Rename file on a disk image. + rename-disk Rename volume of a disk image. + rmdir, rd Remove a directory on disk. + unlock Unlock file(s) on a disk image. +``` + +## Info + +``` +$ acx info --help +Usage: acx info [-h] ... + +Show information on a disk image(s). + +Parameters: + ... Image(s) to process. + +Options: + -h, --help Show help for subcommand. +``` + +``` +$ acx info "Beagle Graphics.dsk" +File Name: Beagle Graphics.dsk +Disk Name: DISK VOLUME #254 +Physical Size (bytes): 143360 +Free Space (bytes): 20480 +Used Space (bytes): 122880 +Physical Size (KB): 140 +Free Space (KB): 20 +Used Space (KB): 120 +Archive Order: DOS +Disk Format: DOS 3.3 +Total Sectors: 560 +Free Sectors: 80 +Used Sectors: 480 +Tracks On Disk: 35 +Sectors On Disk: 16 +``` + +## List + +``` +$ acx list --help +Usage: acx list [-hr] [--[no-]column] [--deleted] [--[no-]footer] [--[no-] + header] [--globs=[,...]]... [-n | -s | -l] + [--file | --directory] ... + +List directory of disk image(s). + +Parameters: + ... Image(s) to process. + +Options: + --[no-]column Show column headers. + --deleted Show deleted files. + --directory Only include directories. + --file Only include files. + --[no-]footer Show footer. + --globs=[,...] + File glob(s) to match. + -h, --help Show help for subcommand. + --[no-]header Show header. + -r, --[no-]recursive Display directory recursively. +File display formatting: + -l, --long, --detail Use long/detailed directory format. + -n, --native Use native directory format (default). + -s, --short, --standard Use brief directory format. +``` + +``` +$ acx list --no-recursive DEVCD.HDV +File: DEVCD.HDV +Name: /DEV.CD/ + TOOLS DIR 002 06/25/1990 04/13/1989 1,024 + II.DISK.CENTRAL DIR 001 06/25/1990 04/13/1989 512 + UTILITIES DIR 002 06/25/1990 04/13/1989 1,024 + READ.ME.FIRST DIR 001 06/25/1990 04/21/1989 512 + GUIDED.TOURS DIR 001 06/25/1990 04/13/1989 512 + FINDER.DATA FND 001 06/25/1990 10/12/1989 172 + DEVELOP DIR 001 07/05/1990 06/25/1990 512 +ProDOS format; 1701376 bytes free; 19270144 bytes used. +``` diff --git a/app/ac-acx/build.gradle b/app/ac-acx/build.gradle new file mode 100644 index 0000000..302ec75 --- /dev/null +++ b/app/ac-acx/build.gradle @@ -0,0 +1,36 @@ +plugins { + id 'org.springframework.boot' version "$springBoot" + id 'java' + id 'application' +} + +sourceCompatibility = 11 +targetCompatibility = 11 + +repositories { + mavenCentral() +} + +dependencies { + implementation "info.picocli:picocli:$picocliVersion" + implementation project(':lib:ac-api') + implementation "net.sf.applecommander:ShrinkItArchive:$shkVersion" + implementation "net.sf.applecommander:applesingle-api:$asVersion" + implementation "net.sf.applecommander:bastools-api:$btVersion" + + testImplementation "junit:junit:$junitVersion" +} + +application { + mainClass = 'io.github.applecommander.acx.Main' +} + +bootJar { + archiveBaseName = 'AppleCommander' + archiveAppendix = 'acx' + manifest { + attributes 'Implementation-Title': "AppleCommander 'acx'", + 'Implementation-Version': archiveVersion + } + from('../../LICENSE') +} diff --git a/app/ac-acx/gradle/wrapper/gradle-wrapper.jar b/app/ac-acx/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..7454180 Binary files /dev/null and b/app/ac-acx/gradle/wrapper/gradle-wrapper.jar differ diff --git a/app/ac-acx/gradle/wrapper/gradle-wrapper.properties b/app/ac-acx/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e750102 --- /dev/null +++ b/app/ac-acx/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/Main.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/Main.java new file mode 100644 index 0000000..b279d6c --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/Main.java @@ -0,0 +1,101 @@ +package io.github.applecommander.acx; + +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +import io.github.applecommander.acx.command.ConvertCommand; +import io.github.applecommander.acx.command.CopyFileCommand; +import io.github.applecommander.acx.command.CreateDiskCommand; +import io.github.applecommander.acx.command.DeleteCommand; +import io.github.applecommander.acx.command.DiskMapCommand; +import io.github.applecommander.acx.command.ExportCommand; +import io.github.applecommander.acx.command.ImportCommand; +import io.github.applecommander.acx.command.InfoCommand; +import io.github.applecommander.acx.command.ListCommand; +import io.github.applecommander.acx.command.LockCommand; +import io.github.applecommander.acx.command.MkdirCommand; +import io.github.applecommander.acx.command.RenameDiskCommand; +import io.github.applecommander.acx.command.RenameFileCommand; +import io.github.applecommander.acx.command.RmdirCommand; +import io.github.applecommander.acx.command.UnlockCommand; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.HelpCommand; +import picocli.CommandLine.Option; + +/** + * Primary entry point into the 'acx' utility. + */ +@Command(name = "acx", mixinStandardHelpOptions = true, versionProvider = VersionProvider.class, + descriptionHeading = "%n", + commandListHeading = "%nCommands:%n", + optionListHeading = "%nOptions:%n", + description = "'ac' experimental utility", + subcommands = { + ConvertCommand.class, + CopyFileCommand.class, + CreateDiskCommand.class, + DeleteCommand.class, + DiskMapCommand.class, + ExportCommand.class, + HelpCommand.class, + ImportCommand.class, + InfoCommand.class, + ListCommand.class, + LockCommand.class, + MkdirCommand.class, + RenameFileCommand.class, + RenameDiskCommand.class, + RmdirCommand.class, + UnlockCommand.class + }) +public class Main { + private static Logger LOG = Logger.getLogger(Main.class.getName()); + private static final Level LOG_LEVELS[] = { Level.OFF, Level.SEVERE, Level.WARNING, Level.INFO, + Level.CONFIG, Level.FINE, Level.FINER, Level.FINEST }; + + static { + System.setProperty("java.util.logging.SimpleFormatter.format", "%4$s: %5$s%n"); + setAllLogLevels(Level.WARNING); + } + private static void setAllLogLevels(Level level) { + Logger rootLogger = LogManager.getLogManager().getLogger(""); + rootLogger.setLevel(level); + for (Handler handler : rootLogger.getHandlers()) { + handler.setLevel(level); + } + } + + // This flag is read in PrintExceptionMessageHandler. + @Option(names = { "--debug" }, description = "Show detailed stack traces.") + static boolean enableStackTrace; + + @Option(names = { "-v", "--verbose" }, description = "Be verbose. Multiple occurrences increase logging.") + public void setVerbosity(boolean[] flag) { + // The "+ 2" is due to the default of the levels + int loglevel = Math.min(flag.length + 2, LOG_LEVELS.length); + Level level = LOG_LEVELS[loglevel-1]; + setAllLogLevels(level); + } + + @Option(names = { "--quiet" }, description = "Turn off all logging.") + public void setQuiet(boolean flag) { + setAllLogLevels(Level.OFF); + } + + public static void main(String[] args) { + CommandLine cmd = new CommandLine(new Main()); + cmd.setExecutionExceptionHandler(new PrintExceptionMessageHandler()); + if (args.length == 0) { + cmd.usage(System.out); + System.exit(1); + } + + LOG.info(() -> String.format("Log level set to %s.", Logger.getGlobal().getLevel())); + int exitCode = cmd.execute(args); + LOG.fine("Exiting with code " + exitCode); + System.exit(exitCode); + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/PrintExceptionMessageHandler.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/PrintExceptionMessageHandler.java new file mode 100644 index 0000000..cbd824d --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/PrintExceptionMessageHandler.java @@ -0,0 +1,25 @@ +package io.github.applecommander.acx; + +import picocli.CommandLine; +import picocli.CommandLine.IExecutionExceptionHandler; +import picocli.CommandLine.ParseResult; + +// Note: Taken from https://picocli.info/#_business_logic_exceptions +public class PrintExceptionMessageHandler implements IExecutionExceptionHandler { + public int handleExecutionException(Exception ex, + CommandLine cmd, + ParseResult parseResult) { + + if (Main.enableStackTrace) { + ex.printStackTrace(System.err); + } + else { + // bold red error message + cmd.getErr().println(cmd.getColorScheme().errorText(ex.getMessage())); + } + + return cmd.getExitCodeExceptionMapper() != null + ? cmd.getExitCodeExceptionMapper().getExitCode(ex) + : cmd.getCommandSpec().exitCodeOnExecutionException(); + } +} \ No newline at end of file diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/SystemType.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/SystemType.java new file mode 100644 index 0000000..d4a5948 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/SystemType.java @@ -0,0 +1,96 @@ +package io.github.applecommander.acx; + +import java.util.Arrays; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.logging.Logger; + +import com.webcodepro.applecommander.storage.DiskException; +import com.webcodepro.applecommander.storage.FileEntry; +import com.webcodepro.applecommander.storage.FormattedDisk; +import com.webcodepro.applecommander.storage.os.dos33.DosFormatDisk; +import com.webcodepro.applecommander.storage.physical.ByteArrayImageLayout; +import com.webcodepro.applecommander.storage.physical.DosOrder; +import com.webcodepro.applecommander.storage.physical.ImageOrder; +import com.webcodepro.applecommander.storage.physical.ProdosOrder; + +import io.github.applecommander.acx.converter.DataSizeConverter; +import io.github.applecommander.acx.fileutil.FileUtils; + +public enum SystemType { + DOS(SystemType::createDosImageOrder, SystemType::copyDosSystemTracks), + OZDOS(SystemType::create800kDosImageOrder, SystemType::copyDosSystemTracks), + UNIDOS(SystemType::create800kDosImageOrder, SystemType::copyDosSystemTracks), + PRODOS(SystemType::createProdosImageOrder, SystemType::copyProdosSystemFiles), + PASCAL(SystemType::createProdosImageOrder, SystemType::copyPascalSystemFiles); + + private static Logger LOG = Logger.getLogger(SystemType.class.getName()); + + private Function createImageOrderFn; + private BiConsumer copySystemFn; + + private SystemType(Function createImageOrderFn, + BiConsumer copySystemFn) { + this.createImageOrderFn = createImageOrderFn; + this.copySystemFn = copySystemFn; + } + + public ImageOrder createImageOrder(int size) { + return createImageOrderFn.apply(size); + } + public void copySystem(FormattedDisk target, FormattedDisk source) { + copySystemFn.accept(target, source); + } + + private static ImageOrder createDosImageOrder(int size) { + ByteArrayImageLayout layout = new ByteArrayImageLayout(new byte[size]); + return new DosOrder(layout); + } + private static ImageOrder create800kDosImageOrder(int size) { + if (size != 800 * DataSizeConverter.KB) { + LOG.warning("Setting image size to 800KB."); + } + ByteArrayImageLayout layout = new ByteArrayImageLayout(new byte[800 * DataSizeConverter.KB]); + return new DosOrder(layout); + } + private static ImageOrder createProdosImageOrder(int size) { + ByteArrayImageLayout layout = new ByteArrayImageLayout(size); + return new ProdosOrder(layout); + } + + private static void copyDosSystemTracks(FormattedDisk targetDisk, FormattedDisk source) { + DosFormatDisk target = (DosFormatDisk)targetDisk; + // FIXME messing with the VTOC should be handled elsewhere + byte[] vtoc = source.readSector(DosFormatDisk.CATALOG_TRACK, DosFormatDisk.VTOC_SECTOR); + int sectorsPerTrack = vtoc[0x35]; + // Note that this also patches T0 S0 for BOOT0 + for (int t=0; t<3; t++) { + for (int s=0; s globs = Arrays.asList("*"); + + @Override + public int handleCommand() throws Exception { + List files = FileStreamer.forDisk(disk) + .ignoreErrors(true) + .includeTypeOfFile(TypeOfFile.FILE) + .matchGlobs(globs) + .stream() + .collect(Collectors.toList()); + + if (files.isEmpty()) { + LOG.warning(() -> String.format("No matches found for %s.", String.join(",", globs))); + } else { + files.forEach(this::fileHandler); + } + + return 0; + } + + public abstract void fileHandler(FileTuple tuple); +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/base/ReusableCommandOptions.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/base/ReusableCommandOptions.java new file mode 100644 index 0000000..c00a333 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/base/ReusableCommandOptions.java @@ -0,0 +1,41 @@ +package io.github.applecommander.acx.base; + +import java.io.IOException; +import java.util.concurrent.Callable; +import java.util.logging.Logger; + +import com.webcodepro.applecommander.storage.Disk; + +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command(descriptionHeading = "%n", + optionListHeading = "%nOptions:%n", + parameterListHeading = "%nParameters:%n") +public abstract class ReusableCommandOptions implements Callable { + private static Logger LOG = Logger.getLogger(ReusableCommandOptions.class.getName()); + + @Option(names = { "-h", "--help" }, description = "Show help for subcommand.", usageHelp = true) + private boolean helpFlag; + + @Override + public Integer call() throws Exception { + return handleCommand(); + } + + public abstract int handleCommand() throws Exception; + + public void saveDisk(Disk disk) { + try { + // Only save if there are changes. + if (disk.getDiskImageManager().hasChanged()) { + LOG.fine(() -> String.format("Saving disk '%s'", disk.getFilename())); + disk.save(); + } else { + LOG.fine(() -> String.format("Disk '%s' has not changed; not saving.", disk.getFilename())); + } + } catch (IOException e) { + LOG.severe(e.getMessage()); + } + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/command/ConvertCommand.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/ConvertCommand.java new file mode 100644 index 0000000..eee77ba --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/ConvertCommand.java @@ -0,0 +1,39 @@ +package io.github.applecommander.acx.command; + +import java.io.File; + +import com.webcodepro.applecommander.storage.Disk; + +import io.github.applecommander.acx.base.ReusableCommandOptions; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +@Command(name = "convert", description = { + "Uncompress a ShrinkIt or Binary II file; ", + "or convert a DiskCopy 4.2 image into a ProDOS disk image." }) +public class ConvertCommand extends ReusableCommandOptions { + @Option(names = { "-d", "--disk" }, description = "Image to create [$ACX_DISK_NAME].", required = true, + defaultValue = "${ACX_DISK_NAME}") + private String diskName; + + @Option(names = { "-f", "--force" }, description = "Allow existing disk image to be replaced.") + private boolean overwriteFlag; + + @Parameters(description = "Archive to convert.", arity = "1") + private String archiveName; + + @Override + public int handleCommand() throws Exception { + File targetFile = new File(diskName); + if (targetFile.exists() && !overwriteFlag) { + throw new RuntimeException("File exists and overwriting not enabled."); + } + + Disk disk = new Disk(archiveName); + disk.setFilename(diskName); + saveDisk(disk); + + return 0; + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/command/CopyFileCommand.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/CopyFileCommand.java new file mode 100644 index 0000000..c23ea50 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/CopyFileCommand.java @@ -0,0 +1,75 @@ +package io.github.applecommander.acx.command; + +import java.util.List; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +import com.webcodepro.applecommander.storage.Disk; +import com.webcodepro.applecommander.storage.DiskException; +import com.webcodepro.applecommander.storage.FormattedDisk; + +import io.github.applecommander.acx.base.ReadWriteDiskCommandOptions; +import io.github.applecommander.acx.converter.DiskConverter; +import io.github.applecommander.acx.fileutil.FileUtils; +import io.github.applecommander.filestreamer.FileStreamer; +import io.github.applecommander.filestreamer.FileTuple; +import io.github.applecommander.filestreamer.TypeOfFile; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +@Command(name = "copy", description = "Copy files between disks.", + aliases = { "cp" }) +public class CopyFileCommand extends ReadWriteDiskCommandOptions { + private static Logger LOG = Logger.getLogger(CopyFileCommand.class.getName()); + + @Option(names = { "-r", "--recursive" }, description = "Copy files recursively.") + private boolean recursiveFlag; + + @Option(names = { "-f", "--force" }, description = "Overwrite existing files.") + private boolean overwriteFlag; + + @Option(names = { "--to", "--directory" }, description = "Specify which directory to place files.") + private String targetPath; + + @Option(names = { "-s", "--from", "--source" }, description = "Source disk for files.", + converter = DiskConverter.class, required = true) + private Disk sourceDisk; + + @Parameters(arity = "*", description = "File glob(s) to copy (default = '*')", + defaultValue = "*") + private List globs; + + @Override + public int handleCommand() throws Exception { + List files = FileStreamer.forDisk(sourceDisk) + .ignoreErrors(true) + .includeTypeOfFile(TypeOfFile.BOTH) + .recursive(recursiveFlag) + .matchGlobs(globs) + .stream() + .collect(Collectors.toList()); + + if (files.isEmpty()) { + LOG.warning(() -> String.format("No matches found for %s.", String.join(",", globs))); + } else { + files.forEach(this::fileHandler); + } + return 0; + } + + private void fileHandler(FileTuple tuple) { + try { + FormattedDisk formattedDisk = disk.getFormattedDisks()[0]; + if (!recursiveFlag && tuple.fileEntry.isDirectory()) { + formattedDisk.createDirectory(tuple.fileEntry.getFilename()); + } else { + FileUtils copier = new FileUtils(overwriteFlag); + copier.copy(formattedDisk, tuple.fileEntry); + } + } catch (DiskException ex) { + LOG.severe(ex.getMessage()); + throw new RuntimeException(ex); + } + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/command/CreateDiskCommand.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/CreateDiskCommand.java new file mode 100644 index 0000000..a5db4c9 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/CreateDiskCommand.java @@ -0,0 +1,79 @@ +package io.github.applecommander.acx.command; + +import java.util.logging.Logger; + +import com.webcodepro.applecommander.storage.Disk; +import com.webcodepro.applecommander.storage.FormattedDisk; +import com.webcodepro.applecommander.storage.os.dos33.DosFormatDisk; +import com.webcodepro.applecommander.storage.os.dos33.OzDosFormatDisk; +import com.webcodepro.applecommander.storage.os.dos33.UniDosFormatDisk; +import com.webcodepro.applecommander.storage.os.pascal.PascalFormatDisk; +import com.webcodepro.applecommander.storage.os.prodos.ProdosFormatDisk; +import com.webcodepro.applecommander.storage.physical.ImageOrder; + +import io.github.applecommander.acx.SystemType; +import io.github.applecommander.acx.base.ReusableCommandOptions; +import io.github.applecommander.acx.converter.DataSizeConverter; +import io.github.applecommander.acx.converter.SystemTypeConverter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command(name = "create", description = "Create a disk image.", + aliases = { "mkdisk" }) +public class CreateDiskCommand extends ReusableCommandOptions { + private static Logger LOG = Logger.getLogger(CreateDiskCommand.class.getName()); + + @Option(names = { "-d", "--disk" }, description = "Image to create [$ACX_DISK_NAME].", required = true, + defaultValue = "${ACX_DISK_NAME}") + private String imageName; + + @Option(names = { "-t", "--type" }, required = true, converter = SystemTypeConverter.class, + description = "Select system type (DOS, ProDOS, Pascal.") + private SystemType type; + + @Option(names = { "-s", "--size" }, defaultValue = "140kb", converter = DataSizeConverter.class, + description = "Select disk size (140K, 800K, 10M).") + private int size; + + @Option(names = { "-f", "--format" }, + description = "Disk to copy system files/tracks/boot sector from.") + private String formatSource; + + @Option(names = { "-n", "--name" }, defaultValue = "NEW.DISK", + description = "Disk Volume name (ProDOS/Pascal).") + private String diskName; + + @Override + public int handleCommand() throws Exception { + LOG.info(() -> String.format("Creating %s image of type %s.", DataSizeConverter.format(size), type)); + + ImageOrder order = type.createImageOrder(size); + FormattedDisk[] disks = null; + switch (type) { + case DOS: + disks = DosFormatDisk.create(imageName, order); + break; + case OZDOS: + disks = OzDosFormatDisk.create(imageName, order); + break; + case UNIDOS: + disks = UniDosFormatDisk.create(imageName, order); + break; + case PRODOS: + disks = ProdosFormatDisk.create(imageName, diskName, order); + break; + case PASCAL: + disks = PascalFormatDisk.create(imageName, diskName, order); + break; + } + + if (formatSource != null) { + Disk systemSource = new Disk(formatSource); + type.copySystem(disks[0], systemSource.getFormattedDisks()[0]); + } + + saveDisk(disks[0]); + + return 0; + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/command/DeleteCommand.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/DeleteCommand.java new file mode 100644 index 0000000..26ae2ac --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/DeleteCommand.java @@ -0,0 +1,31 @@ +package io.github.applecommander.acx.command; + +import java.util.logging.Logger; + +import io.github.applecommander.acx.base.ReadWriteDiskCommandWithGlobOptions; +import io.github.applecommander.filestreamer.FileTuple; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command(name = "delete", description = "Delete file(s) from a disk image.", + aliases = { "del", "rm" }) +public class DeleteCommand extends ReadWriteDiskCommandWithGlobOptions { + private static Logger LOG = Logger.getLogger(DeleteCommand.class.getName()); + + @Option(names = { "-f", "--force" }, description = "Force delete locked files.") + private boolean forceFlag; + + public void fileHandler(FileTuple tuple) { + if (tuple.fileEntry.isLocked()) { + if (forceFlag) { + LOG.info(() -> String.format("File '%s' is locked, but 'force' specified; ignoring lock.", + tuple.fileEntry.getFilename())); + } else { + LOG.warning(() -> String.format("File '%s' is locked.", tuple.fileEntry.getFilename())); + return; + } + } + tuple.fileEntry.delete(); + LOG.info(() -> String.format("File '%s' deleted.", tuple.fileEntry.getFilename())); + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/command/DiskMapCommand.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/DiskMapCommand.java new file mode 100644 index 0000000..ca81336 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/DiskMapCommand.java @@ -0,0 +1,96 @@ +package io.github.applecommander.acx.command; + +import java.util.Arrays; +import java.util.function.Function; + +import com.webcodepro.applecommander.storage.FormattedDisk; +import com.webcodepro.applecommander.storage.FormattedDisk.DiskUsage; + +import io.github.applecommander.acx.base.ReadOnlyDiskImageCommandOptions; +import picocli.CommandLine.Command; + +@Command(name = "diskmap", description = "Show disk usage map.", + aliases = { "map" }) +public class DiskMapCommand extends ReadOnlyDiskImageCommandOptions { + @Override + public int handleCommand() throws Exception { + Arrays.asList(disk.getFormattedDisks()).forEach(this::showDiskMap); + return 0; + } + + public void showDiskMap(FormattedDisk formattedDisk) { + final int[] dimensions = formattedDisk.getBitmapDimensions(); + final int length = formattedDisk.getBitmapLength(); + final int width,height; + final Function leftNumFn, rightNumFn; + if (dimensions != null && dimensions.length == 2) { + height = dimensions[0]; + width = dimensions[1]; + // This is expected to be Track, so same number on left and right. + leftNumFn = rightNumFn = i -> i; + } else { + width = 70; + height= (length + width - 1) / width; + // This is expected to be blocks, so show start of range of + // left and end of range on right. + leftNumFn = i -> i * width; + rightNumFn = i -> (i + 1) * width - 1; + } + + title(formattedDisk.getBitmapLabels()); + header1(width); // 10's position + header2(width); // 1's position + header3(width); // divider + + DiskUsage diskUsage = formattedDisk.getDiskUsage(); + for (int y=0; y globs = Arrays.asList("*"); + + public void validate() { + List errors = new ArrayList<>(); + // multiple files require --output + if (isMultipleFiles()) { + if (outputFile == null) { + errors.add("--output directory must be specified with multiple files"); + } else if (!outputFile.isDirectory()) { + errors.add("--output must be a directory"); + } + } + if (!errors.isEmpty()) { + throw new ParameterException(spec.commandLine(), String.join(", ", errors)); + } + } + + @Override + public int handleCommand() throws Exception { + validate(); + + Consumer fileHandler = + (outputFile == null) ? this::writeToStdout : this::writeToOutput; + + FileStreamer.forDisk(disk) + .ignoreErrors(true) + .includeDeleted(deletedFlag) + .includeTypeOfFile(TypeOfFile.FILE) + .matchGlobs(globs) + .stream() + .forEach(fileHandler); + + return 0; + } + + public boolean hasFiles() { + return globs != null && globs.size() > 1; + } + public boolean isAllFiles() { + return globs == null || globs.isEmpty(); + } + public boolean isMultipleFiles() { + return hasFiles() || isAllFiles(); + } + + public void writeToStdout(FileTuple tuple) { + try { + FileFilter ff = extraction.extractFunction.apply(tuple.fileEntry); + System.out.write(ff.filter(tuple.fileEntry)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + public void writeToOutput(FileTuple tuple) { + File file = outputFile; + FileFilter ff = extraction.extractFunction.apply(tuple.fileEntry); + if (file.isDirectory()) { + if (!tuple.paths.isEmpty()) { + file = new File(outputFile, String.join(File.pathSeparator, tuple.paths)); + boolean created = file.mkdirs(); + if (created) LOG.info(String.format("Directory created: %s", file.getPath())); + } + file = new File(file, ff.getSuggestedFileName(tuple.fileEntry)); + } + LOG.info(String.format("Writing to '%s'", file.getPath())); + try (OutputStream out = new FileOutputStream(file)) { + out.write(ff.filter(tuple.fileEntry)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static class FileExtractMethods { + private Function extractFunction = this::asSuggestedFile; + + @Option(names = { "--raw", "--binary" }, description = "Extract file in native format.") + public void setBinaryExtraction(boolean flag) { + this.extractFunction = this::asRawFile; + } + @Option(names = { "--hex", "--dump" }, description = "Extract file in hex dump format.") + public void setHexDumpExtraction(boolean flag) { + this.extractFunction = this::asHexDumpFile; + } + @Option(names = { "--suggested" }, description = "Extract file as suggested by AppleCommander (default)") + public void setSuggestedExtraction(boolean flag) { + this.extractFunction = this::asSuggestedFile; + } + @Option(names = { "--as", "--applesingle" }, description = "Extract file to AppleSingle file.") + public void setAppleSingleExtraction(boolean flag) { + this.extractFunction = this::asAppleSingleFile; + } + + public FileFilter asRawFile(FileEntry entry) { + return new RawFileFilter(); + } + public FileFilter asSuggestedFile(FileEntry entry) { + FileFilter ff = entry.getSuggestedFilter(); + if (ff instanceof BinaryFileFilter) { + ff = new HexDumpFileFilter(); + } + return ff; + } + public FileFilter asHexDumpFile(FileEntry entry) { + return new HexDumpFileFilter(); + } + public FileFilter asAppleSingleFile(FileEntry entry) { + return new AppleSingleFileFilter(); + } + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/command/ImportCommand.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/ImportCommand.java new file mode 100644 index 0000000..555a751 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/ImportCommand.java @@ -0,0 +1,330 @@ +package io.github.applecommander.acx.command; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.Queue; +import java.util.function.Function; +import java.util.logging.Logger; + +import com.webcodepro.applecommander.storage.DirectoryEntry; +import com.webcodepro.applecommander.storage.FileEntry; +import com.webcodepro.applecommander.storage.os.prodos.ProdosFormatDisk; +import com.webcodepro.applecommander.util.AppleUtil; +import com.webcodepro.applecommander.util.StreamUtil; +import com.webcodepro.applecommander.util.TranslatorStream; +import com.webcodepro.shrinkit.HeaderBlock; +import com.webcodepro.shrinkit.NuFileArchive; +import com.webcodepro.shrinkit.ThreadRecord; + +import io.github.applecommander.acx.base.ReadWriteDiskCommandOptions; +import io.github.applecommander.acx.converter.IntegerTypeConverter; +import io.github.applecommander.acx.fileutil.FileEntryReader; +import io.github.applecommander.acx.fileutil.FileUtils; +import io.github.applecommander.acx.fileutil.OverrideFileEntryReader; +import io.github.applecommander.applesingle.AppleSingle; +import io.github.applecommander.applesingle.FileDatesInfo; +import io.github.applecommander.applesingle.ProdosFileInfo; +import io.github.applecommander.bastools.api.Configuration; +import io.github.applecommander.bastools.api.Parser; +import io.github.applecommander.bastools.api.TokenReader; +import io.github.applecommander.bastools.api.Visitors; +import io.github.applecommander.bastools.api.model.Program; +import io.github.applecommander.bastools.api.model.Token; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +@Command(name = "import", description = "Import file onto disk.", + aliases = { "put" }) +public class ImportCommand extends ReadWriteDiskCommandOptions { + private static Logger LOG = Logger.getLogger(ImportCommand.class.getName()); + + @ArgGroup(heading = "%nInput source:%n", multiplicity = "1") + private InputData inputData; + + @ArgGroup(heading = "%nProcessing options:%n") + private Processor processor; + + @ArgGroup(heading = "%nGeneral overrides:%n", exclusive = false) + private Overrides overrides = new Overrides(); + + @Option(names = { "--dir" }, description = "Write file(s) to directory.") + private Optional directoryName; + + @Option(names = { "-f", "--force" }, description = "Over-write existing files.") + private boolean overwriteFlag; + + @Override + public int handleCommand() throws Exception { + DirectoryEntry directory = disk.getFormattedDisks()[0]; + if (directoryName.isPresent()) { + String[] dirs = directoryName.get().split("/"); + for (String dir : dirs) { + Optional fileEntry = directory.getFiles().stream() + .filter(f -> dir.equalsIgnoreCase(f.getFilename())) + .findFirst(); + Optional dirEntry = fileEntry + .filter(FileEntry::isDirectory) + .map(DirectoryEntry.class::cast); + directory = dirEntry.orElseThrow(() -> + new IOException(String.format("Directory '%s' not found.", dir))); + } + } + + FileUtils copier = new FileUtils(overwriteFlag); + FileEntryReader inputReader = inputData.get(); + for (FileEntryReader processorReader : processor.apply(inputReader)) { + FileEntryReader reader = OverrideFileEntryReader.builder() + .filename(overrides.fileName) + .prodosFiletype(overrides.fileType) + .binaryAddress(overrides.fileAddress) + .build(processorReader); + + copier.copyFile(directory, reader); + } + + return 0; + } + + public static class InputData { + private FileEntryReader fileEntryReader; + + public FileEntryReader get() { + return fileEntryReader; + } + + @Option(names = { "--stdin" }, description = "Import from standard input.") + public void stdinFlag(boolean flag) { + try { + byte[] data = System.in.readAllBytes(); + fileEntryReader = OverrideFileEntryReader.builder() + .fileData(data) + .filename("UNKNOWN") + .prodosFiletype("BIN") + .build(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Parameters(description = "File to import.") + public void fromFile(final String filename) { + try { + Path path = Path.of(filename); + byte[] data = Files.readAllBytes(path); + fileEntryReader = OverrideFileEntryReader.builder() + .fileData(data) + .filename(filename) + .prodosFiletype("BIN") + .build(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + } + + public static class Processor { + private Function> fileEntryReaderFn; + + public List apply(FileEntryReader reader) { + return fileEntryReaderFn.apply(reader); + } + + @Option(names = { "--text", "--text-high" }, description = { + "Import as a text file, setting high bit and ", + "replacing newline characters with $8D." }) + public void setTextModeSetHighBit(boolean textFlag) { + fileEntryReaderFn = this::handleTextModeSetHighBit; + } + + @Option(names = { "--text-low" }, description = { + "Import as a text file, clearing high bit and ", + "replacing newline characters with $8D." }) + public void setTextModeClearHighBit(boolean textFlag) { + fileEntryReaderFn = this::handleTextModeClearHighBit; + } + + @Option(names = { "--dos" }, description = "Use standard 4-byte DOS header.") + public void setDosMode(boolean dosFlag) { + fileEntryReaderFn = this::handleDosMode; + } + + @Option(names = { "--geos" }, description = "Interpret as a GEOS conversion file.") + public void setGeosMode(boolean geosFlag) { + fileEntryReaderFn = this::handleGeosMode; + } + + @Option(names = { "--basic" }, description = "Tokenize an AppleSoft BASIC program.") + public void setApplesoftTokenizerMode(boolean tokenizeMode) { + fileEntryReaderFn = this::handleApplesoftTokenizeMode; + } + + @Option(names = { "--as", "--applesingle" }, description = "Import Apple Single file.") + public void setAppleSingleMode(boolean applesingleMode) { + fileEntryReaderFn = this::handleAppleSingleMode; + } + + @Option(names = { "--shk", "--nufx", "--shrinkit", "--bxy" }, + description = "Import files from SHK archive.") + public void setShrinkitMode(boolean shrinkitMode) { + fileEntryReaderFn = this::handleShrinkitMode; + } + + private List handleTextModeSetHighBit(FileEntryReader reader) { + InputStream inputStream = new ByteArrayInputStream(reader.getFileData().get()); + return handleTextMode(TranslatorStream.builder(inputStream) + .lfToCr().setHighBit().get(), reader); + } + private List handleTextModeClearHighBit(FileEntryReader reader) { + InputStream inputStream = new ByteArrayInputStream(reader.getFileData().get()); + return handleTextMode(TranslatorStream.builder(inputStream) + .lfToCr().clearHighBit().get(), reader); + } + private List handleTextMode(InputStream inputStream, FileEntryReader reader) { + try { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + StreamUtil.copy(inputStream, outputStream); + final byte[] translatedData = outputStream.toByteArray(); + return OverrideFileEntryReader.builder() + .fileData(translatedData) + .prodosFiletype("TXT") + .buildList(reader); + } catch (IOException cause) { + throw new UncheckedIOException(cause); + } + } + + private List handleDosMode(FileEntryReader reader) { + try { + ByteArrayInputStream inputStream = new ByteArrayInputStream(reader.getFileData().get()); + byte[] header = new byte[4]; + if (inputStream.read(header) != 4) { + throw new IOException("Unable to read DOS header."); + } + int address = AppleUtil.getWordValue(header, 0); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + inputStream.transferTo(outputStream); + return OverrideFileEntryReader.builder() + .fileData(outputStream.toByteArray()) + .binaryAddress(address) + .buildList(reader); + } catch (IOException cause) { + throw new UncheckedIOException(cause); + } + } + + private List handleGeosMode(FileEntryReader reader) { + return OverrideFileEntryReader.builder() + .prodosFiletype("GEO") + .binaryAddress(0) + .buildList(reader); + } + + private List handleApplesoftTokenizeMode(FileEntryReader reader) { + try { + File fakeTempSource = File.createTempFile("ac-", "bas"); + fakeTempSource.deleteOnExit(); + Configuration config = Configuration.builder().sourceFile(fakeTempSource).build(); + Queue tokens = TokenReader.tokenize(new ByteArrayInputStream(reader.getFileData().get())); + Parser parser = new Parser(tokens); + Program program = parser.parse(); + byte[] tokenData = Visitors.byteVisitor(config).dump(program); + return OverrideFileEntryReader.builder() + .fileData(tokenData) + .prodosFiletype("BAS") + .binaryAddress(config.startAddress) + .buildList(reader); + } catch (IOException cause) { + throw new UncheckedIOException(cause); + } + } + + private List handleAppleSingleMode(FileEntryReader reader) { + try { + AppleSingle as = AppleSingle.read(reader.getFileData().get()); + if (as.getProdosFileInfo() == null) { + throw new IOException("This AppleSingle does not contain a ProDOS file."); + } + if (as.getDataFork() == null || as.getDataFork().length == 0) { + throw new IOException("This AppleSingle does not contain a data fork."); + } + ProdosFileInfo info = as.getProdosFileInfo(); + String fileType = ProdosFormatDisk.getFiletype(info.getFileType()); + + OverrideFileEntryReader.Builder builder = OverrideFileEntryReader.builder(); + builder.filename(Optional.ofNullable(as.getRealName())); + builder.fileData(as.getDataFork()); + builder.resourceData(Optional.ofNullable(as.getResourceFork())); + builder.prodosFiletype(fileType); + builder.locked(info.getAccess() == 0xc3); + builder.auxiliaryType(info.getAuxType()); + + if (as.getFileDatesInfo() != null) { + FileDatesInfo dates = as.getFileDatesInfo(); + builder.creationDate(Date.from(dates.getCreationInstant())); + builder.lastModificationDate(Date.from(dates.getModificationInstant())); + } + + return builder.buildList(reader); + } catch (IOException cause) { + throw new UncheckedIOException(cause); + } + } + + private List handleShrinkitMode(FileEntryReader reader) { + try { + List files = new ArrayList<>(); + ByteArrayInputStream inputStream = new ByteArrayInputStream(reader.getFileData().get()); + NuFileArchive nufx = new NuFileArchive(inputStream); + for (HeaderBlock header : nufx.getHeaderBlocks()) { + OverrideFileEntryReader.Builder builder = OverrideFileEntryReader.builder() + .filename(header.getFilename()) + .prodosFiletype(ProdosFormatDisk.getFiletype((int)header.getFileType())) + .auxiliaryType((int)header.getExtraType()) + .creationDate(header.getCreateWhen()) + .lastModificationDate(header.getModWhen()); + + ThreadRecord dataFork = header.getDataForkThreadRecord(); + ThreadRecord resourceFork = header.getResourceForkThreadRecord(); + if (dataFork == null) { + LOG.info(() -> String.format("No data fork for '%s', skipping it.", + header.getFilename())); + continue; + } + + builder.fileData(dataFork.getBytes()); + if (resourceFork != null) { + builder.resourceData(resourceFork.getBytes()); + } + files.add(builder.build()); + } + return files; + } catch (IOException cause) { + throw new UncheckedIOException(cause); + } + } + } + + public static class Overrides { + @Option(names = { "-t", "--type" }, description = "File type.") + private Optional fileType; + + @Option(names = { "-a", "--addr" }, description = "File address.", + converter = IntegerTypeConverter.class) + private Optional fileAddress; + + @Option(names = { "-n", "--name" }, description = "File name.") + private Optional fileName; + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/command/InfoCommand.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/InfoCommand.java new file mode 100644 index 0000000..18b44a4 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/InfoCommand.java @@ -0,0 +1,30 @@ +package io.github.applecommander.acx.command; + +import java.util.logging.Logger; + +import com.webcodepro.applecommander.storage.FormattedDisk; +import com.webcodepro.applecommander.storage.FormattedDisk.DiskInformation; + +import io.github.applecommander.acx.base.ReadOnlyDiskImageCommandOptions; +import picocli.CommandLine.Command; + +@Command(name = "info", description = "Show information on a disk image(s).", + aliases = "i") +public class InfoCommand extends ReadOnlyDiskImageCommandOptions { + private static Logger LOG = Logger.getLogger(InfoCommand.class.getName()); + + @Override + public int handleCommand() throws Exception { + LOG.info(() -> "Path: " + disk.getFilename()); + FormattedDisk[] formattedDisks = disk.getFormattedDisks(); + for (int i = 0; i < formattedDisks.length; i++) { + FormattedDisk formattedDisk = formattedDisks[i]; + LOG.info(() -> String.format("Disk: %s (%s)", formattedDisk.getDiskName(), formattedDisk.getFormat())); + for (DiskInformation diskinfo : formattedDisk.getDiskInformation()) { + System.out.printf("%s: %s\n", diskinfo.getLabel(), diskinfo.getValue()); + } + System.out.println(); + } + return 0; + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/command/ListCommand.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/ListCommand.java new file mode 100644 index 0000000..300e519 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/ListCommand.java @@ -0,0 +1,143 @@ +package io.github.applecommander.acx.command; + +import java.util.ArrayList; +import java.util.List; + +import com.webcodepro.applecommander.storage.FormattedDisk; +import com.webcodepro.applecommander.storage.FormattedDisk.FileColumnHeader; + +import io.github.applecommander.acx.base.ReadOnlyDiskImageCommandOptions; +import io.github.applecommander.filestreamer.FileStreamer; +import io.github.applecommander.filestreamer.FileTuple; +import io.github.applecommander.filestreamer.TypeOfFile; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command(name = "list", description = "List directory of disk image(s).", + aliases = { "ls" }) +public class ListCommand extends ReadOnlyDiskImageCommandOptions { + @ArgGroup(exclusive = true, multiplicity = "0..1", heading = "%nFile display formatting:%n") + private FileDisplay fileDisplay = new FileDisplay(); + + @Option(names = { "-r", "--recursive"}, description = "Display directory recursively.", negatable = true, defaultValue = "false") + private boolean recursiveFlag; + + @Option(names = { "--deleted" }, description = "Show deleted files.") + private boolean deletedFlag; + + @ArgGroup(exclusive = true, multiplicity = "0..1") + private TypeOfFileSelection typeOfFile = new TypeOfFileSelection(); + + @Option(names = "--header", negatable = true, description = "Show header.") + private boolean headerFlag = true; + + @Option(names = "--column", negatable = true, description = "Show column headers.") + private boolean columnFlag = true; + + @Option(names = "--footer", negatable = true, description = "Show footer.") + private boolean footerFlag = true; + + @Option(names = "--globs", defaultValue = "*", split = ",", description = "File glob(s) to match.") + private List globs = new ArrayList(); + + private List fmtSpec; + + @Override + public int handleCommand() throws Exception { + FileStreamer.forDisk(disk) + .ignoreErrors(true) + .includeDeleted(deletedFlag) + .recursive(recursiveFlag) + .includeTypeOfFile(typeOfFile.typeOfFile()) + .matchGlobs(globs) + .beforeDisk(this::header) + .afterDisk(this::footer) + .stream() + .forEach(this::list); + return 0; + } + + protected void header(FormattedDisk disk) { + List headers = disk.getFileColumnHeaders(fileDisplay.format()); + fmtSpec = createFormatSpec(headers); + + System.out.println(); + System.out.printf("File: %s\n", disk.getFilename()); + System.out.printf("Name: %s\n", disk.getDiskName()); + } + + protected void list(FileTuple tuple) { + if (!deletedFlag && tuple.fileEntry.isDeleted()) { + return; + } + + List data = tuple.fileEntry.getFileColumnData(fileDisplay.format()); + for (int i=0; i createFormatSpec(List fileColumnHeaders) { + List fmtSpec = new ArrayList<>(); + for (FileColumnHeader h : fileColumnHeaders) { + String spec = String.format("%%%s%ds ", h.isRightAlign() ? "" : "-", + h.getMaximumWidth()); + fmtSpec.add(spec); + } + return fmtSpec; + } + + public static class FileDisplay { + public int format() { + if (standardFormat) { + return FormattedDisk.FILE_DISPLAY_STANDARD; + } + if (longFormat) { + return FormattedDisk.FILE_DISPLAY_DETAIL; + } + return FormattedDisk.FILE_DISPLAY_NATIVE; + } + + @Option(names = { "-n", "--native" }, description = "Use native directory format (default).") + private boolean nativeFormat; + + @Option(names = { "-s", "--short", "--standard" }, description = "Use brief directory format.") + private boolean standardFormat; + + @Option(names = { "-l", "--long", "--detail" }, description = "Use long/detailed directory format.") + private boolean longFormat; + } + + public static class TypeOfFileSelection { + public TypeOfFile typeOfFile() { + if (filesOnly) { + return TypeOfFile.FILE; + } + if (directoriesOnly) { + return TypeOfFile.DIRECTORY; + } + return TypeOfFile.BOTH; + } + + @Option(names = "--file", description = "Only include files.") + private boolean filesOnly; + + @Option(names = "--directory", description = "Only include directories.") + private boolean directoriesOnly; + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/command/LockCommand.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/LockCommand.java new file mode 100644 index 0000000..89577cb --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/LockCommand.java @@ -0,0 +1,17 @@ +package io.github.applecommander.acx.command; + +import java.util.logging.Logger; + +import io.github.applecommander.acx.base.ReadWriteDiskCommandWithGlobOptions; +import io.github.applecommander.filestreamer.FileTuple; +import picocli.CommandLine.Command; + +@Command(name = "lock", description = "Lock file(s) on a disk image.") +public class LockCommand extends ReadWriteDiskCommandWithGlobOptions { + private static Logger LOG = Logger.getLogger(LockCommand.class.getName()); + + public void fileHandler(FileTuple tuple) { + tuple.fileEntry.setLocked(true); + LOG.info(() -> String.format("File '%s' locked.", tuple.fileEntry.getFilename())); + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/command/MkdirCommand.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/MkdirCommand.java new file mode 100644 index 0000000..3329bbd --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/MkdirCommand.java @@ -0,0 +1,55 @@ +package io.github.applecommander.acx.command; + +import java.util.Optional; + +import com.webcodepro.applecommander.storage.DirectoryEntry; +import com.webcodepro.applecommander.storage.FileEntry; +import com.webcodepro.applecommander.storage.FormattedDisk; + +import io.github.applecommander.acx.base.ReadWriteDiskCommandOptions; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +@Command(name = "mkdir", description = "Create a directory on disk.", + aliases = { "md" }) +public class MkdirCommand extends ReadWriteDiskCommandOptions { + @Option(names = { "-p" }, description = "Create intermediate subdirectories.") + private boolean prefixFlag; + + @Parameters(description = "Directory name to create (use '/' as divider).") + private String fullPath; + + @Override + public int handleCommand() throws Exception { + FormattedDisk formattedDisk = disk.getFormattedDisks()[0]; + DirectoryEntry directory = formattedDisk; + + String[] paths = fullPath.split("/"); + for (int i=0; i optEntry = directory.getFiles().stream() + .filter(entry -> entry.getFilename().equalsIgnoreCase(pathName)) + .findFirst(); + + if (optEntry.isPresent()) { + FileEntry fileEntry = optEntry.get(); + if (fileEntry instanceof DirectoryEntry) { + directory = (DirectoryEntry)fileEntry; + } + else { + throw new RuntimeException(String.format("Not a directory: '%s'", pathName)); + } + } + else { + if (prefixFlag || i == paths.length-1) { + directory = directory.createDirectory(pathName); + } + else { + throw new RuntimeException(String.format("Directory does not exist: '%s'", pathName)); + } + } + } + return 0; + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/command/RenameDiskCommand.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/RenameDiskCommand.java new file mode 100644 index 0000000..31ca39a --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/RenameDiskCommand.java @@ -0,0 +1,32 @@ +package io.github.applecommander.acx.command; + +import java.util.logging.Logger; + +import com.webcodepro.applecommander.storage.FormattedDisk; +import com.webcodepro.applecommander.storage.os.pascal.PascalFormatDisk; +import com.webcodepro.applecommander.storage.os.prodos.ProdosFormatDisk; + +import io.github.applecommander.acx.base.ReadWriteDiskCommandOptions; +import picocli.CommandLine.Command; +import picocli.CommandLine.Parameters; + +@Command(name = "rename-disk", description = "Rename volume of a disk image.") +public class RenameDiskCommand extends ReadWriteDiskCommandOptions { + private static Logger LOG = Logger.getLogger(RenameDiskCommand.class.getName()); + + @Parameters(description = "Disk name.") + private String diskName; + + @Override + public int handleCommand() throws Exception { + FormattedDisk[] formattedDisks = disk.getFormattedDisks(); + FormattedDisk formattedDisk = formattedDisks[0]; + if (formattedDisk instanceof ProdosFormatDisk || formattedDisk instanceof PascalFormatDisk) { + formattedDisk.setDiskName(diskName); + return 0; + } else { + LOG.warning("Disk must be ProDOS or Pascal."); + return 1; + } + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/command/RenameFileCommand.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/RenameFileCommand.java new file mode 100644 index 0000000..be99144 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/RenameFileCommand.java @@ -0,0 +1,67 @@ +package io.github.applecommander.acx.command; + +import java.util.List; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +import io.github.applecommander.acx.base.ReadWriteDiskCommandOptions; +import io.github.applecommander.filestreamer.FileStreamer; +import io.github.applecommander.filestreamer.FileTuple; +import io.github.applecommander.filestreamer.TypeOfFile; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +@Command(name = "rename", description = "Rename file on a disk image.", + aliases = { "ren" }) +public class RenameFileCommand extends ReadWriteDiskCommandOptions { + private static Logger LOG = Logger.getLogger(RenameFileCommand.class.getName()); + + @Option(names = { "-m", "--multiple" }, description = "Force rename when multiple files found.") + private boolean multipleOverride; + + @Option(names = { "-f", "--force" }, description = "Rename locked files.") + private boolean lockOverride; + + @Parameters(index = "0", description = "Original file name (include path).") + private String originalFilename; + + @Parameters(index = "1", description = "New file name (just the new filename).") + private String newFilename; + + @Override + public int handleCommand() throws Exception { + List files = FileStreamer.forDisk(disk) + .ignoreErrors(true) + .includeTypeOfFile(TypeOfFile.FILE) + .matchGlobs(originalFilename) + .stream() + .collect(Collectors.toList()); + + if (files.isEmpty()) { + LOG.warning(() -> String.format("File not found for %s.", originalFilename)); + } + else if (!multipleOverride && files.size() > 1) { + LOG.severe(() -> String.format("Multile files with %s found (count = %d).", + originalFilename, files.size())); + } + else { + files.forEach(this::fileHandler); + } + return 0; + } + + public void fileHandler(FileTuple tuple) { + if (tuple.fileEntry.isLocked()) { + if (lockOverride) { + LOG.info(() -> String.format("File '%s' is locked, but 'force' specified; ignoring lock.", + tuple.fileEntry.getFilename())); + } else { + LOG.warning(() -> String.format("File '%s' is locked.", tuple.fileEntry.getFilename())); + return; + } + } + tuple.fileEntry.setFilename(newFilename); + LOG.info(() -> String.format("File '%s' renamed to '%s'.", originalFilename, newFilename)); + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/command/RmdirCommand.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/RmdirCommand.java new file mode 100644 index 0000000..ccb3327 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/RmdirCommand.java @@ -0,0 +1,79 @@ +package io.github.applecommander.acx.command; + +import java.util.Optional; + +import com.webcodepro.applecommander.storage.DirectoryEntry; +import com.webcodepro.applecommander.storage.DiskException; +import com.webcodepro.applecommander.storage.FileEntry; +import com.webcodepro.applecommander.storage.FormattedDisk; + +import io.github.applecommander.acx.base.ReadWriteDiskCommandOptions; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +@Command(name = "rmdir", description = "Remove a directory on disk.", + aliases = { "rd" }) +public class RmdirCommand extends ReadWriteDiskCommandOptions { + @Option(names = { "-r", "--recursive" }, description = "Recursively delete subdirectories.") + private boolean recursiveFlag; + + @Option(names = { "-f", "--force" }, description = "Force files to be deleted as well.") + private boolean forceFlag; + + @Parameters(description = "Directory name to delete (use '/' as divider).") + private String fullPath; + + @Override + public int handleCommand() throws Exception { + FormattedDisk formattedDisk = disk.getFormattedDisks()[0]; + + // Locate directory + DirectoryEntry directory = formattedDisk; + String[] paths = fullPath.split("/"); + for (int i=0; i optEntry = directory.getFiles().stream() + .filter(entry -> entry.getFilename().equalsIgnoreCase(pathName)) + .findFirst(); + + if (optEntry.isPresent()) { + FileEntry fileEntry = optEntry.get(); + if (fileEntry instanceof DirectoryEntry) { + directory = (DirectoryEntry)fileEntry; + } + else { + throw new RuntimeException(String.format("Not a directory: '%s'", pathName)); + } + } + else { + throw new RuntimeException(String.format("Directory does not exist: '%s'", pathName)); + } + } + + deleteDirectory(directory); + return 0; + } + + public void deleteDirectory(DirectoryEntry directory) throws DiskException { + for (FileEntry file : directory.getFiles()) { + if (file.isDeleted()) { + // skip + } else if (recursiveFlag && file.isDirectory()) { + deleteDirectory((DirectoryEntry)file); + } + else if (forceFlag && !file.isDirectory()) { + file.delete(); + } + else { + String message = String.format("Encountered %s '%s'", + file.isDirectory() ? "directory" : "file", + file.getFilename()); + throw new RuntimeException(message); + } + } + + FileEntry file = (FileEntry)directory; + file.delete(); + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/command/UnlockCommand.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/UnlockCommand.java new file mode 100644 index 0000000..950e330 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/command/UnlockCommand.java @@ -0,0 +1,17 @@ +package io.github.applecommander.acx.command; + +import java.util.logging.Logger; + +import io.github.applecommander.acx.base.ReadWriteDiskCommandWithGlobOptions; +import io.github.applecommander.filestreamer.FileTuple; +import picocli.CommandLine.Command; + +@Command(name = "unlock", description = "Unlock file(s) on a disk image.") +public class UnlockCommand extends ReadWriteDiskCommandWithGlobOptions { + private static Logger LOG = Logger.getLogger(UnlockCommand.class.getName()); + + public void fileHandler(FileTuple tuple) { + tuple.fileEntry.setLocked(false); + LOG.info(() -> String.format("File '%s' unlocked.", tuple.fileEntry.getFilename())); + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/converter/DataSizeConverter.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/converter/DataSizeConverter.java new file mode 100644 index 0000000..345db90 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/converter/DataSizeConverter.java @@ -0,0 +1,48 @@ +package io.github.applecommander.acx.converter; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import picocli.CommandLine.ITypeConverter; +import picocli.CommandLine.TypeConversionException; + +public class DataSizeConverter implements ITypeConverter { + public static final int KB = 1024; + public static final int MB = KB * 1024; + + @Override + public Integer convert(String value) throws Exception { + Pattern pattern = Pattern.compile("([0-9]+)([km]b?)?", Pattern.CASE_INSENSITIVE); + Matcher matcher = pattern.matcher(value); + if (matcher.matches()) { + String number = matcher.group(1); + String kmb = matcher.group(2); + if (kmb != null) { + kmb = kmb.toLowerCase(); + } + int bytes = Integer.parseInt(number); + if (kmb.startsWith("k")) { + bytes *= KB; + } + else if (kmb.startsWith("m")) { + bytes *= MB; + } + else { + throw new TypeConversionException(String.format("Unexpected data size '%s'", kmb)); + } + return bytes; + } + throw new TypeConversionException("Expecting format like '140kb' or '5mb'"); + } + + public static String format(int value) { + if (value < KB) { + return String.format("%,dB", value); + } + if (value < MB) { + return String.format("%,dKB", value / KB); + } + return String.format("%,dMB", value / MB); + } + +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/converter/DiskConverter.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/converter/DiskConverter.java new file mode 100644 index 0000000..2002f12 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/converter/DiskConverter.java @@ -0,0 +1,19 @@ +package io.github.applecommander.acx.converter; + +import java.nio.file.Files; +import java.nio.file.Path; + +import com.webcodepro.applecommander.storage.Disk; + +import picocli.CommandLine.ITypeConverter; +import picocli.CommandLine.TypeConversionException; + +public class DiskConverter implements ITypeConverter { + @Override + public Disk convert(String filename) throws Exception { + if (Files.exists(Path.of(filename))) { + return new Disk(filename); + } + throw new TypeConversionException(String.format("Disk '%s' not found", filename)); + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/converter/IntegerTypeConverter.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/converter/IntegerTypeConverter.java new file mode 100644 index 0000000..b828911 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/converter/IntegerTypeConverter.java @@ -0,0 +1,19 @@ +package io.github.applecommander.acx.converter; + +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); + } + } +} \ No newline at end of file diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/converter/SystemTypeConverter.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/converter/SystemTypeConverter.java new file mode 100644 index 0000000..306b41c --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/converter/SystemTypeConverter.java @@ -0,0 +1,11 @@ +package io.github.applecommander.acx.converter; + +import io.github.applecommander.acx.SystemType; +import picocli.CommandLine.ITypeConverter; + +public class SystemTypeConverter implements ITypeConverter { + @Override + public SystemType convert(String value) throws Exception { + return SystemType.valueOf(value.replaceAll("[^a-zA-Z]", "").toUpperCase()); + } +} \ No newline at end of file diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/DosFileEntryReaderWriter.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/DosFileEntryReaderWriter.java new file mode 100644 index 0000000..bcaa160 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/DosFileEntryReaderWriter.java @@ -0,0 +1,113 @@ +package io.github.applecommander.acx.fileutil; + +import java.util.Map; +import java.util.Optional; + +import com.webcodepro.applecommander.storage.DiskFullException; +import com.webcodepro.applecommander.storage.os.dos33.DosFileEntry; +import com.webcodepro.applecommander.storage.os.dos33.DosFormatDisk; +import com.webcodepro.applecommander.util.AppleUtil; + +public class DosFileEntryReaderWriter implements FileEntryReader, FileEntryWriter { + private static final Map FILE_TYPES; + static { + FILE_TYPES = Map.of( + "T", "TXT", + "I", "INT", + "A", "BAS", + "B", "BIN", + "S", "$F1", + "R", "REL", + "a", "$F2", + "b", "$F3" + ); + } + + private DosFileEntry fileEntry; + + public DosFileEntryReaderWriter(DosFileEntry fileEntry) { + if (fileEntry.isDeleted()) { + throw new RuntimeException("Unable to copy deleted files."); + } + this.fileEntry = fileEntry; + } + + @Override + public Optional getFilename() { + return Optional.of(fileEntry.getFilename()); + } + @Override + public void setFilename(String filename) { + fileEntry.setFilename(filename); + } + + @Override + public Optional getProdosFiletype() { + return Optional.ofNullable(FILE_TYPES.get(fileEntry.getFiletype())); + } + @Override + public void setProdosFiletype(String filetype) { + String dosFileType = FILE_TYPES.entrySet() + .stream() + .filter(e -> e.getValue().equals(filetype)) + .map(Map.Entry::getKey) + .findFirst() + .orElse("B"); + fileEntry.setFiletype(dosFileType); + } + + @Override + public Optional isLocked() { + return Optional.of(fileEntry.isLocked()); + } + @Override + public void setLocked(boolean flag) { + fileEntry.setLocked(flag); + } + + @Override + public Optional getFileData() { + return Optional.ofNullable(fileEntry.getFileData()); + } + @Override + public void setFileData(byte[] data) { + try { + fileEntry.setFileData(data); + } catch (DiskFullException e) { + throw new RuntimeException(e); + } + } + + @Override + public Optional getBinaryAddress() { + if (fileEntry.isBinaryFile()) { + byte[] rawdata = getRawFileData(); + return Optional.of(AppleUtil.getWordValue(rawdata, 0)); + } + return Optional.empty(); + } + @Override + public void setBinaryAddress(int address) { + if (fileEntry.needsAddress()) { + fileEntry.setAddress(address); + } + } + + @Override + public Optional getBinaryLength() { + if (fileEntry.isBinaryFile() || fileEntry.isApplesoftBasicFile() || fileEntry.isIntegerBasicFile()) { + // DosFileEntry pulls the address + return Optional.of(fileEntry.getSize()); + } + return Optional.empty(); + } + @Override + public void setBinaryLength(int length) { + // Nothing to do + } + + private byte[] getRawFileData() { + DosFormatDisk disk = (DosFormatDisk) fileEntry.getFormattedDisk(); + return disk.getFileData(fileEntry); + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/FileEntryReader.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/FileEntryReader.java new file mode 100644 index 0000000..ff584f8 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/FileEntryReader.java @@ -0,0 +1,54 @@ +package io.github.applecommander.acx.fileutil; + +import java.util.Date; +import java.util.Optional; + +import com.webcodepro.applecommander.storage.FileEntry; +import com.webcodepro.applecommander.storage.os.dos33.DosFileEntry; +import com.webcodepro.applecommander.storage.os.nakedos.NakedosFileEntry; +import com.webcodepro.applecommander.storage.os.pascal.PascalFileEntry; +import com.webcodepro.applecommander.storage.os.prodos.ProdosFileEntry; +import com.webcodepro.applecommander.storage.os.rdos.RdosFileEntry; + +public interface FileEntryReader { + // FileEntry common + public default Optional getFilename() { return Optional.empty(); } + public default Optional getProdosFiletype() { return Optional.empty(); } + public default Optional isLocked() { return Optional.empty(); } + public default Optional getFileData() { return Optional.empty(); } + public default Optional getResourceData() { return Optional.empty(); } + /** + * The address embedded in binary objects. + * This varies by DOS's so is split apart. + */ + public default Optional getBinaryAddress() { return Optional.empty(); } + /** + * The length embedded in binary, Applesoft, Integer BASIC objects. + * This varies by DOS's so is split apart. + */ + public default Optional getBinaryLength() { return Optional.empty(); } + // ProdosFileEntry specific + public default Optional getAuxiliaryType() { return Optional.empty(); } + public default Optional getCreationDate() { return Optional.empty(); } + // ProdosFileEntry / PascalFileEntry specific + public default Optional getLastModificationDate() { return Optional.empty(); } + + public static FileEntryReader get(FileEntry fileEntry) { + if (fileEntry instanceof DosFileEntry) { + return new DosFileEntryReaderWriter((DosFileEntry)fileEntry); + } + else if (fileEntry instanceof NakedosFileEntry) { + return new NakedosFileEntryReader((NakedosFileEntry)fileEntry); + } + else if (fileEntry instanceof PascalFileEntry) { + return new PascalFileEntryReaderWriter((PascalFileEntry)fileEntry); + } + else if (fileEntry instanceof ProdosFileEntry) { + return new ProdosFileEntryReaderWriter((ProdosFileEntry)fileEntry); + } + else if (fileEntry instanceof RdosFileEntry) { + return new RdosFileEntryReader((RdosFileEntry)fileEntry); + } + throw new RuntimeException(String.format("No reader for %s", fileEntry.getClass().getName())); + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/FileEntryWriter.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/FileEntryWriter.java new file mode 100644 index 0000000..9590a03 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/FileEntryWriter.java @@ -0,0 +1,46 @@ +package io.github.applecommander.acx.fileutil; + +import java.util.Date; + +import com.webcodepro.applecommander.storage.FileEntry; +import com.webcodepro.applecommander.storage.os.dos33.DosFileEntry; +import com.webcodepro.applecommander.storage.os.pascal.PascalFileEntry; +import com.webcodepro.applecommander.storage.os.prodos.ProdosFileEntry; + +public interface FileEntryWriter { + // FileEntry common + public default void setFilename(String filename) { } + public default void setProdosFiletype(String filetype) { } + public default void setLocked(boolean flag) { } + public default void setFileData(byte[] data) { } + // Special case for GS/OS files (uglifies API; sets 0x05) + public default void setFileData(byte[] data, byte[] resource) { } + /** + * The address embedded in binary objects. + * This varies by DOS's so is split apart. + */ + public default void setBinaryAddress(int address) { } + /** + * The length embedded in binary, Applesoft, Integer BASIC objects. + * This varies by DOS's so is split apart. + */ + public default void setBinaryLength(int length) { } + // ProdosFileEntry specific + public default void setAuxiliaryType(int auxType) { } + public default void setCreationDate(Date date) { } + // ProdosFileEntry / PascalFileEntry specific + public default void setLastModificationDate(Date date) { } + + public static FileEntryWriter get(FileEntry fileEntry) { + if (fileEntry instanceof DosFileEntry) { + return new DosFileEntryReaderWriter((DosFileEntry)fileEntry); + } + else if (fileEntry instanceof ProdosFileEntry) { + return new ProdosFileEntryReaderWriter((ProdosFileEntry)fileEntry); + } + else if (fileEntry instanceof PascalFileEntry) { + return new PascalFileEntryReaderWriter((PascalFileEntry)fileEntry); + } + throw new RuntimeException(String.format("No writer for %s", fileEntry.getClass().getName())); + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/FileUtils.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/FileUtils.java new file mode 100644 index 0000000..67af8c3 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/FileUtils.java @@ -0,0 +1,101 @@ +package io.github.applecommander.acx.fileutil; + +import java.util.Optional; +import java.util.logging.Logger; + +import com.webcodepro.applecommander.storage.DirectoryEntry; +import com.webcodepro.applecommander.storage.DiskException; +import com.webcodepro.applecommander.storage.FileEntry; + +import io.github.applecommander.acx.command.CopyFileCommand; + +public class FileUtils { + private static Logger LOG = Logger.getLogger(CopyFileCommand.class.getName()); + + private boolean overwrite; + + public FileUtils(boolean overwrite) { + this.overwrite = overwrite; + } + + public void copy(DirectoryEntry directory, FileEntry file) throws DiskException { + LOG.fine(() -> String.format("Copying '%s'", file.getFilename())); + if (file.isDeleted()) { + // Skip deleted files + } + else if (file.isDirectory()) { + copyDirectory(directory, (DirectoryEntry)file, file.getFilename()); + } + else { + copyFile(directory, file); + } + } + + void copyDirectory(DirectoryEntry targetParent, DirectoryEntry sourceDir, String name) throws DiskException { + Optional targetFile = targetParent.getFiles() + .stream() + .filter(fileEntry -> name.equals(fileEntry.getFilename())) + .findFirst(); + Optional targetDir = targetFile + .filter(FileEntry::isDirectory) + .map(DirectoryEntry.class::cast); + + if (targetDir.isPresent()) { + // Fall through to general logic + } + else if (targetFile.isPresent()) { + // This is an abstract class, so faking it for now. + throw new DiskException("Unable to create directory", name) { + private static final long serialVersionUID = 4726414295404986677L; + }; + } + else { + targetDir = Optional.of(targetParent.createDirectory(name)); + } + + for (FileEntry fileEntry : sourceDir.getFiles()) { + copy(targetDir.get(), fileEntry); + } + } + + void copyFile(DirectoryEntry directory, FileEntry sourceFile) throws DiskException { + FileEntryReader source = FileEntryReader.get(sourceFile); + copyFile(directory, source); + } + + public void copyFile(DirectoryEntry directory, FileEntryReader source) throws DiskException { + String sourceName = source.getFilename().get(); + String sanitizedName = directory.getFormattedDisk().getSuggestedFilename(sourceName); + final Optional fileEntry = directory.getFiles().stream() + .filter(entry -> entry.getFilename().equals(sanitizedName)) + .findFirst(); + + final FileEntry targetFile; + if (fileEntry.isPresent()) { + targetFile = fileEntry + .filter(entry -> overwrite) + .orElseThrow(() -> new RuntimeException(String.format("File '%s' exists.", + source.getFilename().get()))); + } + else { + targetFile = directory.createFile(); + } + + FileEntryWriter target = FileEntryWriter.get(targetFile); + + source.getFilename().ifPresent(target::setFilename); + source.getProdosFiletype().ifPresent(target::setProdosFiletype); + source.isLocked().ifPresent(target::setLocked); + source.getBinaryAddress().ifPresent(target::setBinaryAddress); + source.getBinaryLength().ifPresent(target::setBinaryLength); + source.getAuxiliaryType().ifPresent(target::setAuxiliaryType); + source.getCreationDate().ifPresent(target::setCreationDate); + source.getLastModificationDate().ifPresent(target::setLastModificationDate); + + if (source.getFileData().isPresent() && source.getResourceData().isPresent()) { + target.setFileData(source.getFileData().get(), source.getResourceData().get()); + } else { + source.getFileData().ifPresent(target::setFileData); + } + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/NakedosFileEntryReader.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/NakedosFileEntryReader.java new file mode 100644 index 0000000..14ba1db --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/NakedosFileEntryReader.java @@ -0,0 +1,22 @@ +package io.github.applecommander.acx.fileutil; + +import java.util.Optional; + +import com.webcodepro.applecommander.storage.os.nakedos.NakedosFileEntry; + +public class NakedosFileEntryReader implements FileEntryReader { + private NakedosFileEntry fileEntry; + + public NakedosFileEntryReader(NakedosFileEntry fileEntry) { + this.fileEntry = fileEntry; + } + + @Override + public Optional getFilename() { + return Optional.of(fileEntry.getFilename()); + } + @Override + public Optional getFileData() { + return Optional.of(fileEntry.getFileData()); + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/OverrideFileEntryReader.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/OverrideFileEntryReader.java new file mode 100644 index 0000000..c383f47 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/OverrideFileEntryReader.java @@ -0,0 +1,188 @@ +package io.github.applecommander.acx.fileutil; + +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Allow programmatic control of what is in the results of the file entry. + * Useful for translating from raw data. + * It can also be used to layer in results and overrides. + */ +public class OverrideFileEntryReader implements FileEntryReader { + private Optional parent = Optional.empty(); + private Optional filename = Optional.empty(); + private Optional prodosFiletype = Optional.empty(); + private Optional locked = Optional.empty(); + private Optional fileData = Optional.empty(); + private Optional resourceData = Optional.empty(); + private Optional binaryAddress = Optional.empty(); + private Optional binaryLength = Optional.empty(); + private Optional auxiliaryType = Optional.empty(); + private Optional creationDate = Optional.empty(); + private Optional lastModificationDate = Optional.empty(); + + @Override + public Optional getFilename() { + return filename.or(() -> parent.map(FileEntryReader::getFilename).filter(Optional::isPresent).map(Optional::get)); + } + @Override + public Optional getProdosFiletype() { + return prodosFiletype.or(() -> parent.map(FileEntryReader::getProdosFiletype).filter(Optional::isPresent).map(Optional::get)); + } + @Override + public Optional isLocked() { + return locked.or(() -> parent.map(FileEntryReader::isLocked).filter(Optional::isPresent).map(Optional::get)); + } + @Override + public Optional getFileData() { + return fileData.or(() -> parent.map(FileEntryReader::getFileData).filter(Optional::isPresent).map(Optional::get)); + } + @Override + public Optional getResourceData() { + // Special case, the AppleCommander API does not really handle resource forks. + return resourceData; + } + @Override + public Optional getBinaryAddress() { + return binaryAddress.or(() -> parent.map(FileEntryReader::getBinaryAddress).filter(Optional::isPresent).map(Optional::get)); + } + @Override + public Optional getBinaryLength() { + return binaryLength.or(() -> parent.map(FileEntryReader::getBinaryLength).filter(Optional::isPresent).map(Optional::get)); + } + @Override + public Optional getAuxiliaryType() { + return auxiliaryType.or(() -> parent.map(FileEntryReader::getBinaryLength).filter(Optional::isPresent).map(Optional::get)); + } + @Override + public Optional getCreationDate() { + return creationDate.or(() -> parent.map(FileEntryReader::getCreationDate).filter(Optional::isPresent).map(Optional::get)); + } + @Override + public Optional getLastModificationDate() { + return lastModificationDate.or(() -> parent.map(FileEntryReader::getLastModificationDate).filter(Optional::isPresent).map(Optional::get)); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private OverrideFileEntryReader fileEntryReader = new OverrideFileEntryReader(); + + public FileEntryReader build(FileEntryReader parent) { + Objects.requireNonNull(parent); + fileEntryReader.parent = Optional.of(parent); + return fileEntryReader; + } + public List buildList(FileEntryReader parent) { + return Arrays.asList(build(parent)); + } + public FileEntryReader build() { + return fileEntryReader; + } + public List buildList() { + return Arrays.asList(build()); + } + + public Builder filename(String filename) { + Objects.requireNonNull(filename); + fileEntryReader.filename = Optional.of(filename); + return this; + } + public Builder filename(Optional filename) { + Objects.requireNonNull(filename); + fileEntryReader.filename = filename; + return this; + } + public Builder prodosFiletype(String filetype) { + Objects.requireNonNull(filetype); + fileEntryReader.prodosFiletype = Optional.of(filetype); + return this; + } + public Builder prodosFiletype(Optional filetype) { + Objects.requireNonNull(filetype); + fileEntryReader.prodosFiletype = filetype; + return this; + } + public Builder locked(boolean locked) { + fileEntryReader.locked = Optional.of(locked); + return this; + } + public Builder locked(Optional locked) { + Objects.requireNonNull(locked); + fileEntryReader.locked = locked; + return this; + } + public Builder fileData(byte[] fileData) { + Objects.requireNonNull(fileData); + fileEntryReader.fileData = Optional.of(fileData); + return this; + } + public Builder fileData(Optional fileData) { + Objects.requireNonNull(fileData); + fileEntryReader.fileData = fileData; + return this; + } + public Builder resourceData(byte[] resourceData) { + Objects.requireNonNull(resourceData); + fileEntryReader.resourceData = Optional.of(resourceData); + return this; + } + public Builder resourceData(Optional resourceData) { + Objects.requireNonNull(resourceData); + fileEntryReader.resourceData = resourceData; + return this; + } + public Builder binaryAddress(int binaryAddress) { + fileEntryReader.binaryAddress = Optional.of(binaryAddress); + return this; + } + public Builder binaryAddress(Optional binaryAddress) { + Objects.requireNonNull(binaryAddress); + fileEntryReader.binaryAddress = binaryAddress; + return this; + } + public Builder binaryLength(int binaryLength) { + fileEntryReader.binaryLength = Optional.of(binaryLength); + return this; + } + public Builder binaryLength(Optional binaryLength) { + Objects.requireNonNull(binaryLength); + fileEntryReader.binaryLength = binaryLength; + return this; + } + public Builder auxiliaryType(int auxiliaryType) { + fileEntryReader.auxiliaryType = Optional.of(auxiliaryType); + return this; + } + public Builder auxiliaryType(Optional auxiliaryType) { + Objects.requireNonNull(auxiliaryType); + fileEntryReader.auxiliaryType = auxiliaryType; + return this; + } + public Builder creationDate(Date creationDate) { + Objects.requireNonNull(creationDate); + fileEntryReader.creationDate = Optional.of(creationDate); + return this; + } + public Builder creationDate(Optional creationDate) { + Objects.requireNonNull(creationDate); + fileEntryReader.creationDate = creationDate; + return this; + } + public Builder lastModificationDate(Date lastModificationDate) { + Objects.requireNonNull(lastModificationDate); + fileEntryReader.lastModificationDate = Optional.of(lastModificationDate); + return this; + } + public Builder lastModificationDate(Optional lastModificationDate) { + Objects.requireNonNull(lastModificationDate); + fileEntryReader.lastModificationDate = lastModificationDate; + return this; + } + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/PascalFileEntryReaderWriter.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/PascalFileEntryReaderWriter.java new file mode 100644 index 0000000..22e3b72 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/PascalFileEntryReaderWriter.java @@ -0,0 +1,82 @@ +package io.github.applecommander.acx.fileutil; + +import java.util.Date; +import java.util.Map; +import java.util.Optional; + +import com.webcodepro.applecommander.storage.DiskFullException; +import com.webcodepro.applecommander.storage.filters.PascalTextFileFilter; +import com.webcodepro.applecommander.storage.os.pascal.PascalFileEntry; + +public class PascalFileEntryReaderWriter implements FileEntryReader, FileEntryWriter { + private static final PascalTextFileFilter TEXT_FILTER = new PascalTextFileFilter(); + private static final Map FILE_TYPES; + static { + FILE_TYPES = Map.of( + // Pascal => Prodos + "xdskfile", "BAD", // TODO we should skip bad block files + "CODE", "BIN", // TODO is there an address? + "TEXT", "TXT", + "INFO", "TXT", // TODO We should skip debugger info + "DATA", "BIN", + "GRAF", "BIN", // TODO compressed graphics image + "FOTO", "BIN", // TODO screen image + "securedir", "BIN", // TODO is this even implemented + + // Prodos => Pascal + "BIN", "DATA", + "TXT", "TEXT" + ); + } + + private PascalFileEntry fileEntry; + + public PascalFileEntryReaderWriter(PascalFileEntry fileEntry) { + this.fileEntry = fileEntry; + } + + @Override + public Optional getFilename() { + return Optional.ofNullable(fileEntry.getFilename()); + } + @Override + public void setFilename(String filename) { + fileEntry.setFilename(filename); + } + + @Override + public Optional getProdosFiletype() { + return Optional.ofNullable(FILE_TYPES.get(fileEntry.getFiletype())); + } + @Override + public void setProdosFiletype(String filetype) { + fileEntry.setFiletype(FILE_TYPES.getOrDefault(filetype, "DATA")); + } + + @Override + public Optional getLastModificationDate() { + return Optional.ofNullable(fileEntry.getModificationDate()); + } + @Override + public void setLastModificationDate(Date date) { + fileEntry.setModificationDate(date); + } + + @Override + public Optional getFileData() { + if ("TEXT".equals(fileEntry.getFiletype())) { + return Optional.ofNullable(TEXT_FILTER.filter(fileEntry)); + } + else { + return Optional.ofNullable(fileEntry.getFileData()); + } + } + @Override + public void setFileData(byte[] data) { + try { + fileEntry.setFileData(data); + } catch (DiskFullException e) { + throw new RuntimeException(e); + } + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/ProdosFileEntryReaderWriter.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/ProdosFileEntryReaderWriter.java new file mode 100644 index 0000000..f3257ce --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/ProdosFileEntryReaderWriter.java @@ -0,0 +1,116 @@ +package io.github.applecommander.acx.fileutil; + +import java.util.Date; +import java.util.Optional; + +import com.webcodepro.applecommander.storage.DiskFullException; +import com.webcodepro.applecommander.storage.os.prodos.ProdosFileEntry; + +public class ProdosFileEntryReaderWriter implements FileEntryReader, FileEntryWriter { + private ProdosFileEntry fileEntry; + + public ProdosFileEntryReaderWriter(ProdosFileEntry fileEntry) { + this.fileEntry = fileEntry; + } + + @Override + public Optional getFilename() { + return Optional.ofNullable(fileEntry.getFilename()); + } + @Override + public void setFilename(String filename) { + fileEntry.setFilename(filename); + } + + @Override + public Optional getProdosFiletype() { + return Optional.ofNullable(fileEntry.getFiletype()); + } + @Override + public void setProdosFiletype(String filetype) { + fileEntry.setFiletype(filetype); + } + + @Override + public Optional isLocked() { + return Optional.ofNullable(fileEntry.isLocked()); + } + @Override + public void setLocked(boolean flag) { + fileEntry.setLocked(flag); + } + + @Override + public Optional getFileData() { + return Optional.ofNullable(fileEntry.getFileData()); + } + @Override + public void setFileData(byte[] data) { + try { + fileEntry.setFileData(data); + } catch (DiskFullException e) { + throw new RuntimeException(e); + } + } + @Override + public void setFileData(byte[] data, byte[] resource) { + try { + // If we have a resource fork in addition to a data fork, + // then we've got a GSOS storage type $5. + fileEntry.setFileData(data, resource); + fileEntry.setStorageType(0x05); + } catch (DiskFullException e) { + throw new RuntimeException(e); + } + } + + @Override + public Optional getBinaryAddress() { + if (fileEntry.needsAddress()) { + return Optional.ofNullable(fileEntry.getAuxiliaryType()); + } + return Optional.empty(); + } + @Override + public void setBinaryAddress(int address) { + if (fileEntry.needsAddress()) { + fileEntry.setAddress(address); + } + } + + @Override + public Optional getBinaryLength() { + return Optional.ofNullable(fileEntry.getSize()); + } + @Override + public void setBinaryLength(int length) { + // Nothing to do + } + + @Override + public Optional getAuxiliaryType() { + return Optional.ofNullable(fileEntry.getAuxiliaryType()); + } + @Override + public void setAuxiliaryType(int auxType) { + fileEntry.setAuxiliaryType(auxType); + } + + @Override + public Optional getCreationDate() { + return Optional.ofNullable(fileEntry.getCreationDate()); + } + @Override + public void setCreationDate(Date date) { + fileEntry.setCreationDate(date); + } + + @Override + public Optional getLastModificationDate() { + return Optional.ofNullable(fileEntry.getLastModificationDate()); + } + @Override + public void setLastModificationDate(Date date) { + fileEntry.setLastModificationDate(date); + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/RdosFileEntryReader.java b/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/RdosFileEntryReader.java new file mode 100644 index 0000000..13aff19 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/acx/fileutil/RdosFileEntryReader.java @@ -0,0 +1,43 @@ +package io.github.applecommander.acx.fileutil; + +import java.util.Map; +import java.util.Optional; + +import com.webcodepro.applecommander.storage.os.rdos.RdosFileEntry; + +public class RdosFileEntryReader implements FileEntryReader { + private static final Map FILE_TYPES; + static { + FILE_TYPES = Map.of( + "T", "TXT", + "A", "BAS", + "B", "BIN" + ); + } + + private RdosFileEntry fileEntry; + + public RdosFileEntryReader(RdosFileEntry fileEntry) { + this.fileEntry = fileEntry; + } + + @Override + public Optional getFilename() { + return Optional.ofNullable(fileEntry.getFilename()); + } + + @Override + public Optional getBinaryAddress() { + return Optional.ofNullable(fileEntry.getAddress()); + } + + @Override + public Optional getProdosFiletype() { + return Optional.ofNullable(FILE_TYPES.get(fileEntry.getFiletype())); + } + + @Override + public Optional getFileData() { + return Optional.ofNullable(fileEntry.getFileData()); + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/filestreamer/FileStreamer.java b/app/ac-acx/src/main/java/io/github/applecommander/filestreamer/FileStreamer.java new file mode 100644 index 0000000..089a4a8 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/filestreamer/FileStreamer.java @@ -0,0 +1,205 @@ +package io.github.applecommander.filestreamer; + +import java.io.File; +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Spliterators; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import com.webcodepro.applecommander.storage.Disk; +import com.webcodepro.applecommander.storage.DiskException; +import com.webcodepro.applecommander.storage.DiskUnrecognizedException; +import com.webcodepro.applecommander.storage.FileEntry; +import com.webcodepro.applecommander.storage.FormattedDisk; + +/** + * FileStreamer is utility class that will (optionally) recurse through all directories and + * feed a Java Stream of useful directory walking detail (disk, directory, file, and the + * textual path to get there). + *

+ * Sample usage: + *

+ * FileStreamer.forDisk(image)
+ *             .ignoreErrors(true)
+ *             .stream()
+ *             .filter(this::fileFilter)
+ *             .forEach(fileHandler);
+ * 
+ * + * @author rob + */ +public class FileStreamer { + private static final Consumer NOOP_CONSUMER = d -> {}; + + public static FileStreamer forDisk(File file) throws IOException, DiskUnrecognizedException { + return forDisk(file.getPath()); + } + public static FileStreamer forDisk(String fileName) throws IOException, DiskUnrecognizedException { + return new FileStreamer(new Disk(fileName)); + } + public static FileStreamer forDisk(Disk disk) throws DiskUnrecognizedException { + return new FileStreamer(disk); + } + + private FormattedDisk[] formattedDisks = null; + + // Processor flags (used in gathering) + private boolean ignoreErrorsFlag = false; + private boolean recursiveFlag = true; + + // Processor events + private Consumer beforeDisk = NOOP_CONSUMER; + private Consumer afterDisk = NOOP_CONSUMER; + + // Filters + private Predicate filters = this::deletedFileFilter; + private boolean includeDeletedFlag = false; + private List pathMatchers = new ArrayList<>(); + + private FileStreamer(Disk disk) throws DiskUnrecognizedException { + this.formattedDisks = disk.getFormattedDisks(); + } + + public FileStreamer ignoreErrors(boolean flag) { + this.ignoreErrorsFlag = flag; + return this; + } + public FileStreamer recursive(boolean flag) { + this.recursiveFlag = flag; + return this; + } + public FileStreamer matchGlobs(List globs) { + if (globs != null && !globs.isEmpty()) { + FileSystem fs = FileSystems.getDefault(); + for (String glob : globs) { + pathMatchers.add(fs.getPathMatcher("glob:" + glob)); + } + this.filters = filters.and(this::globFilter); + } + return this; + } + public FileStreamer matchGlobs(String... globs) { + return matchGlobs(Arrays.asList(globs)); + } + public FileStreamer includeTypeOfFile(TypeOfFile type) { + this.filters = filters.and(type.predicate); + return this; + } + public FileStreamer includeDeleted(boolean flag) { + this.includeDeletedFlag = flag; + return this; + } + public FileStreamer beforeDisk(Consumer consumer) { + this.beforeDisk = consumer; + return this; + } + public FileStreamer afterDisk(Consumer consumer) { + this.afterDisk = consumer; + return this; + } + + public Stream stream() { + return StreamSupport.stream(Spliterators.spliteratorUnknownSize(iterator(), 0), false) + .filter(filters); + } + public Iterator iterator() { + return new FileTupleIterator(); + } + + protected boolean deletedFileFilter(FileTuple tuple) { + return includeDeletedFlag || !tuple.fileEntry.isDeleted(); + } + protected boolean globFilter(FileTuple tuple) { + if (tuple.fileEntry.isDirectory()) { + // If we don't match directories, no files can be listed. + return true; + } + // This may cause issues, but Path is a "real" filesystem construct, so the delimiters + // vary by OS (likely just "/" and "\"). However, Java also erases them to some degree, + // so using "/" (as used in ProDOS) will likely work out. + // Also note that we check the single file "PARMS.S" and full path "SOURCE/PARMS.S" since + // the user might have entered "*.S" or something like "SOURCE/PARMS.S". + FileSystem fs = FileSystems.getDefault(); + Path filePath = Paths.get(tuple.fileEntry.getFilename()); + Path fullPath = Paths.get(String.join(fs.getSeparator(), tuple.paths), + tuple.fileEntry.getFilename()); + for (PathMatcher pathMatcher : pathMatchers) { + if (pathMatcher.matches(filePath) || pathMatcher.matches(fullPath)) return true; + } + return false; + } + + private class FileTupleIterator implements Iterator { + private LinkedList files = new LinkedList<>(); + private FormattedDisk currentDisk; + + private FileTupleIterator() { + for (FormattedDisk formattedDisk : formattedDisks) { + files.addAll(toTupleList(FileTuple.of(formattedDisk))); + } + } + + @Override + public boolean hasNext() { + boolean hasNext = !files.isEmpty(); + if (hasNext) { + FileTuple tuple = files.peek(); + // Was there a disk switch? + if (tuple.formattedDisk != currentDisk) { + if (currentDisk != null) { + afterDisk.accept(currentDisk); + } + currentDisk = tuple.formattedDisk; + beforeDisk.accept(currentDisk); + } + } else { + if (currentDisk != null) { + afterDisk.accept(currentDisk); + } + currentDisk = null; + } + return hasNext; + } + + @Override + public FileTuple next() { + if (hasNext()) { + FileTuple tuple = files.removeFirst(); + if (recursiveFlag && tuple.fileEntry.isDirectory()) { + FileTuple newTuple = tuple.pushd(tuple.fileEntry); + files.addAll(0, toTupleList(newTuple)); + } + return tuple; + } else { + throw new NoSuchElementException(); + } + } + + private List toTupleList(FileTuple tuple) { + List list = new ArrayList<>(); + try { + for (FileEntry fileEntry : tuple.directoryEntry.getFiles()) { + list.add(tuple.of(fileEntry)); + } + } catch (DiskException e) { + if (!ignoreErrorsFlag) { + throw new RuntimeException(e); + } + } + return list; + } + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/filestreamer/FileTuple.java b/app/ac-acx/src/main/java/io/github/applecommander/filestreamer/FileTuple.java new file mode 100644 index 0000000..73cfab9 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/filestreamer/FileTuple.java @@ -0,0 +1,42 @@ +package io.github.applecommander.filestreamer; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.logging.Logger; + +import com.webcodepro.applecommander.storage.DirectoryEntry; +import com.webcodepro.applecommander.storage.FileEntry; +import com.webcodepro.applecommander.storage.FormattedDisk; + +public class FileTuple { + private static final Logger LOG = Logger.getLogger(FileTuple.class.getName()); + public final FormattedDisk formattedDisk; + public final List paths; + public final DirectoryEntry directoryEntry; + public final FileEntry fileEntry; + + private FileTuple(FormattedDisk formattedDisk, + List paths, + DirectoryEntry directoryEntry, + FileEntry fileEntry) { + this.formattedDisk = formattedDisk; + this.paths = Collections.unmodifiableList(paths); + this.directoryEntry = directoryEntry; + this.fileEntry = fileEntry; + } + + public FileTuple pushd(FileEntry directoryEntry) { + LOG.fine("Adding directory " + directoryEntry.getFilename()); + List newPaths = new ArrayList<>(paths); + newPaths.add(directoryEntry.getFilename()); + return new FileTuple(formattedDisk, newPaths, (DirectoryEntry)directoryEntry, null); + } + public FileTuple of(FileEntry fileEntry) { + return new FileTuple(formattedDisk, paths, directoryEntry, fileEntry); + } + + public static FileTuple of(FormattedDisk disk) { + return new FileTuple(disk, new ArrayList(), (DirectoryEntry)disk, null); + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/filestreamer/TypeOfFile.java b/app/ac-acx/src/main/java/io/github/applecommander/filestreamer/TypeOfFile.java new file mode 100644 index 0000000..ce85c99 --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/filestreamer/TypeOfFile.java @@ -0,0 +1,15 @@ +package io.github.applecommander.filestreamer; + +import java.util.function.Predicate; + +public enum TypeOfFile { + FILE(tuple -> !tuple.fileEntry.isDirectory()), + DIRECTORY(tuple -> tuple.fileEntry.isDirectory()), + BOTH(tuple -> true); + + public final Predicate predicate; + + private TypeOfFile(Predicate predicate) { + this.predicate = predicate; + } +} \ No newline at end of file diff --git a/app/ac-acx/src/main/java/io/github/applecommander/filters/AppleSingleFileFilter.java b/app/ac-acx/src/main/java/io/github/applecommander/filters/AppleSingleFileFilter.java new file mode 100644 index 0000000..c3cc63f --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/filters/AppleSingleFileFilter.java @@ -0,0 +1,84 @@ +package io.github.applecommander.filters; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; + +import com.webcodepro.applecommander.storage.FileEntry; +import com.webcodepro.applecommander.storage.FileFilter; +import com.webcodepro.applecommander.storage.os.dos33.DosFileEntry; +import com.webcodepro.applecommander.storage.os.prodos.ProdosFileEntry; + +import io.github.applecommander.applesingle.AppleSingle; + +/** + * A FileFilter to write each file to an independent AppleSingle file. + */ +public class AppleSingleFileFilter implements FileFilter { + @Override + public byte[] filter(FileEntry fileEntry) { + try { + AppleSingle.Builder builder = AppleSingle.builder() + .dataFork(fileEntry.getFileData()) + .realName(fileEntry.getFilename()); + if (fileEntry instanceof ProdosFileEntry) { + handleProDOS(builder, (ProdosFileEntry)fileEntry); + } + else if (fileEntry instanceof DosFileEntry) { + handleDOS(builder, (DosFileEntry)fileEntry); + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + builder.build().save(baos); + return baos.toByteArray(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + protected void handleProDOS(AppleSingle.Builder builder, ProdosFileEntry prodos) { + // We can't get the "access" byte so reconstructing it... + int access = (prodos.canDestroy() ? 0x80 : 0x00) + | (prodos.canRename() ? 0x40 : 0x00) + | (prodos.canWrite() ? 0x02 : 0x00) + | (prodos.canRead() ? 0x01 : 0x00); + builder.access(access); + builder.auxType(prodos.getAuxiliaryType()); + builder.creationDate(prodos.getCreationDate().toInstant()); + builder.fileType(prodos.getFiletypeByte()); + builder.modificationDate(prodos.getLastModificationDate().toInstant()); + } + protected void handleDOS(AppleSingle.Builder builder, DosFileEntry dos) { + switch (dos.getFiletype()) { + // 0x00 T + case "T": + builder.fileType(0x04); // TXT + break; + // 0x01 I + case "I": + builder.fileType(0xfa); // INT + break; + // 0x02 A + case "A": + builder.fileType(0xfc); // BAS + break; + // 0x04 B + case "B": + builder.fileType(0x06); // BIN + //builder.auxType(???) // FIXME address is not exposed? + break; + // The rest we just default + default: + builder.fileType(0xf1); // $F1 (???) + break; + } + builder.access(dos.isLocked() ? 0x01 : 0xc3); + } + + @Override + public String getSuggestedFileName(FileEntry fileEntry) { + String fileName = fileEntry.getFilename().trim(); + if (!fileName.toLowerCase().endsWith(".as")) { + fileName = fileName + ".as"; + } + return fileName; + } +} diff --git a/app/ac-acx/src/main/java/io/github/applecommander/filters/RawFileFilter.java b/app/ac-acx/src/main/java/io/github/applecommander/filters/RawFileFilter.java new file mode 100644 index 0000000..ff4db0d --- /dev/null +++ b/app/ac-acx/src/main/java/io/github/applecommander/filters/RawFileFilter.java @@ -0,0 +1,25 @@ +package io.github.applecommander.filters; + +import com.webcodepro.applecommander.storage.FileEntry; +import com.webcodepro.applecommander.storage.FileFilter; + +/** + * A custom FileFilter to dump "raw" data from the disk. + * This filter uses the filename as given on the Disk with + * no additional extensions. + * + * @author rob + */ +public class RawFileFilter implements FileFilter { + + @Override + public byte[] filter(FileEntry fileEntry) { + return fileEntry.getFileData(); + } + + @Override + public String getSuggestedFileName(FileEntry fileEntry) { + return fileEntry.getFilename(); + } + +} diff --git a/app/ac-acx/src/test/java/io/github/applecommander/acx/converter/DataSizeConverterTest.java b/app/ac-acx/src/test/java/io/github/applecommander/acx/converter/DataSizeConverterTest.java new file mode 100644 index 0000000..345b5ce --- /dev/null +++ b/app/ac-acx/src/test/java/io/github/applecommander/acx/converter/DataSizeConverterTest.java @@ -0,0 +1,27 @@ +package io.github.applecommander.acx.converter; + +import static io.github.applecommander.acx.converter.DataSizeConverter.KB; +import static io.github.applecommander.acx.converter.DataSizeConverter.MB; +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class DataSizeConverterTest { + @Test + public void testFormat() { + assertEquals("1B", DataSizeConverter.format(1)); + assertEquals("100B", DataSizeConverter.format(100)); + assertEquals("2KB", DataSizeConverter.format(2*KB)); + assertEquals("140KB", DataSizeConverter.format(140*KB)); + assertEquals("800KB", DataSizeConverter.format(800*KB)); + assertEquals("5MB", DataSizeConverter.format(5*MB)); + } + + @Test + public void testConvert() throws Exception { + DataSizeConverter converter = new DataSizeConverter(); + assertEquals(140*KB, (int)converter.convert("140kb")); + assertEquals(800*KB, (int)converter.convert("800KB")); + assertEquals(5*MB, (int)converter.convert("5Mb")); + } +} diff --git a/app/ac-acx/src/test/java/io/github/applecommander/filestreamer/FileStreamerTest.java b/app/ac-acx/src/test/java/io/github/applecommander/filestreamer/FileStreamerTest.java new file mode 100644 index 0000000..36f6ebb --- /dev/null +++ b/app/ac-acx/src/test/java/io/github/applecommander/filestreamer/FileStreamerTest.java @@ -0,0 +1,85 @@ +package io.github.applecommander.filestreamer; + +import static org.junit.Assert.assertEquals; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import com.webcodepro.applecommander.storage.DiskUnrecognizedException; + +import org.junit.Test; + +public class FileStreamerTest { + private static final List EXPECTED_MERLIN = Arrays.asList( + "PRODOS", "MERLIN.SYSTEM", "PARMS", "ED", "ED.16", + "SOURCEROR", "SOURCEROR/OBJ", "SOURCEROR/LABELS", "SOURCEROR/LABELS.S", + "LIBRARY", "LIBRARY/SENDMSG.S", "LIBRARY/PRDEC.S", "LIBRARY/FPMACROS.S", + "LIBRARY/MACROS.S", "LIBRARY/ROCKWELL.S", + "SOURCE", "SOURCE/PARMS.S", "SOURCE/EDMAC.S", "SOURCE/KEYMAC.S", + "SOURCE/PRINTFILER.S", "SOURCE/MAKE.DUMP.S", "SOURCE/CLOCK.S", + "SOURCE/PI.START.S", "SOURCE/PI.MAIN.S", "SOURCE/PI.LOOK.S", + "SOURCE/PI.DIV.S", "SOURCE/PI.ADD.S", "SOURCE/PI.MACS.S", + "SOURCE/PI.NAMES.S", + "UTILITIES", "UTILITIES/REMOVE.ED", "UTILITIES/EDMAC", "UTILITIES/CLOCK.12.ED", + "UTILITIES/XREF", "UTILITIES/XREFA", "UTILITIES/FORMATTER", + "UTILITIES/PRINTFILER", "UTILITIES/MON.65C02", "UTILITIES/MAKE.DUMP", + "UTILITIES/CONV.REL.LNK", "UTILITIES/CONV.LNK.REL", + "UTILITIES/CLR.HI.BIT", "UTILITIES/KEYMAC", + "PI", "PI/NAMES", "PI/START", "PI/MAIN", "PI/LOOK", "PI/DIV", "PI/ADD", "PI/OBJ" + ); + private static final List EXPECTED_UNIDOS = Arrays.asList( + "HELLO", "FORMATTER", "FORMATTER.OBJ", "MFID", "FUD", // Disk #1 + "HELLO", "MFID", "FUD" // Disk #2 + ); + + @Test + public void testRecursiveListMerlin() throws DiskUnrecognizedException, IOException { + List actual = + FileStreamer.forDisk("./src/test/resources/disks/MERLIN8PRO1.DSK") + .recursive(true) + .stream() + .map(this::makeFullPath) + .collect(Collectors.toList()); + + assertEquals(EXPECTED_MERLIN, actual); + } + + @Test + public void testNonRecursiveListMerlin() throws DiskUnrecognizedException, IOException { + List actual = + FileStreamer.forDisk("./src/test/resources/disks/MERLIN8PRO1.DSK") + .recursive(false) + .stream() + .map(this::makeFullPath) + .collect(Collectors.toList()); + + List expected = EXPECTED_MERLIN.stream() + .filter(s -> !s.contains("/")) + .collect(Collectors.toList()); + + assertEquals(expected, actual); + } + + @Test + public void testListUnidos() throws DiskUnrecognizedException, IOException { + List actual = + FileStreamer.forDisk("./src/test/resources/disks/UniDOS_3.3.dsk") + .recursive(true) + .stream() + .map(this::makeFullPath) + .collect(Collectors.toList()); + + assertEquals(EXPECTED_UNIDOS, actual); + } + + + private String makeFullPath(FileTuple tuple) { + if (tuple.paths == null || tuple.paths.isEmpty()) { + return tuple.fileEntry.getFilename(); + } else { + return String.join("/", String.join("/", tuple.paths), tuple.fileEntry.getFilename()); + } + } +} diff --git a/app/ac-acx/src/test/java/io/github/applecommander/filestreamer/FileTupleTest.java b/app/ac-acx/src/test/java/io/github/applecommander/filestreamer/FileTupleTest.java new file mode 100644 index 0000000..ff34ad0 --- /dev/null +++ b/app/ac-acx/src/test/java/io/github/applecommander/filestreamer/FileTupleTest.java @@ -0,0 +1,31 @@ +package io.github.applecommander.filestreamer; + +import static org.junit.Assert.assertEquals; + +import java.io.IOException; +import java.util.Arrays; + +import com.webcodepro.applecommander.storage.Disk; +import com.webcodepro.applecommander.storage.DiskException; +import com.webcodepro.applecommander.storage.FileEntry; +import com.webcodepro.applecommander.storage.FormattedDisk; + +import org.junit.Test; + +public class FileTupleTest { + @Test + public void test() throws IOException, DiskException { + Disk disk = new Disk("./src/test/resources/disks/MERLIN8PRO1.DSK"); + FormattedDisk formattedDisk = disk.getFormattedDisks()[0]; + FileTuple tuple = FileTuple.of(formattedDisk); + FileEntry sourcerorDir = tuple.formattedDisk.getFile("SOURCEROR"); + tuple = tuple.pushd(sourcerorDir); + FileEntry labelsSource = tuple.directoryEntry.getFiles().get(2); + tuple = tuple.of(labelsSource); + + assertEquals(Arrays.asList("SOURCEROR"), tuple.paths); + assertEquals(formattedDisk, tuple.formattedDisk); + assertEquals(sourcerorDir, tuple.directoryEntry); + assertEquals(labelsSource, tuple.fileEntry); + } +} diff --git a/app/ac-acx/src/test/resources/disks/MERLIN8PRO1.DSK b/app/ac-acx/src/test/resources/disks/MERLIN8PRO1.DSK new file mode 100644 index 0000000..4131b05 Binary files /dev/null and b/app/ac-acx/src/test/resources/disks/MERLIN8PRO1.DSK differ diff --git a/app/ac-acx/src/test/resources/disks/UniDOS_3.3.dsk b/app/ac-acx/src/test/resources/disks/UniDOS_3.3.dsk new file mode 100644 index 0000000..02e4acf Binary files /dev/null and b/app/ac-acx/src/test/resources/disks/UniDOS_3.3.dsk differ diff --git a/app/ac-cli/build.gradle b/app/ac-cli/build.gradle index 3eed5f5..a6c1e55 100644 --- a/app/ac-cli/build.gradle +++ b/app/ac-cli/build.gradle @@ -1,8 +1,11 @@ plugins { - id 'org.springframework.boot' version '2.6.1' + id 'org.springframework.boot' version "$springBoot" id 'application' } +sourceCompatibility = 1.8 +targetCompatibility = 1.8 + repositories { mavenCentral() } diff --git a/app/ac-swing/build.gradle b/app/ac-swing/build.gradle index d49fcaa..47ac509 100644 --- a/app/ac-swing/build.gradle +++ b/app/ac-swing/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'org.springframework.boot' version '2.6.1' + id 'org.springframework.boot' version "$springBoot" id 'application' } diff --git a/app/ac-swt-linux-aarch64/build.gradle b/app/ac-swt-linux-aarch64/build.gradle index 95aae48..ce7f753 100644 --- a/app/ac-swt-linux-aarch64/build.gradle +++ b/app/ac-swt-linux-aarch64/build.gradle @@ -1,8 +1,11 @@ plugins { - id 'org.springframework.boot' version '2.6.1' + id 'org.springframework.boot' version "$springBoot" id 'application' } +sourceCompatibility = 11 +targetCompatibility = 11 + repositories { mavenCentral() } diff --git a/app/ac-swt-linux-x86_64/build.gradle b/app/ac-swt-linux-x86_64/build.gradle index 37beebd..1daa454 100644 --- a/app/ac-swt-linux-x86_64/build.gradle +++ b/app/ac-swt-linux-x86_64/build.gradle @@ -1,8 +1,11 @@ plugins { - id 'org.springframework.boot' version '2.6.1' + id 'org.springframework.boot' version "$springBoot" id 'application' } +sourceCompatibility = 11 +targetCompatibility = 11 + repositories { mavenCentral() } diff --git a/app/ac-swt-macosx-aarch64/build.gradle b/app/ac-swt-macosx-aarch64/build.gradle index c612bda..13ca2ce 100644 --- a/app/ac-swt-macosx-aarch64/build.gradle +++ b/app/ac-swt-macosx-aarch64/build.gradle @@ -1,8 +1,11 @@ plugins { - id 'org.springframework.boot' version '2.6.1' + id 'org.springframework.boot' version "$springBoot" id 'application' } +sourceCompatibility = 11 +targetCompatibility = 11 + repositories { mavenCentral() } diff --git a/app/ac-swt-macosx-x86_64/build.gradle b/app/ac-swt-macosx-x86_64/build.gradle index 5dc7277..b1c6439 100644 --- a/app/ac-swt-macosx-x86_64/build.gradle +++ b/app/ac-swt-macosx-x86_64/build.gradle @@ -1,8 +1,11 @@ plugins { - id 'org.springframework.boot' version '2.6.1' + id 'org.springframework.boot' version "$springBoot" id 'application' } +sourceCompatibility = 11 +targetCompatibility = 11 + repositories { mavenCentral() } diff --git a/app/ac-swt-win32-x86_64/build.gradle b/app/ac-swt-win32-x86_64/build.gradle index 7a4feda..6539910 100644 --- a/app/ac-swt-win32-x86_64/build.gradle +++ b/app/ac-swt-win32-x86_64/build.gradle @@ -1,8 +1,11 @@ plugins { - id 'org.springframework.boot' version '2.6.1' + id 'org.springframework.boot' version "$springBoot" id 'application' } +sourceCompatibility = 11 +targetCompatibility = 11 + repositories { mavenCentral() } diff --git a/gradle.properties b/gradle.properties index 9c8469d..b5afa32 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,3 +13,5 @@ antVersion=1.8.2 commonsLang3Version=3.7 commonsCsvVersion=1.8 gsonVersion=2.8.6 +picocliVersion=4.6.2 +springBoot=2.6.1 diff --git a/lib/ac-api/build.gradle b/lib/ac-api/build.gradle index c542ae1..1406046 100644 --- a/lib/ac-api/build.gradle +++ b/lib/ac-api/build.gradle @@ -7,6 +7,9 @@ plugins { ext.isSnapshotVersion = version.endsWith("SNAPSHOT") ext.isReleaseVersion = !ext.isSnapshotVersion +sourceCompatibility = 1.8 +targetCompatibility = 1.8 + repositories { mavenCentral() } diff --git a/lib/ac-swt-common/build.gradle b/lib/ac-swt-common/build.gradle index 50bc60d..aeb03a9 100644 --- a/lib/ac-swt-common/build.gradle +++ b/lib/ac-swt-common/build.gradle @@ -2,6 +2,9 @@ plugins { id 'java-library' } +sourceCompatibility = 11 +targetCompatibility = 11 + repositories { mavenCentral() } diff --git a/settings.gradle b/settings.gradle index e1321e9..8dad550 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,6 +2,7 @@ rootProject.name = "AppleCommander" include ':lib:ac-api' include ':lib:ac-swt-common' +include ':app:ac-acx' include ':app:ac-cli' include ':app:ac-swing' include ':app:ac-swt-macosx-aarch64'