abraunegg-onedrive/src/main.d

666 lines
23 KiB
D
Raw Normal View History

import core.stdc.stdlib: EXIT_SUCCESS, EXIT_FAILURE, exit;
2015-09-20 21:21:51 +02:00
import core.memory, core.time, core.thread;
import std.getopt, std.file, std.path, std.process, std.stdio, std.conv, std.algorithm.searching, std.string;
2019-02-22 08:15:10 +01:00
import std.algorithm.sorting: sort;
import config, itemdb, monitor, onedrive, selective, sync, util;
import std.net.curl: CurlException;
import core.stdc.signal;
import std.traits;
static import log;
2015-09-01 20:45:34 +02:00
OneDriveApi oneDrive;
ItemDatabase itemDb;
int main(string[] args)
2015-09-01 20:45:34 +02:00
{
// Disable buffering on stdout
stdout.setvbuf(0, _IONBF);
2019-01-26 01:03:00 +01:00
// configuration directory
string configDirName;
2019-02-22 08:15:10 +01:00
Option[] savedOpts;
bool helpWanted = false;
2019-01-26 01:03:00 +01:00
try {
// print the version and exit
bool printVersion = false;
auto opt = getopt(
args,
std.getopt.config.passThrough,
std.getopt.config.bundling,
std.getopt.config.caseSensitive,
"confdir", "Set the directory used to store the configuration files", &configDirName,
"version", "Print the version and exit", &printVersion
);
if (opt.helpWanted) {
2019-02-22 08:15:10 +01:00
args ~= "--help";
helpWanted = true;
savedOpts ~= opt.options;
2019-01-26 01:03:00 +01:00
}
if (printVersion) {
std.stdio.write("onedrive ", import("version"));
return EXIT_SUCCESS;
}
} catch (GetOptException e) {
log.error(e.msg);
log.error("Try 'onedrive -h' for more information");
return EXIT_FAILURE;
} catch (Exception e) {
// error
log.error(e.msg);
log.error("Try 'onedrive -h' for more information");
return EXIT_FAILURE;
}
2019-02-12 09:58:10 +01:00
// load configuration file if available
auto cfg = new config.Config(configDirName);
2019-02-22 08:15:10 +01:00
if (!cfg.initialize()) {
2019-02-12 09:58:10 +01:00
// There was an error loading the configuration
// Error message already printed
return EXIT_FAILURE;
}
2019-02-22 08:15:10 +01:00
// update configuration from command line args
savedOpts ~= cfg.update_from_args(args);
2019-02-12 09:58:10 +01:00
2019-02-22 08:15:10 +01:00
//
// parse the remaining arguments that do not have a corresponding config file entry
2019-01-26 01:03:00 +01:00
// Application Option Variables
// Create a single root directory on OneDrive
string createDirectory;
// The destination directory if we are using the OneDrive client to rename a directory
string destinationDirectory;
// Display application configuration but do not sync
bool displayConfiguration = false;
// Display sync status
bool displaySyncStatus = false;
// SharePoint / Office 365 Shared Library name to query
string o365SharedLibraryName;
2016-08-05 00:12:58 +02:00
// remove the current user and sync state
bool logout = false;
// enable monitor mode
bool monitor = false;
2017-05-28 20:14:50 +02:00
// print the access token
bool printAccessToken = false;
// force a full resync
bool resync = false;
// Remove a single directory on OneDrive
string removeDirectory;
// This allows for selective directory syncing instead of everything under ~/OneDrive/
string singleDirectory;
// The source directory if we are using the OneDrive client to rename a directory
string sourceDirectory;
// Configure a flag to perform a sync
// This is beneficial so that if just running the client itself - without any options, or sync check, the client does not perform a sync
bool synchronize = false;
2019-02-22 08:15:10 +01:00
// Options without respective config file setting
2015-09-14 19:21:06 +02:00
try {
auto opt = getopt(
args,
std.getopt.config.bundling,
2017-07-14 11:31:16 +02:00
std.getopt.config.caseSensitive,
"create-directory", "Create a directory on OneDrive - no sync will be performed.", &createDirectory,
"destination-directory", "Destination directory for renamed or move on OneDrive - no sync will be performed.", &destinationDirectory,
"display-config", "Display what options the client will use as currently configured - no sync will be performed.", &displayConfiguration,
"display-sync-status", "Display the sync status of the client - no sync will be performed.", &displaySyncStatus,
"get-O365-drive-id", "Query and return the Office 365 Drive ID for a given Office 365 SharePoint Shared Library", &o365SharedLibraryName,
2017-07-14 11:31:16 +02:00
"logout", "Logout the current user", &logout,
"monitor|m", "Keep monitoring for local and remote changes", &monitor,
"print-token", "Print the access token, useful for debugging", &printAccessToken,
"resync", "Forget the last saved state, perform a full sync", &resync,
"remove-directory", "Remove a directory on OneDrive - no sync will be performed.", &removeDirectory,
"single-directory", "Specify a single local directory within the OneDrive root to sync.", &singleDirectory,
"source-directory", "Source directory to rename or move on OneDrive - no sync will be performed.", &sourceDirectory,
"synchronize", "Perform a synchronization", &synchronize,
2015-09-14 19:21:06 +02:00
);
if (opt.helpWanted) {
2019-02-22 08:15:10 +01:00
outputLongHelp(opt.options ~ savedOpts);
return EXIT_SUCCESS;
2015-09-14 19:21:06 +02:00
}
} catch (GetOptException e) {
2017-12-28 15:21:41 +01:00
log.error(e.msg);
log.error("Try 'onedrive -h' for more information");
return EXIT_FAILURE;
} catch (Exception e) {
// error
log.error(e.msg);
log.error("Try 'onedrive -h' for more information");
return EXIT_FAILURE;
2015-09-14 19:21:06 +02:00
}
2019-02-22 08:15:10 +01:00
// Are we able to reach the OneDrive Service
bool online = false;
// sync_dir environment handling to handle ~ expansion properly
string syncDir;
if ((environment.get("SHELL") == "") && (environment.get("USER") == "")){
log.vdebug("sync_dir: No SHELL or USER environment variable configuration detected");
// No shell or user set, so expandTilde() will fail - usually headless system running under init.d / systemd or potentially Docker
// Does the 'currently configured' sync_dir include a ~
2019-02-22 08:15:10 +01:00
if (canFind(cfg.getValueString("sync_dir"), "~")) {
// A ~ was found
log.vdebug("sync_dir: A '~' was found in sync_dir, using the calculated 'homePath' to replace '~'");
2019-02-22 08:15:10 +01:00
syncDir = cfg.homePath ~ strip(cfg.getValueString("sync_dir"), "~");
} else {
// No ~ found in sync_dir, use as is
log.vdebug("sync_dir: Getting syncDir from config value sync_dir");
2019-02-22 08:15:10 +01:00
syncDir = cfg.getValueString("sync_dir");
}
} else {
// A shell and user is set, expand any ~ as this will be expanded correctly if present
log.vdebug("sync_dir: Getting syncDir from config value sync_dir");
2019-02-22 08:15:10 +01:00
if (canFind(cfg.getValueString("sync_dir"), "~")) {
log.vdebug("sync_dir: A '~' was found in configured sync_dir, automatically expanding as SHELL and USER environment variable is set");
2019-02-22 08:15:10 +01:00
syncDir = expandTilde(cfg.getValueString("sync_dir"));
} else {
2019-02-22 08:15:10 +01:00
syncDir = cfg.getValueString("sync_dir");
}
}
// vdebug syncDir as set and calculated
log.vdebug("syncDir: ", syncDir);
// Configure logging if enabled
2019-02-22 08:15:10 +01:00
if (cfg.getValueBool("enable_logging")){
// Read in a user defined log directory or use the default
2019-02-22 08:15:10 +01:00
string logDir = cfg.getValueString("log_dir");
log.vlog("Using logfile dir: ", logDir);
log.init(logDir);
}
// Configure whether notifications are used
2019-02-22 08:15:10 +01:00
log.setNotifications(monitor && !cfg.getValueBool("disable_notifications"));
2016-12-25 19:23:33 +01:00
// upgrades
if (exists(configDirName ~ "/items.db")) {
remove(configDirName ~ "/items.db");
log.logAndNotify("Database schema changed, resync needed");
2016-12-25 19:23:33 +01:00
resync = true;
}
2016-08-05 00:12:58 +02:00
if (resync || logout) {
2017-05-28 23:14:37 +02:00
log.vlog("Deleting the saved status ...");
2016-08-05 00:12:58 +02:00
safeRemove(cfg.databaseFilePath);
safeRemove(cfg.deltaLinkFilePath);
2016-08-05 00:12:58 +02:00
safeRemove(cfg.uploadStateFilePath);
if (logout) {
safeRemove(cfg.refreshTokenFilePath);
}
2015-09-14 19:21:06 +02:00
}
// Display current application configuration, no application initialisation
if (displayConfiguration){
string userConfigFilePath = configDirName ~ "/config";
string userSyncList = configDirName ~ "/sync_list";
// Display application version
std.stdio.write("onedrive version = ", import("version"));
// Display all of the pertinent configuration options
writeln("Config path = ", configDirName);
// Does a config file exist or are we using application defaults
if (exists(userConfigFilePath)){
writeln("Config file found in config path = true");
} else {
writeln("Config file found in config path = false");
}
// Config Options
writeln("Config option 'sync_dir' = ", syncDir);
2019-02-22 08:15:10 +01:00
writeln("Config option 'skip_file' = ", cfg.getValueString("skip_file"));
writeln("Config option 'skip_symlinks' = ", cfg.getValueBool("skip_symlinks"));
writeln("Config option 'monitor_interval' = ", cfg.getValueLong("monitor_interval"));
writeln("Config option 'min_notif_changes' = ", cfg.getValueLong("min_notif_changes"));
writeln("Config option 'log_dir' = ", cfg.getValueString("log_dir"));
// Is config option drive_id configured?
2019-02-22 08:15:10 +01:00
if (cfg.getValueString("drive_id") != ""){
writeln("Config option 'drive_id' = ", cfg.getValueString("drive_id"));
}
// Is sync_list configured?
if (exists(userSyncList)){
writeln("Selective sync configured = true");
writeln("sync_list contents:");
// Output the sync_list contents
auto syncListFile = File(userSyncList);
auto range = syncListFile.byLine();
foreach (line; range)
{
writeln(line);
}
} else {
writeln("Selective sync configured = false");
}
return EXIT_SUCCESS;
}
log.vlog("Initializing the OneDrive API ...");
try {
online = testNetwork();
} catch (CurlException e) {
// No network connection to OneDrive Service
log.error("No network connection to Microsoft OneDrive Service");
if (!monitor) {
return EXIT_FAILURE;
}
}
// Initialize OneDrive, check for authorization
2019-02-22 08:15:10 +01:00
oneDrive = new OneDriveApi(cfg);
oneDrive.printAccessToken = printAccessToken;
if (!oneDrive.init()) {
2017-12-28 15:21:41 +01:00
log.error("Could not initialize the OneDrive API");
// workaround for segfault in std.net.curl.Curl.shutdown() on exit
oneDrive.http.shutdown();
return EXIT_FAILURE;
2015-09-01 20:45:34 +02:00
}
// if --synchronize or --monitor not passed in, exit & display help
auto performSyncOK = false;
if (synchronize || monitor) {
performSyncOK = true;
}
// create-directory, remove-directory, source-directory, destination-directory
// are activities that dont perform a sync no error message for these items either
if (((createDirectory != "") || (removeDirectory != "")) || ((sourceDirectory != "") && (destinationDirectory != "")) || (o365SharedLibraryName != "") || (displaySyncStatus == true)) {
performSyncOK = true;
}
if (!performSyncOK) {
writeln("\n--synchronize or --monitor missing from your command options or use --help for further assistance\n");
writeln("No OneDrive sync will be performed without either of these two arguments being present\n");
oneDrive.http.shutdown();
return EXIT_FAILURE;
}
// initialize system
log.vlog("Opening the item database ...");
itemDb = new ItemDatabase(cfg.databaseFilePath);
log.vlog("All operations will be performed in: ", syncDir);
if (!exists(syncDir)) {
log.vdebug("syncDir: Configured syncDir is missing. Creating: ", syncDir);
mkdirRecurse(syncDir);
}
chdir(syncDir);
// Configure selective sync by parsing and getting a regex for skip_file config component
auto selectiveSync = new SelectiveSync();
if (exists(cfg.syncListFilePath)){
log.vdebug("Loading user configured sync_list file ...");
// list what will be synced
auto syncListFile = File(cfg.syncListFilePath);
auto range = syncListFile.byLine();
foreach (line; range)
{
log.vdebug("sync_list: ", line);
}
}
selectiveSync.load(cfg.syncListFilePath);
2019-02-22 08:15:10 +01:00
selectiveSync.setMask(cfg.getValueString("skip_file"));
// Initialise the sync engine
log.logAndNotify("Initializing the Synchronization Engine ...");
auto sync = new SyncEngine(cfg, oneDrive, itemDb, selectiveSync);
try {
if (!initSyncEngine(sync)) {
oneDrive.http.shutdown();
return EXIT_FAILURE;
}
} catch (CurlException e) {
if (!monitor) {
log.log("\nNo internet connection.");
oneDrive.http.shutdown();
return EXIT_FAILURE;
}
}
// We should only set noRemoteDelete in an upload-only scenario
2019-02-22 08:15:10 +01:00
if ((cfg.getValueBool("upload_only"))&&(cfg.getValueBool("no_remote_delete"))) sync.setNoRemoteDelete();
// Do we configure to disable the upload validation routine
2019-02-22 08:15:10 +01:00
if (cfg.getValueBool("disable_upload_validation")) sync.setDisableUploadValidation();
// Do we need to validate the syncDir to check for the presence of a '.nosync' file
2019-02-22 08:15:10 +01:00
if (cfg.getValueBool("check_for_nomount")) {
// we were asked to check the mounts
if (exists(syncDir ~ "/.nosync")) {
log.logAndNotify("ERROR: .nosync file found. Aborting synchronization process to safeguard data.");
oneDrive.http.shutdown();
return EXIT_FAILURE;
}
}
// Do we need to create or remove a directory?
if ((createDirectory != "") || (removeDirectory != "")) {
if (createDirectory != "") {
// create a directory on OneDrive
sync.createDirectoryNoSync(createDirectory);
}
if (removeDirectory != "") {
// remove a directory on OneDrive
sync.deleteDirectoryNoSync(removeDirectory);
}
}
// Are we renaming or moving a directory?
if ((sourceDirectory != "") && (destinationDirectory != "")) {
// We are renaming or moving a directory
sync.renameDirectoryNoSync(sourceDirectory, destinationDirectory);
}
// Are we obtaining the Office 365 Drive ID for a given Office 365 SharePoint Shared Library?
if (o365SharedLibraryName != ""){
sync.querySiteCollectionForDriveID(o365SharedLibraryName);
}
// Are we displaying the sync status of the client?
if (displaySyncStatus) {
string remotePath = "/";
string localPath = ".";
// Are we doing a single directory check?
if (singleDirectory != ""){
// Need two different path strings here
remotePath = singleDirectory;
localPath = singleDirectory;
}
sync.queryDriveForChanges(remotePath);
}
// Are we performing a sync, resync or monitor operation?
if ((synchronize) || (resync) || (monitor)) {
2015-09-01 20:45:34 +02:00
if ((synchronize) || (resync)) {
if (online) {
// Check user entry for local path - the above chdir means we are already in ~/OneDrive/ thus singleDirectory is local to this path
if (singleDirectory != ""){
// Does the directory we want to sync actually exist?
if (!exists(singleDirectory)){
// the requested directory does not exist ..
log.logAndNotify("ERROR: The requested local directory does not exist. Please check ~/OneDrive/ for requested path");
oneDrive.http.shutdown();
return EXIT_FAILURE;
}
}
// Perform the sync
2019-02-22 08:15:10 +01:00
performSync(sync, singleDirectory, cfg.getValueBool("download_only"), cfg.getValueBool("local_first"), cfg.getValueBool("upload_only"), monitor);
2015-09-20 21:21:51 +02:00
}
}
if (monitor) {
log.logAndNotify("Initializing monitor ...");
2019-02-22 08:15:10 +01:00
log.log("OneDrive monitor interval (seconds): ", cfg.getValueLong("monitor_interval"));
Monitor m = new Monitor(selectiveSync);
m.onDirCreated = delegate(string path) {
log.vlog("[M] Directory created: ", path);
try {
sync.scanForDifferences(path);
} catch (CurlException e) {
log.vlog("Offline, cannot create remote dir!");
} catch(Exception e) {
log.logAndNotify("Cannot create remote directory: ", e.msg);
}
};
m.onFileChanged = delegate(string path) {
log.vlog("[M] File changed: ", path);
try {
sync.scanForDifferences(path);
} catch (CurlException e) {
log.vlog("Offline, cannot upload changed item!");
} catch(Exception e) {
log.logAndNotify("Cannot upload file changes/creation: ", e.msg);
}
};
m.onDelete = delegate(string path) {
log.vlog("[M] Item deleted: ", path);
try {
sync.deleteByPath(path);
} catch (CurlException e) {
log.vlog("Offline, cannot delete item!");
} catch(SyncException e) {
if (e.msg == "The item to delete is not in the local database") {
log.vlog("Item cannot be deleted because not found in database");
} else {
log.logAndNotify("Cannot delete remote item: ", e.msg);
}
} catch(Exception e) {
log.logAndNotify("Cannot delete remote item: ", e.msg);
}
};
m.onMove = delegate(string from, string to) {
log.vlog("[M] Item moved: ", from, " -> ", to);
try {
sync.uploadMoveItem(from, to);
} catch (CurlException e) {
log.vlog("Offline, cannot move item!");
} catch(Exception e) {
log.logAndNotify("Cannot move item:, ", e.msg);
}
};
signal(SIGINT, &exitHandler);
signal(SIGTERM, &exitHandler);
// initialise the monitor class
2019-02-22 08:15:10 +01:00
if (!cfg.getValueBool("download_only")) m.init(cfg, cfg.getValueLong("verbose") > 0, cfg.getValueBool("skip_symlinks"));
// monitor loop
2019-02-22 08:15:10 +01:00
immutable auto checkInterval = dur!"seconds"(cfg.getValueLong("monitor_interval"));
auto lastCheckTime = MonoTime.currTime();
while (true) {
2019-02-22 08:15:10 +01:00
if (!cfg.getValueBool("download_only")) m.update(online);
auto currTime = MonoTime.currTime();
if (currTime - lastCheckTime > checkInterval) {
// log.logAndNotify("DEBUG trying to create checkpoint");
// auto res = itemdb.db_checkpoint();
// log.logAndNotify("Checkpoint return: ", res);
// itemdb.dump_open_statements();
try {
if (!initSyncEngine(sync)) {
oneDrive.http.shutdown();
return EXIT_FAILURE;
}
try {
2019-02-22 08:15:10 +01:00
performSync(sync, singleDirectory, cfg.getValueBool("download_only"), cfg.getValueBool("local_first"), cfg.getValueBool("upload_only"), monitor);
if (!cfg.getValueBool("download_only")) {
// discard all events that may have been generated by the sync
m.update(false);
}
} catch (CurlException e) {
// we already tried three times in the performSync routine
// if we still have problems, then the sync handle might have
// gone stale and we need to re-initialize the sync engine
log.log("Pesistent connection errors, reinitializing connection");
sync.reset();
}
} catch (CurlException e) {
log.log("Cannot initialize connection to OneDrive");
2017-12-31 17:07:21 +01:00
}
// performSync complete, set lastCheckTime to current time
lastCheckTime = MonoTime.currTime();
GC.collect();
}
Thread.sleep(dur!"msecs"(500));
2015-09-17 00:16:23 +02:00
}
}
2015-09-14 19:21:06 +02:00
}
// workaround for segfault in std.net.curl.Curl.shutdown() on exit
oneDrive.http.shutdown();
return EXIT_SUCCESS;
2015-09-01 20:45:34 +02:00
}
2015-09-20 21:21:51 +02:00
bool initSyncEngine(SyncEngine sync)
{
try {
sync.init();
} catch (OneDriveException e) {
if (e.httpStatusCode == 400 || e.httpStatusCode == 401) {
// Authorization is invalid
log.log("\nAuthorization token invalid, use --logout to authorize the client again\n");
return false;
}
if (e.httpStatusCode >= 500) {
// There was a HTTP 5xx Server Side Error, message already printed
return false;
}
}
return true;
}
2015-09-20 21:21:51 +02:00
// try to synchronize the folder three times
void performSync(SyncEngine sync, string singleDirectory, bool downloadOnly, bool localFirst, bool uploadOnly, bool monitor)
2015-09-20 21:21:51 +02:00
{
int count;
string remotePath = "/";
string localPath = ".";
// Are we doing a single directory sync?
if (singleDirectory != ""){
// Need two different path strings here
remotePath = singleDirectory;
localPath = singleDirectory;
}
2015-09-20 21:21:51 +02:00
do {
try {
if (singleDirectory != ""){
// we were requested to sync a single directory
log.vlog("Syncing changes from this selected path: ", singleDirectory);
if (uploadOnly){
// Upload Only of selected single directory
if (!monitor) log.log("Syncing changes from selected local path only - NOT syncing data changes from OneDrive ...");
sync.scanForDifferences(localPath);
} else {
// No upload only
if (localFirst) {
// Local First
if (!monitor) log.log("Syncing changes from selected local path first before downloading changes from OneDrive ...");
sync.scanForDifferences(localPath);
sync.applyDifferencesSingleDirectory(remotePath);
} else {
// OneDrive First
if (!monitor) log.log("Syncing changes from selected OneDrive path ...");
sync.applyDifferencesSingleDirectory(remotePath);
// is this a download only request?
if (!downloadOnly) {
// process local changes
sync.scanForDifferences(localPath);
// ensure that the current remote state is updated locally
sync.applyDifferencesSingleDirectory(remotePath);
}
}
}
} else {
// no single directory sync
if (uploadOnly){
// Upload Only of entire sync_dir
if (!monitor) log.log("Syncing changes from local path only - NOT syncing data changes from OneDrive ...");
sync.scanForDifferences(localPath);
} else {
// No upload only
if (localFirst) {
// sync local files first before downloading from OneDrive
if (!monitor) log.log("Syncing changes from local path first before downloading changes from OneDrive ...");
sync.scanForDifferences(localPath);
sync.applyDifferences();
} else {
// sync from OneDrive first before uploading files to OneDrive
log.log("Syncing changes from OneDrive ...");
sync.applyDifferences();
// is this a download only request?
if (!downloadOnly) {
// process local changes
sync.scanForDifferences(localPath);
// ensure that the current remote state is updated locally
sync.applyDifferences();
}
}
}
2017-12-31 17:07:21 +01:00
}
2015-09-20 21:21:51 +02:00
count = -1;
2017-12-28 15:03:15 +01:00
} catch (Exception e) {
if (++count == 3) {
log.log("Giving up on sync after three attempts: ", e.msg);
throw e;
} else
log.log("Retry sync count: ", count, ": ", e.msg);
2015-09-20 21:21:51 +02:00
}
} while (count != -1);
}
// getting around the @nogc problem
// https://p0nce.github.io/d-idioms/#Bypassing-@nogc
auto assumeNoGC(T) (T t) if (isFunctionPointer!T || isDelegate!T)
{
enum attrs = functionAttributes!T | FunctionAttribute.nogc;
return cast(SetFunctionAttributes!(T, functionLinkage!T, attrs)) t;
}
extern(C) nothrow @nogc @system void exitHandler(int value) {
try {
assumeNoGC ( () {
log.log("Got termination signal, shutting down db connection");
// make sure the .wal file is incorporated into the main db
destroy(itemDb);
// workaround for segfault in std.net.curl.Curl.shutdown() on exit
oneDrive.http.shutdown();
})();
} catch(Exception e) {}
exit(0);
}
void outputLongHelp(Option[] opt)
{
auto argsNeedingOptions = [
"--confdir",
"--create-directory",
"--destination-directory",
"--get-O365-drive-id",
"--remove-directory",
"--single-directory",
"--source-directory",
"--syncdir" ];
writeln(`OneDrive - a client for OneDrive Cloud Services
Usage:
onedrive [options] --synchronize
Do a one time synchronization
onedrive [options] --monitor
Monitor filesystem and sync regularly
onedrive [options] --display-config
Display the currently used configuration
onedrive [options] --display-sync-status
Query OneDrive service and report on pending changes
onedrive -h | --help
Show this help screen
onedrive --version
Show version
Options:
`);
2019-02-22 08:15:10 +01:00
foreach (it; opt.sort!("a.optLong < b.optLong")) {
if (it.optLong == "--help") continue;
writefln(" %s%s%s%s\n %s",
it.optLong,
2019-02-22 08:15:10 +01:00
it.optShort == "" ? "" : " " ~ it.optShort,
argsNeedingOptions.canFind(it.optLong) ? " ARG" : "",
it.required ? " (required)" : "", it.help);
}
2019-02-22 08:15:10 +01:00
// write help last
writefln(" --help -h\n This help information.");
}