diff --git a/source/app.d b/source/app.d index 6790fec..b7afa41 100644 --- a/source/app.d +++ b/source/app.d @@ -1,65 +1,84 @@ -import std.typecons; -import std.path; -import std.file; -import std.stdio; -import std.string; -import std.algorithm; -import std.getopt; -import filesizes; -import progress; - import utils; import ingest; -const DEFAULT_MEDIA_DIR = "/media"; -const DEFAULT_OUTPUT_DIR = "raw"; - int main(string[] args) { - writeln( - "+---------------------------------+\n" ~ - "| |\n" ~ - "| GoPro Ingester |\n" ~ - "| v1.0.0 |\n" ~ - "| by Andrew Lalis |\n" ~ - "| |\n" ~ - "+---------------------------------+\n" - ); - IngestConfig config; - config.outputDir = buildPath(getcwd(), DEFAULT_OUTPUT_DIR); - string mediaSearchDir = DEFAULT_MEDIA_DIR; - auto helpInfo = getopt( - args, - "mediaDir|i", - format!"The base directory from which to search for the GoPro media. Defaults to \"%s\"."(mediaSearchDir), - &mediaSearchDir, - "outputDir|o", - format!"The directory to copy data to. Defaults to \"%s\". Will create the directory if it doesn't exist yet."(config.outputDir), - &config.outputDir, - "force|f", - format!"Whether to forcibly overwrite existing files. Defaults to %s."(config.force), - &config.force, - "dryRun|d", - format!"Whether to perform a dry-run (don't actually copy anything). Defaults to %s."(config.dryRun), - &config.dryRun, - "bufferSize|b", - format!"The size of the buffer for copying files, in bytes. Defaults to %s."(formatFilesize(config.bufferSize)), - &config.bufferSize, - "clean|c", - format!"Whether to remove files from the GoPro media card after copying. Defaults to %s."(config.clean), - &config.clean - ); - - if (helpInfo.helpWanted) { - defaultGetoptPrinter("Ingestion tool for importing data from GoPro media cards.", helpInfo.options); + printBanner(); + CliResult result = parseArgs(args); + if (result.type == CliResultType.NO_CONTENT) { return 0; + } else if (result.type == CliResultType.MISSING_MEDIA) { + return 1; + } else { + return copyFiles(result.config); + } +} + +unittest { + // First some utilities to make the tests simpler. + import std.stdio; + import std.file; + import std.path; + import std.algorithm; + import std.string; + import utils; + + struct DirView { + DirEntry[] entries; } - auto nullableGoProDir = getGoProDir(mediaSearchDir); - if (nullableGoProDir.isNull) { - writeln("Couldn't find GoPro directory."); - return 1; + DirView getDirView(string dir) { + DirView view; + foreach (DirEntry entry; dirEntries(dir, SpanMode.shallow)) { + view.entries ~= entry; + } + view.entries.sort!((a, b) => a.name > b.name); + return view; } - config.inputDir = nullableGoProDir.get(); - writefln!"Found GoPro media at %s."(config.inputDir); - return copyFiles(config); + + int callApp(string[] args ...) { + return main(["app"] ~ args); + } + + string getBaseCardDir(string name) { + return buildPath("test", "media-cards", "card-" ~ name); + } + + string getTestCardDir(string name) { + return buildPath("test", "media-cards", "card-test-" ~ name); + } + + void assertCardsUnchanged(string[] cards ...) { + foreach (string card; cards) { + string baseDir = getBaseCardDir(card); + string testDir = getTestCardDir(card); + if (exists(testDir)) { + assert(getDirView(baseDir) == getDirView(testDir)); + } + } + } + + void prepareCardTests(string[] cards ...) { + foreach (string card; cards) { + copyDir(getBaseCardDir(card), getTestCardDir(card)); + } + } + + void cleanupCardTests(string[] cards ...) { + foreach (string card; cards) { + rmdirRecurse(getTestCardDir(card)); + } + } + + string[] allCards = ["1", "2", "3"]; + prepareCardTests(allCards); + writeln(isDir(getBaseCardDir("1"))); + assert(callApp("-h") == 0); + assertCardsUnchanged(allCards); + assert(callApp("--help") == 0); + assertCardsUnchanged(allCards); + assert(callApp("-f", "-h") == 0); + assertCardsUnchanged(allCards); + // Ingesting from an empty card shouldn't have any effect. + assert(callApp("-i", getTestCardDir("1")) == 0); + assertCardsUnchanged("1"); } diff --git a/source/ingest.d b/source/ingest.d index ec1155b..415d948 100644 --- a/source/ingest.d +++ b/source/ingest.d @@ -8,6 +8,7 @@ import std.string; import std.typecons; import filesizes; import progress; +import utils; const DEFAULT_BUFFER_SIZE = 1024 * 1024; @@ -43,7 +44,7 @@ public struct IngestConfig { */ private bool shouldCopyFile(DirEntry entry, string targetFile, bool force) { return entry.isFile() && - (entry.name.endsWith(".MP4") || entry.name.endsWith(".WAV")) && + (endsWithAny(entry.name, ".MP4", ".mp4", ".WAV", ".wav")) && (!exists(targetFile) || getSize(targetFile) != entry.size || force); } @@ -136,4 +137,18 @@ public int copyFiles(IngestConfig config) { } return 0; +} + +unittest { + import testutils; + + // Test a dry run. + prepareCardTests("1"); + IngestConfig c1; + c1.dryRun = true; + c1.inputDir = getTestCardDir("1"); + c1.outputDir = "test-out"; + assert(copyFiles(c1) == 0); + assertCardsUnchanged("1"); + cleanupCardTests("1"); } \ No newline at end of file diff --git a/source/testutils.d b/source/testutils.d new file mode 100644 index 0000000..b7195e0 --- /dev/null +++ b/source/testutils.d @@ -0,0 +1,51 @@ +module testutils; + +import std.stdio; +import std.file; +import std.path; +import std.algorithm; +import std.string; +import utils; + +struct DirView { + DirEntry[] entries; +} + +DirView getDirView(string dir) { + DirView view; + foreach (DirEntry entry; dirEntries(dir, SpanMode.shallow)) { + view.entries ~= entry; + } + view.entries.sort!((a, b) => a.name > b.name); + return view; +} + +string getBaseCardDir(string name) { + return buildPath("test", "media-cards", "card-" ~ name); +} + +string getTestCardDir(string name) { + return buildPath("test", "media-cards", "card-test-" ~ name); +} + +void assertCardsUnchanged(string[] cards ...) { + foreach (string card; cards) { + string baseDir = getBaseCardDir(card); + string testDir = getTestCardDir(card); + if (exists(testDir)) { + assert(getDirView(baseDir) == getDirView(testDir)); + } + } +} + +void prepareCardTests(string[] cards ...) { + foreach (string card; cards) { + copyDir(getBaseCardDir(card), getTestCardDir(card)); + } +} + +void cleanupCardTests(string[] cards ...) { + foreach (string card; cards) { + rmdirRecurse(getTestCardDir(card)); + } +} \ No newline at end of file diff --git a/source/utils.d b/source/utils.d index 97c5372..85be884 100644 --- a/source/utils.d +++ b/source/utils.d @@ -1,8 +1,129 @@ module utils; import std.typecons; -import std.file; -import std.path; +import ingest; + +const DEFAULT_MEDIA_DIR = "/media"; +const DEFAULT_OUTPUT_DIR = "raw"; + +public enum CliResultType { + OK, + NO_CONTENT, + MISSING_MEDIA +} + +public struct CliResult { + CliResultType type; + IngestConfig config; +} + +public CliResult parseArgs(string[] args) { + import std.path; + import std.file; + import std.getopt; + import std.string; + import std.stdio; + import filesizes; + CliResult result; + IngestConfig config; + config.outputDir = buildPath(getcwd(), DEFAULT_OUTPUT_DIR); + string mediaSearchDir = DEFAULT_MEDIA_DIR; + auto helpInfo = getopt( + args, + "mediaDir|i", + format!"The base directory from which to search for the GoPro media. Defaults to \"%s\"."(mediaSearchDir), + &mediaSearchDir, + "outputDir|o", + format!"The directory to copy data to. Defaults to \"%s\". Will create the directory if it doesn't exist yet."(config.outputDir), + &config.outputDir, + "force|f", + format!"Whether to forcibly overwrite existing files. Defaults to %s."(config.force), + &config.force, + "dryRun|d", + format!"Whether to perform a dry-run (don't actually copy anything). Defaults to %s."(config.dryRun), + &config.dryRun, + "bufferSize|b", + format!"The size of the buffer for copying files, in bytes. Defaults to %s."(formatFilesize(config.bufferSize)), + &config.bufferSize, + "clean|c", + format!"Whether to remove files from the GoPro media card after copying. Defaults to %s."(config.clean), + &config.clean + ); + + if (helpInfo.helpWanted) { + defaultGetoptPrinter("Ingestion tool for importing data from GoPro media cards.", helpInfo.options); + result.type = CliResultType.NO_CONTENT; + return result; + } else { + auto nullableGoProDir = getGoProDir(mediaSearchDir); + if (nullableGoProDir.isNull) { + writeln("Couldn't find GoPro directory."); + result.type = CliResultType.MISSING_MEDIA; + } else { + config.inputDir = nullableGoProDir.get(); + writefln!"Found GoPro media at %s."(config.inputDir); + result.type = CliResultType.OK; + result.config = config; + } + } + return result; +} + +unittest { + assert(parseArgs(["app", "-h"]).type == CliResultType.NO_CONTENT); + // TODO: Expand tests. +} + +public void printBanner() { + import std.stdio : writeln; + writeln( + "+---------------------------------+\n" ~ + "| |\n" ~ + "| GoPro Ingester |\n" ~ + "| v1.0.0 |\n" ~ + "| by Andrew Lalis |\n" ~ + "| |\n" ~ + "+---------------------------------+\n" + ); +} + +public bool endsWithAny(string s, string[] suffixes ...) { + import std.algorithm : endsWith; + foreach (string suffix; suffixes) { + if (s.endsWith(suffix)) return true; + } + return false; +} + +unittest { + assert(endsWithAny("abc", "a", "b", "c")); + assert(!endsWithAny("abc", "d")); + assert(!endsWithAny("abc", "A", "B", "C")); +} + +/** + * Copies all files from the given source directory, to the given destination + * directory. Will create the destination directory if it doesn't exist yet. + * Overwrites any files that already exist in the destination directory. + * Params: + * sourceDir = The source directory to copy from. + * destDir = The destination directory to copy to. + */ +public void copyDir(string sourceDir, string destDir) { + import std.file; + if (!isDir(sourceDir)) return; + if (exists(destDir) && !isDir(destDir)) return; + if (!exists(destDir)) mkdirRecurse(destDir); + import std.path : buildPath, baseName; + foreach (DirEntry entry; dirEntries(sourceDir, SpanMode.shallow)) { + string destPath = buildPath(destDir, baseName(entry.name)); + if (entry.isDir) { + copyDir(entry.name, destPath); + } else if (entry.isFile) { + copy(entry.name, destPath); + } + } +} /** * Tries to find a GoPro's media directory. @@ -11,7 +132,9 @@ import std.path; * Returns: A nullable string that, if present, refers to the GoPro's media * directory. */ -public Nullable!string getGoProDir(string baseDir) { +private Nullable!string getGoProDir(string baseDir) { + import std.file; + import std.path; if (!exists(baseDir) || !isDir(baseDir)) return Nullable!string.init; foreach (dir; std.file.dirEntries(baseDir, SpanMode.breadth)) { // We know that a GoPro contains DCIM/100GOPRO in it. @@ -21,4 +144,4 @@ public Nullable!string getGoProDir(string baseDir) { } } return Nullable!string.init; -} \ No newline at end of file +} diff --git a/test/media-cards/card-1/DCIM/100GOPRO/tmp.txt b/test/media-cards/card-1/DCIM/100GOPRO/tmp.txt new file mode 100644 index 0000000..5ab2f8a --- /dev/null +++ b/test/media-cards/card-1/DCIM/100GOPRO/tmp.txt @@ -0,0 +1 @@ +Hello \ No newline at end of file diff --git a/test/media-cards/card-1/README.md b/test/media-cards/card-1/README.md new file mode 100644 index 0000000..43a5808 --- /dev/null +++ b/test/media-cards/card-1/README.md @@ -0,0 +1,2 @@ +# Card 1 +This is an empty card. \ No newline at end of file diff --git a/test/media-cards/card-2/DCIM/100GOPRO/tmp.txt b/test/media-cards/card-2/DCIM/100GOPRO/tmp.txt new file mode 100644 index 0000000..5ab2f8a --- /dev/null +++ b/test/media-cards/card-2/DCIM/100GOPRO/tmp.txt @@ -0,0 +1 @@ +Hello \ No newline at end of file diff --git a/test/media-cards/card-2/README.md b/test/media-cards/card-2/README.md new file mode 100644 index 0000000..52a6922 --- /dev/null +++ b/test/media-cards/card-2/README.md @@ -0,0 +1 @@ +# Card 2 diff --git a/test/media-cards/card-3/DCIM/100GOPRO/tmp.txt b/test/media-cards/card-3/DCIM/100GOPRO/tmp.txt new file mode 100644 index 0000000..5ab2f8a --- /dev/null +++ b/test/media-cards/card-3/DCIM/100GOPRO/tmp.txt @@ -0,0 +1 @@ +Hello \ No newline at end of file diff --git a/test/media-cards/card-3/README.md b/test/media-cards/card-3/README.md new file mode 100644 index 0000000..62c5864 --- /dev/null +++ b/test/media-cards/card-3/README.md @@ -0,0 +1 @@ +# Card 3