0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【C#】進捗表示付きファイル差分コピー、zip圧縮解凍(コンソールアプリ)

Last updated at Posted at 2025-09-28

はじめに

・大容量のファイルやフォルダの差分コピーやzip圧縮、解凍において
 進捗表示(特に完了予定を表示)しながら、コピーができるプログラムを作成しました。

・ほかのプログラムから簡単に実行できる
 コンソールアプリ(C# .NetFramework4.8)で作成しました。

コマンドライン実行例

・フォルダコピー (差分コピー) ※簡易ハッシュ計算による同一性チェック

FileSync.exe --input="***(フォルダ)" --output="***(フォルダ)" --subdirectory --hash=partial128kb --copycheck --progress-detail --progress-singleline --progress-force-inline

・zip出力 (圧縮) 進捗表示付き

FileSync.exe --input="***(フォルダ)" --zip-output="***.zip" --zip-compression=fastest --subdirectory --progress-detail --progress-singleline --progress-force-inline

・zip解凍 進捗表示付き

FileSync.exe --unzip-input="***.zip" --output="***(フォルダ)" --unzip-shared --parallel=1 --progress-detail --progress-singleline --progress-force-inline

実行結果:例

進捗: 11/ 21  速度:    34.5 MB/s  残り:00:02:10  完了:    1.939 GB/    2.939 GB  ...

ソースコード

・もし試してみたい場合は、下記コードを確認してください。

Program.cs
// File: Program.cs
// Target: .NET Framework 4.8
// Ref   : System.IO.Compression
// Ref   : System.IO.Compression.FileSystem
// NuGet : Tomlyn (TOML parser)

using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Tomlyn;

namespace FileSync
{
    internal static class Program
    {
        static int Main(string[] args)
        {
            try
            {
                var norm = args.Select(NormalizeDash).ToArray();

                // --config or default path
                string cfgPath = GetConfigPathFromArgs(norm);
                if (cfgPath == null)
                {
                    var def = ConfigPaths.GetDefaultConfigPath();
                    if (File.Exists(def)) cfgPath = def;
                }

                // Load TOML (if any)
                SyncConfig baseCfg = new SyncConfig();
                TomlMapping[] maps = new TomlMapping[0];
                if (!string.IsNullOrEmpty(cfgPath))
                {
                    var loaded = TomlLoader.Load(cfgPath);
                    baseCfg = loaded.Item1;
                    maps = loaded.Item2;
                }

                // Apply CLI overrides
                TomlMapping[] mappingsOverride;
                if (!Cli.ApplyOverrides(norm, baseCfg, out mappingsOverride))
                {
                    Help.Print();
                    return 2;
                }

                // Decide mappings
                TomlMapping[] mappings;
                if (mappingsOverride != null) mappings = mappingsOverride;
                else if (maps.Length == 0 && Cli.TryGetPositionalMapping(args, out var pos)) mappings = new[] { pos };
                else mappings = maps;

                if (mappings.Length == 0)
                {
                    Console.WriteLine("[ERROR] マッピングがありません。--config か <input> <output> を指定してください。");
                    Help.Print();
                    return 2;
                }

                // If zip-output is used across multiple mappings, delete existing zip once at the start
                bool zipModeAll = !string.IsNullOrEmpty(baseCfg.ZipOutputPath);
                if (zipModeAll && File.Exists(baseCfg.ZipOutputPath))
                {
                    try { File.Delete(baseCfg.ZipOutputPath); }
                    catch (Exception ex) { Console.WriteLine("[WARN] 既存ZIP削除失敗: " + ex.Message); }
                }

                int rc = 0;

                for (int i = 0; i < mappings.Length; i++)
                {
                    var m = mappings[i];
                    bool unzipMode = !string.IsNullOrEmpty(baseCfg.UnzipInputPath);
                    bool zipMode = !string.IsNullOrEmpty(baseCfg.ZipOutputPath);

                    RunContext run = new RunContext(baseCfg, cfgPath);
                    try
                    {
                        run.WriteRunStart();

                        if (unzipMode)
                        {
                            // unzip: output だけ必須
                            if (string.IsNullOrWhiteSpace(m.output))
                            {
                                Console.WriteLine("[WARN] --unzip-input 使用時は mappings.output を指定してください。スキップ。");
                                run.AnyFailed = true; run.WriteRunEnd(false);
                                continue;
                            }

                            var cfg = baseCfg.Clone();
                            cfg.OutputDir = Path.GetFullPath(m.output);
                            cfg.InputDir = null;
                            cfg.ProgressTitle = "[zip:" + baseCfg.UnzipInputPath + " \u2192 " + cfg.OutputDir + "]";
                            rc |= Engine.RunDirectoryToDirectory(cfg, run);
                        }
                        else if (zipMode)
                        {
                            // zip: input だけ必須
                            if (string.IsNullOrWhiteSpace(m.input))
                            {
                                Console.WriteLine("[WARN] --zip-output 使用時は mappings.input を指定してください。スキップ。");
                                run.AnyFailed = true; run.WriteRunEnd(false);
                                continue;
                            }
                            if (!Directory.Exists(m.input))
                            {
                                Console.WriteLine("[WARN] 入力フォルダが見つかりません: " + m.input);
                                run.AnyFailed = true; run.WriteRunEnd(false);
                                continue;
                            }

                            var cfg = baseCfg.Clone();
                            cfg.InputDir = Path.GetFullPath(m.input);
                            cfg.OutputDir = null;
                            cfg.ProgressTitle = "[" + cfg.InputDir + " \u2192 zip:" + baseCfg.ZipOutputPath + "]";
                            rc |= Engine.RunDirectoryToDirectory(cfg, run);
                        }
                        else
                        {
                            // 通常: input/output 必須
                            if (string.IsNullOrWhiteSpace(m.input) || string.IsNullOrWhiteSpace(m.output))
                            {
                                Console.WriteLine("[WARN] mapping の input/output が空です。スキップします。");
                                run.AnyFailed = true; run.WriteRunEnd(false);
                                continue;
                            }

                            bool isDir = Directory.Exists(m.input);
                            bool isFile = File.Exists(m.input);
                            if (!isDir && !isFile)
                            {
                                Console.WriteLine("[WARN] 入力が見つかりません: " + m.input);
                                run.AnyFailed = true; run.WriteRunEnd(false);
                                continue;
                            }

                            if (isDir)
                            {
                                var cfg = baseCfg.Clone();
                                cfg.InputDir = Path.GetFullPath(m.input);
                                cfg.OutputDir = Path.GetFullPath(m.output);
                                cfg.ProgressTitle = "[" + cfg.InputDir + " \u2192 " + cfg.OutputDir + "]";
                                rc |= Engine.RunDirectoryToDirectory(cfg, run);
                            }
                            else
                            {
                                var cfg = baseCfg.Clone();
                                cfg.ProgressTitle = "[" + m.input + " \u2192 " + m.output + "]";
                                rc |= Engine.RunFileToFile(cfg, run, m.input, m.output);
                            }
                        }

                        run.WriteRunEnd(!run.AnyFailed);
                    }
                    catch (Exception ex)
                    {
                        run.AnyFailed = true;
                        run.WriteRunEnd(false);
                        Console.WriteLine("[ERR] マッピング中にエラー: " + ex.Message);
                        rc |= 1;
                    }
                    finally
                    {
                        run.Dispose();
                    }
                }

                return rc;
            }
            catch (Exception ex)
            {
                Console.WriteLine("[FATAL] " + ex);
                return 2;
            }
        }

        static string NormalizeDash(string a)
        {
            if (a.StartsWith("--")) return a;
            if (a.StartsWith("-")) return "-" + a;
            return a;
        }

        static string GetConfigPathFromArgs(string[] args)
        {
            for (int i = 0; i < args.Length; i++)
            {
                var a = args[i];
                if (a.Equals("--config", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
                    return args[i + 1];
                if (a.StartsWith("--config=", StringComparison.OrdinalIgnoreCase))
                    return a.Substring("--config=".Length);
            }
            return null;
        }
    }

    // ========= 設定 =========
    internal class SyncConfig
    {
        // globals
        public bool Subdirectory = false;          // TOML: subdirectory / recursive(互換)
        public bool Recursive { get { return Subdirectory; } set { Subdirectory = value; } }

        public bool MoveFiles = false;
        public string HashMode = "none";           // none|full|partial
        public int PartialBytes = 4 * 1024;        // partial ブロック
        public bool EnableCopyCheck = false;
        public bool EnableSyncDelete = false;
        public int Parallelism = 1;
        public int Retry = 3;
        public bool PauseOnError = false;
        public string LogPath = "";
        public long MaxLogBytes = 10L * 1024 * 1024;
        public bool ProgressDetail = false;
        public bool ProgressSingleLine = true;     // 同じ行上書き/毎回改行
        public bool ProgressForceInline = false;   // リダイレクトでも強制インライン
        public bool DryRun = false;
        public DateTime? ModifiedAfter = null;
        public bool ModifiedAfterAuto = false;
        public DateTime? ModifiedBefore = null;
        public List<string> FilterExt = new List<string>();
        public List<string> ExcludeExt = new List<string>();
        public List<string> ExcludeFolders = new List<string>();
        public List<string> NameMatch = new List<string>();
        public List<Regex> NameRegex = new List<Regex>();
        public string ErrorListPath = "";
        public string ZipOutputPath = "";
        public string UnzipInputPath = "";

        // ZIP オプション
        public string ZipLayout = "flat";          // flat | root
        public string ZipDuplicate = "seq";        // overwrite | skip | seq
        public string ZipCompression = "optimal";  // optimal | fastest | none

        // UNZIP 最適化
        public bool UnzipShareZip = true;          // true かつ Parallelism==1 で共有 ZipArchive を使う

        // 実行用
        public string InputDir = null;
        public string OutputDir = null;
        public string ProgressTitle = "";

        public SyncConfig Clone()
        {
            var c = (SyncConfig)MemberwiseClone();
            c.FilterExt = new List<string>(FilterExt);
            c.ExcludeExt = new List<string>(ExcludeExt);
            c.ExcludeFolders = new List<string>(ExcludeFolders);
            c.NameMatch = new List<string>(NameMatch);
            c.NameRegex = new List<Regex>();
            foreach (var rx in NameRegex) c.NameRegex.Add(new Regex(rx.ToString(), rx.Options));
            return c;
        }
    }

    internal class TomlMapping
    {
        public string input;
        public string output;
    }

    // ========= TOML ローダ =========
    internal static class TomlLoader
    {
        public static Tuple<SyncConfig, TomlMapping[]> Load(string path)
        {
            string text = File.ReadAllText(path, Encoding.UTF8);
            var model = Toml.ToModel(text);

            SyncConfig cfg = new SyncConfig();
            List<TomlMapping> maps = new List<TomlMapping>();

            var root = model as Tomlyn.Model.TomlTable;
            if (root != null)
            {
                object gobj;
                if (root.TryGetValue("globals", out gobj) && gobj is Tomlyn.Model.TomlTable)
                {
                    var g = (Tomlyn.Model.TomlTable)gobj;

                    SetBool(g, "recursive", v => cfg.Recursive = v);   // 互換
                    SetBool(g, "subdirectory", v => cfg.Recursive = v); // 正式

                    SetBool(g, "move", v => cfg.MoveFiles = v);
                    SetBool(g, "copycheck", v => cfg.EnableCopyCheck = v);
                    SetBool(g, "syncdelete", v => cfg.EnableSyncDelete = v);
                    SetBool(g, "pause_on_error", v => cfg.PauseOnError = v);
                    SetBool(g, "progress_detail", v => cfg.ProgressDetail = v);
                    SetBool(g, "progress_singleline", v => cfg.ProgressSingleLine = v);
                    SetBool(g, "progress_force_inline", v => cfg.ProgressForceInline = v); // 追加
                    SetBool(g, "dryrun", v => cfg.DryRun = v);

                    SetString(g, "hash", s => cfg.HashMode = string.IsNullOrEmpty(s) ? "none" : s.Trim().ToLowerInvariant());
                    SetString(g, "log", s => cfg.LogPath = s ?? "");
                    SetString(g, "error_list", s => cfg.ErrorListPath = s ?? "");
                    SetString(g, "zip_output", s => cfg.ZipOutputPath = s ?? "");
                    SetString(g, "unzip_input", s => cfg.UnzipInputPath = s ?? "");
                    SetBool(g, "unzip_shared", v => cfg.UnzipShareZip = v);

                    SetString(g, "zip_layout", s =>
                    {
                        if (string.IsNullOrWhiteSpace(s)) return;
                        var v = s.Trim().ToLowerInvariant();
                        if (v == "flat" || v == "root") cfg.ZipLayout = v;
                        else throw new FormatException("globals.zip_layout は flat|root");
                    });
                    SetString(g, "zip_duplicate", s =>
                    {
                        if (string.IsNullOrWhiteSpace(s)) return;
                        var v = s.Trim().ToLowerInvariant();
                        if (v == "overwrite" || v == "skip" || v == "seq") cfg.ZipDuplicate = v;
                        else throw new FormatException("globals.zip_duplicate は overwrite|skip|seq");
                    });
                    SetString(g, "zip_compression", s =>
                    {
                        if (string.IsNullOrWhiteSpace(s)) return;
                        var v = s.Trim().ToLowerInvariant();
                        if (v == "optimal" || v == "fastest" || v == "none") cfg.ZipCompression = v;
                        else throw new FormatException("globals.zip_compression は optimal|fastest|none");
                    });

                    SetString(g, "partial_bytes", s =>
                    {
                        if (!string.IsNullOrWhiteSpace(s))
                        {
                            long n;
                            if (!SizeParser.TryParse(s, out n)) throw new FormatException("globals.partial_bytes が不正");
                            cfg.PartialBytes = (int)Math.Min(n, int.MaxValue);
                        }
                    });
                    SetString(g, "max_log_size", s =>
                    {
                        if (!string.IsNullOrWhiteSpace(s))
                        {
                            long n;
                            if (!SizeParser.TryParse(s, out n)) throw new FormatException("globals.max_log_size が不正");
                            cfg.MaxLogBytes = n;
                        }
                    });

                    SetInt(g, "parallel", v => cfg.Parallelism = Math.Max(1, v));
                    SetInt(g, "retry", v => cfg.Retry = Math.Max(0, v));

                    SetString(g, "modified_after", s =>
                    {
                        if (string.IsNullOrWhiteSpace(s)) return;
                        if (string.Equals(s.Trim(), "auto", StringComparison.OrdinalIgnoreCase)) cfg.ModifiedAfterAuto = true;
                        else
                        {
                            DateTime dt;
                            if (!DateParser.TryParse(s, out dt)) throw new FormatException("globals.modified_after が不正");
                            cfg.ModifiedAfter = dt;
                        }
                    });
                    SetString(g, "modified_before", s =>
                    {
                        if (string.IsNullOrWhiteSpace(s)) return;
                        DateTime dt;
                        if (!DateParser.TryParse(s, out dt)) throw new FormatException("globals.modified_before が不正");
                        cfg.ModifiedBefore = dt;
                    });

                    AddExtArray(g, "filter_ext", cfg.FilterExt);
                    AddExtArray(g, "exclude_ext", cfg.ExcludeExt);
                    AddStringArray(g, "exclude_folder", cfg.ExcludeFolders);
                    AddStringArray(g, "name_match", cfg.NameMatch);
                    object rxObj;
                    if (g.TryGetValue("name_regex", out rxObj) && rxObj is Tomlyn.Model.TomlArray)
                    {
                        foreach (var it in (Tomlyn.Model.TomlArray)rxObj)
                        {
                            var pat = (it == null ? "" : it.ToString()).Trim();
                            if (pat.Length > 0) cfg.NameRegex.Add(new Regex(pat, RegexOptions.IgnoreCase | RegexOptions.Compiled));
                        }
                    }
                }

                object mobs;
                if (root.TryGetValue("mappings", out mobs) && mobs is Tomlyn.Model.TomlTableArray)
                {
                    foreach (var e in (Tomlyn.Model.TomlTableArray)mobs)
                    {
                        var t = (Tomlyn.Model.TomlTable)e;
                        string i = t.TryGetValue("input", out var vi) ? (vi == null ? null : vi.ToString()) : null;
                        string o = t.TryGetValue("output", out var vo) ? (vo == null ? null : vo.ToString()) : null;
                        maps.Add(new TomlMapping { input = i, output = o });
                    }
                }
            }

            return Tuple.Create(cfg, maps.ToArray());
        }

        static void SetBool(Tomlyn.Model.TomlTable t, string key, Action<bool> setter)
        {
            object v; if (t.TryGetValue(key, out v) && v is bool) setter((bool)v);
        }
        static void SetInt(Tomlyn.Model.TomlTable t, string key, Action<int> setter)
        {
            object v;
            if (t.TryGetValue(key, out v))
            {
                int n; if (int.TryParse(v == null ? null : v.ToString(), out n)) setter(n);
            }
        }
        static void SetString(Tomlyn.Model.TomlTable t, string key, Action<string> setter)
        {
            object v; if (t.TryGetValue(key, out v)) setter(v == null ? null : v.ToString());
        }
        static void AddExtArray(Tomlyn.Model.TomlTable t, string key, List<string> dest)
        {
            object v;
            if (!t.TryGetValue(key, out v) || !(v is Tomlyn.Model.TomlArray)) return;
            foreach (var it in (Tomlyn.Model.TomlArray)v)
            {
                var s = (it == null ? "" : it.ToString()).Trim();
                if (s.Length == 0) continue;
                if (!s.StartsWith(".")) s = "." + s;
                dest.Add(s.ToLowerInvariant());
            }
        }
        static void AddStringArray(Tomlyn.Model.TomlTable t, string key, List<string> dest)
        {
            object v;
            if (!t.TryGetValue(key, out v) || !(v is Tomlyn.Model.TomlArray)) return;
            foreach (var it in (Tomlyn.Model.TomlArray)v)
            {
                var s = (it == null ? "" : it.ToString()).Trim();
                if (s.Length == 0) continue;
                dest.Add(s);
            }
        }
    }

    internal static class ConfigPaths
    {
        public static string GetDefaultConfigPath()
        {
            string dir;
            var plat = Environment.OSVersion.Platform;
            if (plat == PlatformID.Win32NT)
                dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "FileSync");
            else
                dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "FileSync");
            Directory.CreateDirectory(dir);
            return Path.Combine(dir, "config.toml");
        }
    }

    // ========= CLI 解析 =========
    internal static class Cli
    {
        public static bool TryGetPositionalMapping(string[] originalArgs, out TomlMapping mapping)
        {
            var plain = originalArgs.Where(a => !(a.StartsWith("-") || a.StartsWith("--"))).ToArray();
            if (plain.Length >= 2)
            {
                mapping = new TomlMapping { input = plain[0], output = plain[1] };
                return true;
            }
            mapping = new TomlMapping();
            return false;
        }

        public static bool ApplyOverrides(string[] args, SyncConfig cfg, out TomlMapping[] mappingsOverride)
        {
            mappingsOverride = null;
            string inputList = null, outputList = null;

            foreach (var a in args)
            {
                var low = a.ToLowerInvariant();
                if (!low.StartsWith("--")) continue;

                if (low == "--subdirectory" || low == "--subdiretory" || low == "--recursive") cfg.Recursive = true;
                else if (low == "--move") cfg.MoveFiles = true;
                else if (low == "--copycheck") cfg.EnableCopyCheck = true;
                else if (low == "--syncdelete") cfg.EnableSyncDelete = true;
                else if (low == "--dryrun") cfg.DryRun = true;
                else if (low == "--pause-on-error") cfg.PauseOnError = true;
                else if (low == "--progress-detail") cfg.ProgressDetail = true;
                else if (low == "--progress-singleline") cfg.ProgressSingleLine = true;
                else if (low == "--progress-multiline") cfg.ProgressSingleLine = false;
                else if (low == "--progress-force-inline") cfg.ProgressForceInline = true;
                else if (low.StartsWith("--progress-mode="))
                {
                    var v = a.Substring("--progress-mode=".Length).Trim().ToLowerInvariant();
                    cfg.ProgressSingleLine = (v != "multi" && v != "multiline");
                }

                else if (low.StartsWith("--hash="))
                {
                    var val = a.Substring("--hash=".Length);
                    if (val.StartsWith("partial", StringComparison.OrdinalIgnoreCase))
                    {
                        cfg.HashMode = "partial";
                        var sizePart = val.Substring("partial".Length);
                        if (sizePart.Length > 0)
                        {
                            long n;
                            if (!SizeParser.TryParse(sizePart, out n)) { Console.WriteLine("Invalid --hash partial size."); return false; }
                            cfg.PartialBytes = (int)Math.Min(n, int.MaxValue);
                        }
                    }
                    else if (string.Equals(val, "none", StringComparison.OrdinalIgnoreCase) ||
                             string.Equals(val, "full", StringComparison.OrdinalIgnoreCase))
                    {
                        cfg.HashMode = val.ToLowerInvariant();
                    }
                    else { Console.WriteLine("Unknown --hash mode."); return false; }
                }
                else if (low.StartsWith("--partial-bytes="))
                {
                    var s = a.Substring("--partial-bytes=".Length);
                    long n;
                    if (!SizeParser.TryParse(s, out n)) { Console.WriteLine("Invalid --partial-bytes"); return false; }
                    cfg.PartialBytes = (int)Math.Min(n, int.MaxValue);
                    if (cfg.HashMode == "none") cfg.HashMode = "partial";
                }
                else if (low.StartsWith("--parallel="))
                {
                    int n;
                    if (!int.TryParse(a.Substring("--parallel=".Length), out n) || n < 1) { Console.WriteLine("Invalid --parallel"); return false; }
                    cfg.Parallelism = n;
                }
                else if (low.StartsWith("--retry="))
                {
                    int n;
                    if (!int.TryParse(a.Substring("--retry=".Length), out n) || n < 0) { Console.WriteLine("Invalid --retry"); return false; }
                    cfg.Retry = n;
                }
                else if (low.StartsWith("--log="))
                {
                    cfg.LogPath = a.Substring("--log=".Length);
                }
                else if (low.StartsWith("--maxlogsize="))
                {
                    var s = a.Substring("--maxlogsize=".Length);
                    long n;
                    if (!SizeParser.TryParse(s, out n)) { Console.WriteLine("Invalid --maxlogsize"); return false; }
                    cfg.MaxLogBytes = n;
                }
                else if (low.StartsWith("--max-log-size="))
                {
                    var s = a.Substring("--max-log-size=".Length);
                    long n;
                    if (!SizeParser.TryParse(s, out n)) { Console.WriteLine("Invalid --max-log-size"); return false; }
                    cfg.MaxLogBytes = n;
                }
                else if (low.StartsWith("--modified-after="))
                {
                    var s = a.Substring("--modified-after=".Length);
                    if (string.Equals(s, "auto", StringComparison.OrdinalIgnoreCase)) cfg.ModifiedAfterAuto = true;
                    else
                    {
                        DateTime dt;
                        if (!DateParser.TryParse(s, out dt)) { Console.WriteLine("Invalid --modified-after"); return false; }
                        cfg.ModifiedAfter = dt;
                    }
                }
                else if (low.StartsWith("--modified-before="))
                {
                    var s = a.Substring("--modified-before=".Length);
                    DateTime dt;
                    if (!DateParser.TryParse(s, out dt)) { Console.WriteLine("Invalid --modified-before"); return false; }
                    cfg.ModifiedBefore = dt;
                }
                else if (low.StartsWith("--filter-ext="))
                {
                    cfg.FilterExt.Clear();
                    foreach (var ext in a.Substring("--filter-ext=".Length).Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
                    {
                        var e = ext.Trim(); if (!e.StartsWith(".")) e = "." + e;
                        cfg.FilterExt.Add(e.ToLowerInvariant());
                    }
                }
                else if (low.StartsWith("--exclude-ext="))
                {
                    cfg.ExcludeExt.Clear();
                    foreach (var ext in a.Substring("--exclude-ext=".Length).Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
                    {
                        var e = ext.Trim(); if (!e.StartsWith(".")) e = "." + e;
                        cfg.ExcludeExt.Add(e.ToLowerInvariant());
                    }
                }
                else if (low.StartsWith("--exclude-folder="))
                {
                    cfg.ExcludeFolders.Clear();
                    foreach (var f in a.Substring("--exclude-folder=".Length).Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
                        cfg.ExcludeFolders.Add(f.Trim());
                }
                else if (low.StartsWith("--name-match="))
                {
                    cfg.NameMatch.Clear();
                    foreach (var p in a.Substring("--name-match=".Length).Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
                        cfg.NameMatch.Add(p.Trim());
                }
                else if (low.StartsWith("--name-regex="))
                {
                    cfg.NameRegex.Clear();
                    foreach (var r in a.Substring("--name-regex=".Length).Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
                        cfg.NameRegex.Add(new Regex(r.Trim(), RegexOptions.IgnoreCase | RegexOptions.Compiled));
                }
                else if (low.StartsWith("--error-list="))
                {
                    cfg.ErrorListPath = a.Substring("--error-list=".Length);
                }
                else if (low.StartsWith("--zip-output="))
                {
                    cfg.ZipOutputPath = a.Substring("--zip-output=".Length);
                }
                else if (low.StartsWith("--unzip-input="))
                {
                    cfg.UnzipInputPath = a.Substring("--unzip-input=".Length);
                }
                else if (low.StartsWith("--zip-layout="))
                {
                    var v = a.Substring("--zip-layout=".Length).Trim().ToLowerInvariant();
                    if (v == "flat" || v == "root") cfg.ZipLayout = v;
                    else { Console.WriteLine("Invalid --zip-layout (flat|root)"); return false; }
                }
                else if (low.StartsWith("--zip-dup="))
                {
                    var v = a.Substring("--zip-dup=".Length).Trim().ToLowerInvariant();
                    if (v == "overwrite" || v == "skip" || v == "seq") cfg.ZipDuplicate = v;
                    else { Console.WriteLine("Invalid --zip-dup (overwrite|skip|seq)"); return false; }
                }
                else if (low.StartsWith("--zip-duplicate="))
                {
                    var v = a.Substring("--zip-duplicate=".Length).Trim().ToLowerInvariant();
                    if (v == "overwrite" || v == "skip" || v == "seq") cfg.ZipDuplicate = v;
                    else { Console.WriteLine("Invalid --zip-duplicate (overwrite|skip|seq)"); return false; }
                }
                else if (low.StartsWith("--zip-compression="))
                {
                    var v = a.Substring("--zip-compression=".Length).Trim().ToLowerInvariant();
                    if (v == "optimal" || v == "fastest" || v == "none") cfg.ZipCompression = v;
                    else { Console.WriteLine("Invalid --zip-compression (optimal|fastest|none)"); return false; }
                }
                else if (low.StartsWith("--input="))
                {
                    inputList = a.Substring("--input=".Length);
                }
                else if (low.StartsWith("--output="))
                {
                    outputList = a.Substring("--output=".Length);
                }
                else if (low.StartsWith("--unzip-shared"))
                {
                    // --unzip-shared or --unzip-shared=false
                    if (low == "--unzip-shared") cfg.UnzipShareZip = true;
                    else
                    {
                        var idx = a.IndexOf('=');
                        if (idx > 0)
                        {
                            var val = a.Substring(idx + 1).Trim().ToLowerInvariant();
                            cfg.UnzipShareZip = !(val == "0" || val == "false" || val == "no");
                        }
                        else cfg.UnzipShareZip = true;
                    }
                }
            }

            // Build mappings from input/output lists when possible
            if (!string.IsNullOrEmpty(inputList) && !string.IsNullOrEmpty(outputList))
            {
                var ins = inputList.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
                var outs = outputList.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
                int n = Math.Min(ins.Length, outs.Length);
                var list = new List<TomlMapping>(n);
                for (int i = 0; i < n; i++)
                    list.Add(new TomlMapping { input = ins[i].Trim(), output = outs[i].Trim() });
                mappingsOverride = list.ToArray();
            }

            if (mappingsOverride == null)
            {
                if (!string.IsNullOrEmpty(cfg.UnzipInputPath) && !string.IsNullOrEmpty(outputList))
                {
                    var outs = outputList.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
                    mappingsOverride = outs.Select(o => new TomlMapping { input = null, output = o.Trim() }).ToArray();
                }
                if (!string.IsNullOrEmpty(cfg.ZipOutputPath) && !string.IsNullOrEmpty(inputList))
                {
                    var ins = inputList.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
                    mappingsOverride = ins.Select(iStr => new TomlMapping { input = iStr.Trim(), output = null }).ToArray();
                }
            }

            if (!string.IsNullOrEmpty(cfg.ZipOutputPath) && !string.IsNullOrEmpty(cfg.UnzipInputPath))
            {
                Console.WriteLine("[ERROR] --zip-output と --unzip-input は同時指定不可です。");
                return false;
            }

            return true;
        }
    }

    internal static class Help
    {
        public static void Print()
        {
            Console.WriteLine(@"
Usage:
  FileSync.exe --config <config.toml> [options]
  FileSync.exe <inputDir> <outputDir> [options]

Options:
  --subdirectory (alias: --recursive)
  --move  --hash=none|full|partialN  --partial-bytes=256kb
  --copycheck  --syncdelete  --parallel=N  --retry=N  --pause-on-error
  --log=PATH  --max-log-size=20mb (alias: --maxlogsize)
  --progress-detail  --progress-singleline | --progress-multiline | --progress-mode=single|multi | --progress-force-inline
  --dryrun
  --modified-after=auto|YYYY-MM-DD|YYYY-MM-DDTHH:mm:ss
  --modified-before=YYYY-MM-DD|YYYY-MM-DDTHH:mm:ss
  --filter-ext=jpg;png  --exclude-ext=tmp;bak  --exclude-folder=.git;node_modules
  --name-match=""*.pdf;report_*.docx""  --name-regex=""^IMG_\\d+\\.jpg$""
  --error-list=errors.txt
  --zip-output=out.zip  --zip-layout=flat|root  --zip-duplicate=overwrite|skip|seq
  --zip-compression=optimal|fastest|none
  --unzip-input=in.zip  --unzip-shared[=true|false]
  --input=D:/a;D:/b  --output=E:/x;E:/y
");
        }
    }

    // ========= 進行セッション(ログ・履歴・進捗UI) =========
    internal sealed class RunContext : IDisposable
    {
        public readonly SyncConfig BaseConfig;
        public readonly string SessionId = Guid.NewGuid().ToString("N");
        public readonly string ConfigPath;
        public bool AnyFailed = false;

        StreamWriter _log;
        string _runningFlag;

        public readonly Dictionary<string, HistoryEntry> History = new Dictionary<string, HistoryEntry>(StringComparer.OrdinalIgnoreCase);

        readonly object _nowLock = new object();
        string _nowSrcRel = "";
        string _nowDstRel = "";

        // 進捗出力の調整用(表示幅 単位)
        readonly object _printLock = new object();
        int _lastPrintedCols = 0;              // 前回出力の “表示幅(桁数)”

        public void SetNowCopying(string srcRel, string dstRel)
        {
            lock (_nowLock) { _nowSrcRel = srcRel ?? ""; _nowDstRel = dstRel ?? ""; }
        }

        public RunContext(SyncConfig cfg, string configPath)
        {
            BaseConfig = cfg;
            ConfigPath = configPath;

            if (!string.IsNullOrWhiteSpace(cfg.LogPath))
            {
                try { LoadHistoryFromLog(cfg.LogPath); } catch { }

                if (cfg.ModifiedAfterAuto)
                {
                    DateTime autoDt;
                    if (TryGetLastSuccessfulRunEndTime(cfg.LogPath, out autoDt))
                    {
                        BaseConfig.ModifiedAfter = autoDt;
                        Console.WriteLine("[auto] modified-after = " + autoDt.ToString("yyyy-MM-dd HH:mm:ss"));
                    }
                    else
                    {
                        Console.WriteLine("[auto] 前回全成功が見つかりません。");
                    }
                }

                try
                {
                    var fs = new FileStream(cfg.LogPath, FileMode.Append, FileAccess.Write, FileShare.Read, 4096, FileOptions.WriteThrough);
                    _log = new StreamWriter(fs, Encoding.UTF8) { AutoFlush = true };
                }
                catch
                {
                    _log = new StreamWriter(cfg.LogPath, true, Encoding.UTF8) { AutoFlush = true };
                }

                _runningFlag = cfg.LogPath + ".running";
                try { File.WriteAllText(_runningFlag, SessionId, Encoding.UTF8); } catch { }

                var oldFlag = cfg.LogPath + ".running";
                if (File.Exists(oldFlag))
                {
                    string oldSession = "";
                    try { oldSession = File.ReadAllText(oldFlag, Encoding.UTF8).Trim(); } catch { }
                    WriteLine("#RUNRECOVER," + NowLocal() + "," + SessionId + ",prev=" + oldSession);
                    try { File.Delete(oldFlag); } catch { }
                }
            }
        }

        public void Dispose() { try { if (_log != null) _log.Dispose(); } catch { } }

        public void WriteRunStart() { WriteLine("#RUNSTART," + NowLocal() + "," + SessionId); }
        public void WriteRunEnd(bool success)
        {
            WriteLine(success ? "#RUNEND,SUCCESS," + NowLocal() + "," + SessionId
                              : "#RUNEND,FAILED," + NowLocal() + "," + SessionId);
            try { if (!string.IsNullOrEmpty(_runningFlag) && File.Exists(_runningFlag)) File.Delete(_runningFlag); } catch { }
        }

        DateTime _start;
        long _totalFiles, _processedFiles, _totalBytes, _doneBytes;

        public void InitProgress(long totalFiles, long totalBytes)
        {
            _start = DateTime.Now;
            _totalFiles = totalFiles;
            _totalBytes = totalBytes;
            _processedFiles = 0;
            _doneBytes = 0;
            _lastPrintedCols = 0; // 進捗行の前回表示幅をリセット
        }

        public void StepProgress(bool addFile, long addBytes, bool detail, string title)
        {
            if (addFile) Interlocked.Increment(ref _processedFiles);
            if (addBytes > 0) Interlocked.Add(ref _doneBytes, addBytes);
            if (!detail) return;

            // ---- 現在値の計算 ----
            double seconds = (DateTime.Now - _start).TotalSeconds;
            double bps = seconds > 0 ? _doneBytes / seconds : 0.0;             // Bytes/s
            double remain = (bps > 0) ? (_totalBytes - _doneBytes) / bps : double.NaN;

            int countWidth = Math.Max(3, _totalFiles.ToString().Length);
            string prog = _processedFiles.ToString().PadLeft(countWidth) + "/" +
                          _totalFiles.ToString().PadLeft(countWidth);

            // サイズの単位は総量で決める(GB/MB/KB/B)
            var unitSize = ChooseUnit(_totalBytes);

            // 右側(ファイル名や「src → dst」)を組み立て
            string joined;
            lock (_nowLock)
            {
                string file = System.IO.Path.GetFileName(_nowSrcRel ?? "");
                string pair = (_nowSrcRel ?? "") + " \u2192 " + (_nowDstRel ?? "");
                string map = title ?? "";
                // 例: "ZFC_9965.JPG  albums\...\2024 → E:\backup\...\2024  [D:\in → E:\out]"
                joined = (string.IsNullOrEmpty(file) ? pair : (file + "  " + pair)) + "  " + map;
            }

            int consoleWidth = GetConsoleWidth();
            int rightMargin = Math.Max(3, consoleWidth / 25);      // 右端に常時余白を確保
            int maxCols = consoleWidth - rightMargin;

            // 左側(進捗/速度/残り/完了)の整形パラメータ
            const int speedDp = 1;      // 速度は常に MB/s・小数1桁
            int decimalsSize = 3;      // 完了サイズ(done/total)の小数桁(3→2→1→0)
            int numWidth = 9;      // 数値部分の幅(狭い時は 9→8→...→6 まで詰める)

            // 1) まずは "..." による右側の省略を**最優先**で実施
            string left = BuildLeft(prog, unitSize, numWidth, decimalsSize, speedDp);
            int leftCols = DisplayWidth(left);
            int tailMax = maxCols - leftCols;
            string tail = tailMax > 0 ? TruncateMiddleByCols(joined, tailMax) : "";
            string line = left + tail;

            // 2) まだはみ出す or tail を 0 まで詰めても左側が大きすぎる場合、
            //    完了サイズの小数桁数を 3→2→1→0 と段階的に減らしてフィットを試みる。
            int safety = 0;
            while (DisplayWidth(line) > maxCols && safety++ < 32)
            {
                if (decimalsSize > 0)
                {
                    decimalsSize--; // 3→2→1→0
                }
                else if (numWidth > 6)
                {
                    numWidth--;     // 9→8→...→6 まで詰める(桁合わせの左詰め余白を削る)
                }
                else
                {
                    // これ以上は詰められないので打ち切り
                    break;
                }

                left = BuildLeft(prog, unitSize, numWidth, decimalsSize, speedDp);
                leftCols = DisplayWidth(left);
                tailMax = maxCols - leftCols;
                tail = tailMax > 0 ? TruncateMiddleByCols(joined, tailMax) : "";
                line = left + tail;
            }

            // 3) 出力:シングルライン上書き or 改行
            bool inline = (BaseConfig != null) &&
                          (BaseConfig.ProgressSingleLine || BaseConfig.ProgressForceInline) &&
                          (!Console.IsOutputRedirected || BaseConfig.ProgressForceInline);

            lock (_printLock)
            {
                if (inline)
                {
                    try { Console.CursorVisible = false; } catch { }
                    Console.Write("\r" + line);

                    // 前回より短くなったぶんを“表示幅”でスペース消去
                    int lineCols = DisplayWidth(line);
                    int diffCols = _lastPrintedCols - lineCols;
                    if (diffCols > 0) Console.Write(new string(' ', diffCols));
                    _lastPrintedCols = lineCols;
                }
                else
                {
                    Console.WriteLine(line);
                    _lastPrintedCols = 0;
                }
            }

            // ---- ローカル: 左側の組み立て(完了サイズの小数桁を可変) ----
            string BuildLeft(string progStr, SizeUnit uSize, int w, int dpSize, int dpSpeed)
            {
                string doneStr = FormatSizeFixed(_doneBytes, uSize, w, dpSize);
                string totalStr = FormatSizeFixed(_totalBytes, uSize, w, dpSize);
                string speedStr = FormatSizeFixedDouble(bps, SizeUnit.MB, w, dpSpeed) + "/s"; // 常に MB/s(小数1桁)
                string remainStr = double.IsNaN(remain) ? "--:--:--" : FormatHMS(remain);
                // 左側は固定順序・固定文言。ここが広すぎる場合のみ上の while で dpSize / w を詰める。
                return $"進捗:{progStr}  速度:{speedStr}  残り:{remainStr}  完了:{doneStr}/{totalStr}  ";
            }
        }

        enum SizeUnit { B, KB, MB, GB, TB }
        static SizeUnit ChooseUnit(long bytes)
        {
            if (bytes >= 1099511627776L) return SizeUnit.TB;
            if (bytes >= 1073741824L) return SizeUnit.GB;
            if (bytes >= 1048576L) return SizeUnit.MB;
            if (bytes >= 1024L) return SizeUnit.KB;
            return SizeUnit.B;
        }
        static string FormatSizeFixed(long bytes, SizeUnit unit, int width, int decimals)
        {
            double div = 1.0; string label = "B";
            switch (unit)
            {
                case SizeUnit.KB: div = 1024.0; label = "KB"; break;
                case SizeUnit.MB: div = 1024.0 * 1024.0; label = "MB"; break;
                case SizeUnit.GB: div = 1024.0 * 1024.0 * 1024.0; label = "GB"; break;
                case SizeUnit.TB: div = 1024.0 * 1024.0 * 1024.0 * 1024.0; label = "TB"; break;
            }
            double v = bytes / div;
            string fmt = (decimals > 0) ? ("0." + new string('0', decimals)) : "0";
            string num = v.ToString(fmt, CultureInfo.InvariantCulture);
            return num.PadLeft(width) + " " + label;
        }
        static string FormatSizeFixedDouble(double bytes, SizeUnit unit, int width, int decimals)
        {
            double div = 1.0; string label = "B";
            switch (unit)
            {
                case SizeUnit.KB: div = 1024.0; label = "KB"; break;
                case SizeUnit.MB: div = 1024.0 * 1024.0; label = "MB"; break;
                case SizeUnit.GB: div = 1024.0 * 1024.0 * 1024.0; label = "GB"; break;
                case SizeUnit.TB: div = 1024.0 * 1024.0 * 1024.0 * 1024.0; label = "TB"; break;
            }
            double v = bytes / div;
            string fmt = (decimals > 0) ? ("0." + new string('0', decimals)) : "0";
            string num = v.ToString(fmt, CultureInfo.InvariantCulture);
            return num.PadLeft(width) + " " + label;
        }
        static string FormatHMS(double sec)
        {
            if (double.IsNaN(sec) || double.IsInfinity(sec) || sec < 0) return "--:--:--";
            var ts = TimeSpan.FromSeconds(sec);
            long hh = (long)ts.TotalHours;
            return string.Format("{0:D2}:{1:D2}:{2:D2}", hh, ts.Minutes, ts.Seconds);
        }

        // 進捗行の横幅(列数)を取得:リダイレクト時もそこそこ妥当な値を返す
        static int GetConsoleWidth()
        {
            try
            {
                // 通常コンソール
                if (!Console.IsOutputRedirected)
                {
                    int w = 0;
                    try { w = Console.WindowWidth; } catch { /* 一部環境で投げる */ }
                    if (w <= 0 || w > 3000) w = 120;   // 異常値ガード
                    return Math.Max(40, w);           // 極端に狭いと崩れるので下限
                }

                // 出力がリダイレクトされている場合
                // 環境変数(COLUMNS / CONSOLE_WIDTH)があれば利用、なければデフォルト
                int cols;
                var envCols = Environment.GetEnvironmentVariable("COLUMNS")
                           ?? Environment.GetEnvironmentVariable("CONSOLE_WIDTH");
                if (!string.IsNullOrEmpty(envCols) && int.TryParse(envCols, out cols))
                    return Math.Max(40, cols);

                // 既定値(ログ/パイプ/CI など)
                return 120;
            }
            catch
            {
                return 120;
            }
        }

        // 表示幅(全角2桁)で長さを計算
        static int DisplayWidth(string s)
        {
            if (string.IsNullOrEmpty(s)) return 0;
            int cols = 0;
            for (int i = 0; i < s.Length; i++)
            {
                char c = s[i];
                if (char.IsSurrogate(c)) { cols += 2; continue; }
                cols += IsWide(c) ? 2 : 1;
            }
            return cols;
        }
        static bool IsWide(char c)
        {
            int code = c;
            if (code >= 0x1100 &&
                (code <= 0x115F ||
                 code == 0x2329 || code == 0x232A ||
                 (code >= 0x2E80 && code <= 0xA4CF && code != 0x303F) ||
                 (code >= 0xAC00 && code <= 0xD7A3) ||
                 (code >= 0xF900 && code <= 0xFAFF) ||
                 (code >= 0xFE10 && code <= 0xFE19) ||
                 (code >= 0xFE30 && code <= 0xFE6F) ||
                 (code >= 0xFF00 && code <= 0xFF60) ||
                 (code >= 0xFFE0 && code <= 0xFFE6)))
                return true;
            return false;
        }
        // “表示幅”で中央省略(...)
        static string TruncateMiddleByCols(string s, int maxCols)
        {
            if (maxCols <= 0) return "";
            int w = DisplayWidth(s);
            if (w <= maxCols) return s;
            if (maxCols <= 3) return new string('.', maxCols);

            int keep = maxCols - 3; // “...”
            int headCols = keep / 2;
            int tailCols = keep - headCols;
            string head = SliceByCols(s, headCols, fromStart: true);
            string tail = SliceByCols(s, tailCols, fromStart: false);
            return head + "..." + tail;
        }
        static string SliceByCols(string s, int cols, bool fromStart)
        {
            if (cols <= 0 || string.IsNullOrEmpty(s)) return "";
            var sb = new StringBuilder();
            int acc = 0;

            if (fromStart)
            {
                for (int i = 0; i < s.Length; i++)
                {
                    int w = char.IsSurrogate(s[i]) ? 2 : (IsWide(s[i]) ? 2 : 1);
                    if (acc + w > cols) break;
                    sb.Append(s[i]); acc += w;
                }
            }
            else
            {
                for (int i = s.Length - 1; i >= 0; i--)
                {
                    int w = char.IsSurrogate(s[i]) ? 2 : (IsWide(s[i]) ? 2 : 1);
                    if (acc + w > cols) break;
                    sb.Insert(0, s[i]); acc += w;
                }
            }
            return sb.ToString();
        }

        public void LogCsv(string inRel, string outRel, string action, string result, bool skip, string note, long size, DateTime lastWriteUtc, string hash)
        {
            if (_log == null) return;
            CheckAndCompressLog();

            string tsLocal = NowLocal();
            string lwUtc = (lastWriteUtc == DateTime.MinValue) ? "" : lastWriteUtc.ToString("yyyy-MM-ddTHH:mm:ssZ");
            string line = "\"" + inRel + "\",\"" + outRel + "\",\"" + action + "\",\"" + result + "\",\"" + tsLocal + "\",\"" + (skip ? "Skipped" : note) + "\",\"" + SessionId + "\",\"" + size + "\",\"" + lwUtc + "\",\"" + hash + "\"";
            WriteLine(line);

            if (string.Equals(result, "Success", StringComparison.OrdinalIgnoreCase) ||
                string.Equals(result, "SkippedByHistory", StringComparison.OrdinalIgnoreCase))
            {
                if (!string.IsNullOrEmpty(lwUtc))
                {
                    History[inRel + "|" + outRel] = new HistoryEntry { Size = size, LastWriteUtc = lastWriteUtc, Hash = hash };
                }
            }
        }

        void WriteLine(string s)
        {
            if (_log == null) return;
            lock (_log) { _log.WriteLine(s); _log.Flush(); }
        }

        string NowLocal() { return DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); }

        void CheckAndCompressLog()
        {
            if (_log == null) return;
            try
            {
                var info = new FileInfo(BaseConfig.LogPath);
                if (info.Exists && info.Length > BaseConfig.MaxLogBytes)
                {
                    _log.Flush(); _log.Dispose();

                    var lines = File.ReadAllLines(BaseConfig.LogPath, Encoding.UTF8);
                    var headers = new List<string>();
                    var latest = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

                    for (int i = 0; i < lines.Length; i++)
                    {
                        var line = lines[i];
                        if (line.StartsWith("#RUN")) { headers.Add(line); continue; }
                        var cols = Csv.Parse(line);
                        if (cols.Length < 2) continue;
                        var inRel = Csv.Unquote(cols[0]);
                        var outRel = Csv.Unquote(cols[1]);
                        latest[inRel + "|" + outRel] = line;
                    }

                    using (var sw = new StreamWriter(BaseConfig.LogPath, false, Encoding.UTF8))
                    {
                        foreach (var h in headers) sw.WriteLine(h);
                        foreach (var kv in latest.Values) sw.WriteLine(kv);
                    }

                    var fs = new FileStream(BaseConfig.LogPath, FileMode.Append, FileAccess.Write, FileShare.Read, 4096, FileOptions.WriteThrough);
                    _log = new StreamWriter(fs, Encoding.UTF8) { AutoFlush = true };
                    Console.WriteLine("\n[LOG] 圧縮: " + BaseConfig.LogPath);
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine("\n[LOG] 圧縮失敗: " + ex.Message);
            }
        }

        void LoadHistoryFromLog(string path)
        {
            try
            {
                var lines = File.ReadAllLines(path, Encoding.UTF8);
                for (int i = 0; i < lines.Length; i++)
                {
                    var line = lines[i];
                    if (line.StartsWith("#RUN")) continue;
                    var cols = Csv.Parse(line);
                    if (cols.Length < 10) continue;

                    var inRel = Csv.Unquote(cols[0]);
                    var outRel = Csv.Unquote(cols[1]);
                    var result = Csv.Unquote(cols[3]);
                    if (!string.Equals(result, "Success", StringComparison.OrdinalIgnoreCase) &&
                        !string.Equals(result, "SkippedByHistory", StringComparison.OrdinalIgnoreCase)) continue;

                    long size = 0; long.TryParse(Csv.Unquote(cols[7]), out size);
                    DateTime lwUtc = DateTime.MinValue;
                    var s = Csv.Unquote(cols[8]);
                    if (!string.IsNullOrEmpty(s))
                        DateTime.TryParseExact(s, "yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out lwUtc);
                    string hash = Csv.Unquote(cols[9]);

                    if (lwUtc != DateTime.MinValue)
                        History[inRel + "|" + outRel] = new HistoryEntry { Size = size, LastWriteUtc = lwUtc, Hash = hash };
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine("[LOG] 履歴読み込み失敗: " + ex.Message);
            }
        }

        static bool TryGetLastSuccessfulRunEndTime(string path, out DateTime dt)
        {
            dt = default(DateTime);
            try
            {
                var lines = File.ReadAllLines(path, Encoding.UTF8);
                for (int i = lines.Length - 1; i >= 0; i--)
                {
                    var L = lines[i];
                    if (L.StartsWith("#RUNEND,SUCCESS"))
                    {
                        var p = L.Split(',');
                        DateTime val;
                        if (p.Length >= 3 && DateTime.TryParseExact(p[2], "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out val))
                        { dt = val; return true; }
                        if (p.Length >= 2 && DateTime.TryParseExact(p[1], "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out val))
                        { dt = val; return true; }
                    }
                }
            }
            catch { }
            return false;
        }
    }

    internal class HistoryEntry
    {
        public long Size;
        public DateTime LastWriteUtc;
        public string Hash;
    }

    // ========= CSV =========
    internal static class Csv
    {
        public static string[] Parse(string line)
        {
            var list = new List<string>();
            int i = 0;
            while (i < line.Length)
            {
                if (line[i] == '"')
                {
                    int j = i + 1; var sb = new StringBuilder();
                    while (j < line.Length)
                    {
                        if (line[j] == '"' && j + 1 < line.Length && line[j + 1] == '"') { sb.Append('"'); j += 2; }
                        else if (line[j] == '"') { j++; break; }
                        else { sb.Append(line[j]); j++; }
                    }
                    list.Add(sb.ToString());
                    if (j < line.Length && line[j] == ',') j++;
                    i = j;
                }
                else
                {
                    int j = line.IndexOf(',', i); if (j < 0) j = line.Length;
                    list.Add(line.Substring(i, j - i).Trim());
                    i = j + 1;
                }
            }
            return list.ToArray();
        }
        public static string Unquote(string s)
        {
            if (s.Length >= 2 && s[0] == '"' && s[s.Length - 1] == '"') return s.Substring(1, s.Length - 2).Replace("\"\"", "\"");
            return s;
        }
    }

    // ========= 便利パーサ =========
    internal static class SizeParser
    {
        public static bool TryParse(string input, out long bytes)
        {
            bytes = 0;
            if (string.IsNullOrWhiteSpace(input)) return false;
            input = input.Trim().ToLowerInvariant();
            int pos = 0;
            while (pos < input.Length && (char.IsDigit(input[pos]) || input[pos] == '.')) pos++;
            if (pos == 0) return false;
            string num = input.Substring(0, pos);
            string unit = input.Substring(pos).Trim();

            double n;
            if (!double.TryParse(num, NumberStyles.Float, CultureInfo.InvariantCulture, out n)) return false;
            double mul =
                unit == "" ? 1 :
                unit == "b" ? 1 :
                unit == "kb" ? 1024 :
                unit == "mb" ? 1024 * 1024 :
                unit == "gb" ? 1024d * 1024 * 1024 :
                double.NaN;
            if (double.IsNaN(mul)) return false;
            double val = n * mul;
            if (val > long.MaxValue) return false;
            bytes = (long)val;
            return true;
        }
    }
    internal static class DateParser
    {
        public static bool TryParse(string s, out DateTime dt)
        {
            return DateTime.TryParseExact(
                s,
                new[] { "yyyy-MM-dd", "yyyy-MM-ddTHH:mm:ss" },
                CultureInfo.InvariantCulture,
                DateTimeStyles.AssumeLocal,
                out dt);
        }
    }

    internal static class PathUtil
    {
        public static string GetRelativePath(string basePath, string fullPath)
        {
            string from = EnsureTrailingSlash(Path.GetFullPath(basePath));
            string to = Path.GetFullPath(fullPath);
            Uri fromUri = new Uri(from, UriKind.Absolute);
            Uri toUri = new Uri(to, UriKind.Absolute);
            string rel = Uri.UnescapeDataString(fromUri.MakeRelativeUri(toUri).ToString());
            return rel.Replace('/', Path.DirectorySeparatorChar);
        }
        static string EnsureTrailingSlash(string p)
        {
            if (!p.EndsWith(Path.DirectorySeparatorChar.ToString())) return p + Path.DirectorySeparatorChar;
            return p;
        }
    }

    // ========= エンジン =========
    internal static class Engine
    {
        public static int RunDirectoryToDirectory(SyncConfig cfg, RunContext run)
        {
            DateTime opStart = DateTime.Now; // 処理時間計測

            if (!string.IsNullOrEmpty(cfg.ZipOutputPath) && !string.IsNullOrEmpty(cfg.UnzipInputPath))
            {
                Console.WriteLine("[ERROR] zip-output と unzip-input は同時指定不可。");
                return 2;
            }

            bool unzipMode = !string.IsNullOrEmpty(cfg.UnzipInputPath);
            bool zipMode = !string.IsNullOrEmpty(cfg.ZipOutputPath);

            if (unzipMode)
            {
                if (string.IsNullOrEmpty(cfg.OutputDir))
                {
                    Console.WriteLine("[ERROR] --unzip-input 使用時は出力先(output)が必要です。");
                    return 2;
                }
            }
            else if (zipMode)
            {
                if (string.IsNullOrEmpty(cfg.InputDir))
                {
                    Console.WriteLine("[ERROR] --zip-output 使用時は入力元(input)が必要です。");
                    return 2;
                }
            }
            else
            {
                if (string.IsNullOrEmpty(cfg.InputDir) || string.IsNullOrEmpty(cfg.OutputDir))
                {
                    Console.WriteLine("[ERROR] 入出力ディレクトリ未指定。");
                    return 2;
                }
            }

            // --- 列挙:ファイルとフォルダ(フォルダは時刻反映用) ---
            List<FileItem> files;
            List<DirItem> dirs;
            try
            {
                if (unzipMode) files = EnumerateFromZip(cfg.UnzipInputPath, cfg, out dirs);
                else files = EnumerateFromDir(cfg.InputDir, cfg.Recursive, cfg, out dirs);
            }
            catch (Exception ex)
            {
                Console.WriteLine("[ERR] 列挙失敗: " + ex.Message);
                return 2;
            }

            files = files.Where(x => FilterAllow(cfg, x)).ToList();

            long totalFiles = files.LongCount();
            long totalBytes = 0; foreach (var it in files) totalBytes += it.Length;
            run.InitProgress(totalFiles, totalBytes);

            if (zipMode)
            {
                // ZIP作成:エントリの LastWriteTime を元時刻に、ディレクトリエントリも生成
                string prefix = "";
                if (cfg.ZipLayout == "root")
                {
                    var baseName = GetZipRootFolderName(cfg.InputDir);
                    prefix = baseName + "/";
                }
                int zr = RunToZip(files, dirs, cfg, run, prefix);
                Console.CursorVisible = true;
                Console.WriteLine();
                var opEnd = DateTime.Now;
                var elapsed = opEnd - opStart;
                string status = run.AnyFailed || zr != 0 ? "[DONE] エラーあり" : "[DONE] 全成功";
                Console.WriteLine($"{status}  完了:{opEnd:yyyy-MM-dd HH:mm:ss}  処理時間:{HMS(elapsed)}");
                return run.AnyFailed || zr != 0 ? 1 : 0;
            }

            if (cfg.EnableSyncDelete && !unzipMode) DeleteSyncFiles(cfg, run, files);

            var po = new ParallelOptions { MaxDegreeOfParallelism = cfg.Parallelism };
            var bagError = new ConcurrentBag<string>();

            // 共有Zip(最適化): unzip_shared && parallel==1 のときのみ有効
            ZipArchive sharedZip = null;
            if (unzipMode && cfg.UnzipShareZip && cfg.Parallelism == 1)
            {
                try { sharedZip = ZipFile.OpenRead(cfg.UnzipInputPath); }
                catch (Exception ex)
                {
                    Console.WriteLine("[WARN] 共有Zipを開けませんでした。安全モードに切替: " + ex.Message);
                    sharedZip = null;
                }
            }

            Parallel.ForEach(files, po, item =>
            {
                string destPath = Path.Combine(cfg.OutputDir, item.RelativePath);

                string inRel = item.RelativePath;
                string outRel = item.RelativePath;

                run.SetNowCopying(inRel, outRel);

                if (!unzipMode && TrySkipByHistory(cfg, run, inRel, outRel, item, item.LastWriteUtc))
                {
                    run.StepProgress(true, 0, cfg.ProgressDetail, cfg.ProgressTitle);
                    return;
                }

                for (int attempt = 0; attempt <= cfg.Retry; attempt++)
                {
                    bool skipExisting = false; // リトライ毎に初期化

                    try
                    {
                        if (!cfg.DryRun)
                        {
                            Directory.CreateDirectory(Path.GetDirectoryName(destPath));

                            if (!unzipMode && File.Exists(destPath))
                                skipExisting = AreFilesIdenticalWithDest(cfg, item, destPath);

                            if (!skipExisting)
                            {
                                if (!string.IsNullOrEmpty(item.ZipEntryName))
                                {
                                    // unzip(共有Zip優先)
                                    if (sharedZip != null)
                                    {
                                        var entry = GetEntryCI(sharedZip, item.ZipEntryName);
                                        if (entry == null) throw new FileNotFoundException("ZIP entry が見つかりません: " + item.ZipEntryName);
                                        using (var src = entry.Open())
                                        using (var dst = File.Create(destPath))
                                            CopyStreamWithProgress(src, dst, run, cfg, item.Length);
                                    }
                                    else
                                    {
                                        using (var zip = ZipFile.OpenRead(cfg.UnzipInputPath ?? item.ZipPath))
                                        {
                                            var entry = GetEntryCI(zip, item.ZipEntryName);
                                            if (entry == null) throw new FileNotFoundException("ZIP entry が見つかりません: " + item.ZipEntryName);
                                            using (var src = entry.Open())
                                            using (var dst = File.Create(destPath))
                                                CopyStreamWithProgress(src, dst, run, cfg, item.Length);
                                        }
                                    }
                                    try { File.SetLastWriteTime(destPath, item.LastWriteLocal); } catch { }
                                }
                                else
                                {
                                    // 通常コピー(進捗付き)
                                    using (var src = File.OpenRead(item.AbsolutePath))
                                    using (var dst = File.Create(destPath))
                                        CopyStreamWithProgress(src, dst, run, cfg, item.Length);

                                    // ← コピー後に“元の更新日時”を反映
                                    try { File.SetLastWriteTime(destPath, item.LastWriteLocal); } catch { }

                                    if (cfg.MoveFiles) File.Delete(item.AbsolutePath);
                                }

                                if (cfg.EnableCopyCheck)
                                {
                                    bool ok = VerifyAfterCopy(cfg, item, destPath, sharedZip);
                                    if (!ok) throw new IOException("コピー後ハッシュチェック失敗");
                                }
                            }

                            string h = ComputeHashForHistoryIfNeeded(cfg, item, sharedZip);
                            run.LogCsv(inRel, outRel, cfg.MoveFiles ? "Move" : (unzipMode ? "Unzip" : "Copy"),
                                       "Success", skipExisting, "", item.Length, item.LastWriteUtc, h);
                        }
                        else
                        {
                            // DryRun: 実コピーなし
                            run.LogCsv(inRel, outRel, cfg.MoveFiles ? "Move" : (unzipMode ? "Unzip" : "Copy"),
                                       "Success", false, "(dryrun)", item.Length, item.LastWriteUtc,
                                       ComputeHashForHistoryIfNeeded(cfg, item, sharedZip));
                        }

                        // 進捗は CopyStreamWithProgress 内で加算済み(スキップ時のみ+1)
                        if (skipExisting) run.StepProgress(true, 0, cfg.ProgressDetail, cfg.ProgressTitle);
                        return;
                    }
                    catch (Exception ex)
                    {
                        if (attempt == cfg.Retry)
                        {
                            run.AnyFailed = true;
                            run.LogCsv(inRel, outRel, cfg.MoveFiles ? "Move" : (unzipMode ? "Unzip" : "Copy"),
                                       "Failed", false, ex.Message, item.Length, item.LastWriteUtc, "");
                            bagError.Add(inRel);
                            Console.WriteLine("\n[FAIL] " + inRel + " : " + ex.Message);
                            run.StepProgress(true, 0, cfg.ProgressDetail, cfg.ProgressTitle);
                        }
                        else
                        {
                            if (cfg.PauseOnError) Thread.Sleep(1500);
                        }
                    }
                }
            });

            try { sharedZip?.Dispose(); } catch { }

            // --- フォルダの更新日時を反映 ---
            try
            {
                if (!zipMode)
                {
                    if (unzipMode)
                        ApplyDirectoryTimesFromFiles(cfg.OutputDir, files); // ZIP内のdirエントリが無い場合も、配下ファイルの最大時刻で推定
                    else
                        ApplyDirectoryTimestampsCopy(cfg.InputDir, cfg.OutputDir, dirs); // ソースのフォルダ時刻をそのまま反映
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine("\n[WARN] ディレクトリ時刻反映に失敗: " + ex.Message);
            }

            if (!string.IsNullOrWhiteSpace(cfg.ErrorListPath))
            {
                try
                {
                    File.WriteAllLines(cfg.ErrorListPath, bagError.ToArray(), Encoding.UTF8);
                    Console.WriteLine("\n[ERRLIST] " + cfg.ErrorListPath + " (" + bagError.Count + "件)");
                }
                catch (Exception ex) { Console.WriteLine("\n[ERRLIST] 書込失敗: " + ex.Message); }
            }

            Console.CursorVisible = true;
            Console.WriteLine();
            var opEndFinal = DateTime.Now;
            var elapsedFinal = opEndFinal - opStart;
            string statusFinal = run.AnyFailed ? "[DONE] エラーあり" : "[DONE] 全成功";
            Console.WriteLine($"{statusFinal}  完了:{opEndFinal:yyyy-MM-dd HH:mm:ss}  処理時間:{HMS(elapsedFinal)}");
            return run.AnyFailed ? 1 : 0;
        }

        public static int RunFileToFile(SyncConfig cfg, RunContext run, string srcFile, string dstFile)
        {
            DateTime opStart = DateTime.Now;

            try
            {
                long sz = new FileInfo(srcFile).Length;
                run.InitProgress(1, sz);

                run.SetNowCopying(Path.GetFileName(srcFile), Path.GetFileName(dstFile));

                if (cfg.DryRun)
                {
                    run.LogCsv(Path.GetFileName(srcFile), Path.GetFileName(dstFile), cfg.MoveFiles ? "Move" : "Copy", "Success", false, "(dryrun)", sz, File.GetLastWriteTimeUtc(srcFile), "");
                    var opEndDR = DateTime.Now;
                    Console.WriteLine($"\n[DRYRUN] 完了  完了:{opEndDR:yyyy-MM-dd HH:mm:ss}  処理時間:{HMS(opEndDR - opStart)}");
                    return 0;
                }

                Directory.CreateDirectory(Path.GetDirectoryName(dstFile));

                if (File.Exists(dstFile))
                {
                    if (AreFilesIdentical(cfg, srcFile, dstFile))
                    {
                        run.LogCsv(Path.GetFileName(srcFile), Path.GetFileName(dstFile), cfg.MoveFiles ? "Move" : "Copy", "Success", true, "Identical", sz, File.GetLastWriteTimeUtc(srcFile), "");
                        run.StepProgress(true, 0, cfg.ProgressDetail, cfg.ProgressTitle);
                        var opEndSk = DateTime.Now;
                        Console.WriteLine($"\n[SKIP] 同一  完了:{opEndSk:yyyy-MM-dd HH:mm:ss}  処理時間:{HMS(opEndSk - opStart)}");
                        return 0;
                    }
                }

                using (var src = File.OpenRead(srcFile))
                using (var dst = File.Create(dstFile))
                    CopyStreamWithProgress(src, dst, run, cfg, sz);

                // ファイルの “元の更新日時” を反映
                try { File.SetLastWriteTime(dstFile, File.GetLastWriteTime(srcFile)); } catch { }

                if (cfg.MoveFiles) File.Delete(srcFile);

                if (cfg.EnableCopyCheck && !AreFilesIdentical(cfg, srcFile, dstFile))
                    throw new IOException("コピー後ハッシュチェック失敗");

                run.LogCsv(Path.GetFileName(srcFile), Path.GetFileName(dstFile), cfg.MoveFiles ? "Move" : "Copy", "Success", false, "", sz, File.GetLastWriteTimeUtc(srcFile), "");
                var opEnd = DateTime.Now;
                Console.WriteLine($"\n[DONE] 成功  完了:{opEnd:yyyy-MM-dd HH:mm:ss}  処理時間:{HMS(opEnd - opStart)}");
                return 0;
            }
            catch (Exception ex)
            {
                run.AnyFailed = true;
                long sz = 0; try { sz = new FileInfo(srcFile).Length; } catch { }
                DateTime lw = DateTime.MinValue; try { lw = File.GetLastWriteTimeUtc(srcFile); } catch { }
                run.LogCsv(Path.GetFileName(srcFile), Path.GetFileName(dstFile), cfg.MoveFiles ? "Move" : "Copy", "Failed", false, ex.Message, sz, lw, "");
                Console.WriteLine("\n[ERR] " + ex.Message);
                var opEnd = DateTime.Now;
                Console.WriteLine($"  完了:{opEnd:yyyy-MM-dd HH:mm:ss}  処理時間:{HMS(opEnd - opStart)}");
                return 1;
            }
        }

        // ==== ZIP 出力(Create/Update安全・重複ポリシー・圧縮レベル・時刻反映) ====
        // ==== ZIP 出力(Create/Update安全・重複ポリシー・圧縮レベル・時刻反映) ====
        // Createモードでは GetEntry/Entries に触らないように修正
        static int RunToZip(List<FileItem> items, List<DirItem> dirItems, SyncConfig cfg, RunContext run, string prefix = "")
        {
            try
            {
                bool append = File.Exists(cfg.ZipOutputPath);

                using (var zip = ZipFile.Open(cfg.ZipOutputPath, append ? ZipArchiveMode.Update : ZipArchiveMode.Create))
                {
                    var known = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
                    if (zip.Mode == ZipArchiveMode.Update)
                    {
                        // Update のときだけ既存エントリを収集
                        foreach (var e in zip.Entries) known.Add(e.FullName);
                    }

                    var created = new Dictionary<string, ZipArchiveEntry>(StringComparer.OrdinalIgnoreCase);

                    // 圧縮レベル
                    CompressionLevel level = CompressionLevel.Optimal;
                    switch ((cfg.ZipCompression ?? "optimal").Trim().ToLowerInvariant())
                    {
                        case "fastest": level = CompressionLevel.Fastest; break;
                        case "none": level = CompressionLevel.NoCompression; break;
                        default: level = CompressionLevel.Optimal; break;
                    }

                    // 1) ディレクトリエントリ(Createモードでは GetEntry を呼ばない)
                    if (dirItems != null && dirItems.Count > 0)
                    {
                        foreach (var d in dirItems.OrderBy(x => x.RelativePath, StringComparer.OrdinalIgnoreCase))
                        {
                            string dirName = (prefix ?? "") + d.RelativePath.Replace(Path.DirectorySeparatorChar, '/') + "/";
                            if (known.Contains(dirName) || created.ContainsKey(dirName)) continue;

                            ZipArchiveEntry de = null;
                            if (zip.Mode == ZipArchiveMode.Update)
                            {
                                // Update のときのみ既存を探す
                                de = zip.GetEntry(dirName);
                            }
                            if (de == null)
                            {
                                de = zip.CreateEntry(dirName, CompressionLevel.NoCompression);
                            }

                            de.LastWriteTime = new DateTimeOffset(d.LastWriteLocal);
                            known.Add(dirName);
                            created[dirName] = de;
                        }
                    }

                    // 2) ファイルエントリ
                    foreach (var item in items)
                    {
                        run.SetNowCopying(item.RelativePath, item.RelativePath);

                        string entryName = (prefix ?? "") + item.RelativePath.Replace(Path.DirectorySeparatorChar, '/');
                        string finalName = entryName;

                        // 重複ポリシー
                        switch (cfg.ZipDuplicate)
                        {
                            case "overwrite":
                                if (zip.Mode == ZipArchiveMode.Update && known.Contains(finalName))
                                {
                                    var exist = zip.GetEntry(finalName)
                                             ?? zip.Entries.FirstOrDefault(e => string.Equals(e.FullName, finalName, StringComparison.OrdinalIgnoreCase));
                                    if (exist != null) { exist.Delete(); known.Remove(finalName); }
                                }
                                if (created.TryGetValue(finalName, out var prev))
                                {
                                    try { prev.Delete(); } catch { }
                                    created.Remove(finalName);
                                    known.Remove(finalName);
                                }
                                break;

                            case "skip":
                                if (known.Contains(finalName) || created.ContainsKey(finalName))
                                {
                                    run.LogCsv(item.RelativePath, finalName.Replace('/', Path.DirectorySeparatorChar),
                                               "ZipAdd", "Success", true, "dup-skip", item.Length, item.LastWriteUtc,
                                               ComputeHashForHistoryIfNeeded(cfg, item, null));
                                    run.StepProgress(true, 0, cfg.ProgressDetail, cfg.ProgressTitle);
                                    continue;
                                }
                                break;

                            case "seq":
                            default:
                                finalName = GenerateUniqueName(finalName, known, created);
                                break;
                        }

                        var entry = zip.CreateEntry(finalName, level);
                        entry.LastWriteTime = new DateTimeOffset(item.LastWriteLocal); // 元の更新日時を付与

                        using (var dst = entry.Open())
                        using (var src = File.OpenRead(item.AbsolutePath))
                        {
                            CopyStreamWithProgress(src, dst, run, cfg, item.Length);
                        }

                        known.Add(finalName);
                        created[finalName] = entry;

                        run.LogCsv(item.RelativePath, finalName.Replace('/', Path.DirectorySeparatorChar),
                                   "ZipAdd", "Success", false, "", item.Length, item.LastWriteUtc,
                                   ComputeHashForHistoryIfNeeded(cfg, item, null));
                    }
                }

                Console.WriteLine("\n[DONE] ZIP 出力完了");
                return 0;
            }
            catch (Exception ex)
            {
                run.AnyFailed = true;
                Console.WriteLine("\n[ERR] ZIP 出力失敗: " + ex.Message);
                return 1;
            }
        }

        static string GenerateUniqueName(string desired, HashSet<string> known, Dictionary<string, ZipArchiveEntry> created)
        {
            if (!known.Contains(desired) && !created.ContainsKey(desired)) return desired;

            int slash = desired.LastIndexOf('/');
            string dir = (slash >= 0) ? desired.Substring(0, slash + 1) : "";
            string name = (slash >= 0) ? desired.Substring(slash + 1) : desired;

            int dot = name.LastIndexOf('.');
            string baseName = (dot >= 0) ? name.Substring(0, dot) : name;
            string ext = (dot >= 0) ? name.Substring(dot) : "";

            for (int i = 1; i < 10000; i++)
            {
                string cand = dir + baseName + " (" + i + ")" + ext;
                if (!known.Contains(cand) && !created.ContainsKey(cand)) return cand;
            }
            return dir + baseName + " (" + DateTime.Now.ToString("yyyyMMddHHmmssfff") + ")" + ext;
        }

        // ====== 列挙 ======
        class FileItem
        {
            public string AbsolutePath;
            public string RelativePath;
            public long Length;
            public DateTime LastWriteLocal;
            public DateTime LastWriteUtc;

            // unzip 用
            public string ZipPath;      // zip ファイルパス
            public string ZipEntryName; // ZIP 内パス(/ 区切り)
        }
        class DirItem
        {
            public string RelativePath;
            public DateTime LastWriteLocal;
        }

        static List<FileItem> EnumerateFromDir(string root, bool recursive, SyncConfig cfg, out List<DirItem> dirItems)
        {
            var items = new List<FileItem>();
            dirItems = new List<DirItem>();

            // ディレクトリ(時刻反映用)
            var dopt = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
            foreach (var d in Directory.GetDirectories(root, "*", dopt))
            {
                var di = new DirectoryInfo(d);
                var rel = PathUtil.GetRelativePath(root, d);
                dirItems.Add(new DirItem { RelativePath = rel, LastWriteLocal = di.LastWriteTime });
            }

            // ファイル
            var fopt = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
            var files = Directory.GetFiles(root, "*", fopt);
            for (int i = 0; i < files.Length; i++)
            {
                var path = files[i];
                if (IsExcludedFolder(cfg, path)) continue;

                var fi = new FileInfo(path);
                var rel = PathUtil.GetRelativePath(root, path);
                items.Add(new FileItem
                {
                    AbsolutePath = path,
                    RelativePath = rel,
                    Length = fi.Length,
                    LastWriteLocal = fi.LastWriteTime,
                    LastWriteUtc = fi.LastWriteTimeUtc
                });
            }
            return items;
        }

        static List<FileItem> EnumerateFromZip(string zipPath, SyncConfig cfg, out List<DirItem> dirItems)
        {
            var items = new List<FileItem>();
            var dirMap = new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase); // 相対dir → 最終時刻
            using (var zip = ZipFile.OpenRead(zipPath))
            {
                foreach (var e in zip.Entries)
                {
                    if (string.IsNullOrEmpty(e.Name))
                    {
                        // ディレクトリエントリ(ZIP に入っていればその時刻を採用)
                        string relDir = e.FullName.TrimEnd('/').Replace('/', Path.DirectorySeparatorChar);
                        if (relDir.Length > 0)
                            dirMap[relDir] = e.LastWriteTime.DateTime;
                        continue;
                    }

                    var local = e.LastWriteTime.DateTime;
                    var utc = DateTime.SpecifyKind(local, DateTimeKind.Local).ToUniversalTime();

                    string relFile = e.FullName.Replace('/', Path.DirectorySeparatorChar);
                    items.Add(new FileItem
                    {
                        AbsolutePath = null,
                        RelativePath = relFile,
                        Length = e.Length,
                        LastWriteLocal = local,
                        LastWriteUtc = utc,
                        ZipPath = zipPath,
                        ZipEntryName = e.FullName
                    });

                    // 親ディレクトリの推定時刻(エントリが無ければ配下ファイルの最大時刻)
                    string dir = Path.GetDirectoryName(relFile) ?? "";
                    while (!string.IsNullOrEmpty(dir))
                    {
                        DateTime cur;
                        if (!dirMap.TryGetValue(dir, out cur) || cur < local) dirMap[dir] = local;
                        var parent = Path.GetDirectoryName(dir) ?? "";
                        if (string.Equals(parent, dir, StringComparison.OrdinalIgnoreCase)) break;
                        dir = parent;
                    }
                }
            }

            dirItems = dirMap.Select(kv => new DirItem { RelativePath = kv.Key, LastWriteLocal = kv.Value }).ToList();
            return items;
        }

        // ====== フィルタ・スキップ ======
        static bool FilterAllow(SyncConfig cfg, FileItem x)
        {
            if (cfg.ModifiedAfter.HasValue && x.LastWriteLocal <= cfg.ModifiedAfter.Value) return false;
            if (cfg.ModifiedBefore.HasValue && x.LastWriteLocal >= cfg.ModifiedBefore.Value) return false;

            string ext = Path.GetExtension(x.RelativePath).ToLowerInvariant();
            if (cfg.FilterExt.Count > 0 && !cfg.FilterExt.Contains(ext)) return false;
            if (cfg.ExcludeExt.Contains(ext)) return false;

            if (IsExcludedFolder(cfg, cfg.InputDir != null ? Path.Combine(cfg.InputDir, x.RelativePath) : x.RelativePath, fromRelative: cfg.InputDir == null))
                return false;

            var name = Path.GetFileName(x.RelativePath);
            if (cfg.NameMatch.Count > 0)
            {
                bool ok = false;
                foreach (var p in cfg.NameMatch) if (WildcardIsMatch(name, p)) { ok = true; break; }
                if (!ok) return false;
            }
            if (cfg.NameRegex.Count > 0)
            {
                bool ok = false;
                foreach (var rx in cfg.NameRegex) if (rx.IsMatch(name)) { ok = true; break; }
                if (!ok) return false;
            }
            return true;
        }

        static bool IsExcludedFolder(SyncConfig cfg, string filePath, bool fromRelative = false)
        {
            if (cfg.ExcludeFolders.Count == 0) return false;

            string[] parts;
            if (fromRelative)
            {
                var dir = Path.GetDirectoryName(filePath) ?? "";
                parts = dir.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries);
            }
            else
            {
                if (string.IsNullOrEmpty(cfg.InputDir)) return false;
                var dir = Path.GetDirectoryName(filePath) ?? "";
                var rel = PathUtil.GetRelativePath(cfg.InputDir, dir);
                parts = rel.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries);
            }

            foreach (var d in parts)
                if (cfg.ExcludeFolders.Contains(d, StringComparer.OrdinalIgnoreCase)) return true;
            return false;
        }

        static bool WildcardIsMatch(string text, string pattern)
        {
            string rx = "^" + Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", ".") + "$";
            return Regex.IsMatch(text, rx, RegexOptions.IgnoreCase);
        }

        static bool TrySkipByHistory(SyncConfig cfg, RunContext run, string inRel, string outRel, FileItem item, DateTime lastWriteUtc)
        {
            HistoryEntry hist;
            if (!run.History.TryGetValue(inRel + "|" + outRel, out hist)) return false;
            if (hist.Size != item.Length) return false;
            if (hist.LastWriteUtc != lastWriteUtc) return false;

            if (!string.IsNullOrEmpty(hist.Hash))
            {
                string currentHash = ComputeHashForHistoryIfNeeded(cfg, item, null);
                if (!string.Equals(hist.Hash, currentHash, StringComparison.OrdinalIgnoreCase)) return false;
            }

            run.LogCsv(inRel, outRel, cfg.MoveFiles ? "Move" : "Copy", "SkippedByHistory", true, "", item.Length, lastWriteUtc, hist.Hash ?? "");
            return true;
        }

        static void DeleteSyncFiles(SyncConfig cfg, RunContext run, List<FileItem> inputs)
        {
            try
            {
                var expect = new HashSet<string>(inputs.Select(x => x.RelativePath), StringComparer.OrdinalIgnoreCase);
                var opt = cfg.Recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
                string[] outFiles = Directory.Exists(cfg.OutputDir) ? Directory.GetFiles(cfg.OutputDir, "*", opt) : new string[0];

                foreach (var of in outFiles)
                {
                    var rel = PathUtil.GetRelativePath(cfg.OutputDir, of);
                    if (!expect.Contains(rel))
                    {
                        if (!cfg.DryRun) File.Delete(of);
                        run.LogCsv(rel, rel, "Delete", "Success", false, "syncdelete", 0, DateTime.MinValue, "");
                    }
                }
            }
            catch (Exception ex)
            {
                run.AnyFailed = true;
                Console.WriteLine("[SYNCDELETE] 失敗: " + ex.Message);
            }
        }

        // ===== ディレクトリ時刻反映 =====

        // ディレクトリ→ディレクトリコピー時:ソース時刻をそのまま宛先へ
        static void ApplyDirectoryTimestampsCopy(string srcRoot, string dstRoot, List<DirItem> srcDirs)
        {
            if (srcDirs == null) return;
            foreach (var d in srcDirs.OrderByDescending(x => x.RelativePath.Length)) // 深い順に反映(子→親)
            {
                string dstDir = Path.Combine(dstRoot, d.RelativePath);
                try
                {
                    if (Directory.Exists(dstDir))
                        Directory.SetLastWriteTime(dstDir, d.LastWriteLocal);
                }
                catch { }
            }
            // ルート自身
            try
            {
                if (Directory.Exists(dstRoot))
                    Directory.SetLastWriteTime(dstRoot, new DirectoryInfo(srcRoot).LastWriteTime);
            }
            catch { }
        }

        // 解凍時:ZIPにdirエントリがない場合も、配下ファイルの最大時刻で推定して反映
        static void ApplyDirectoryTimesFromFiles(string dstRoot, List<FileItem> files)
        {
            var map = new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
            foreach (var f in files)
            {
                string dir = Path.GetDirectoryName(Path.Combine(dstRoot, f.RelativePath)) ?? dstRoot;
                while (!string.IsNullOrEmpty(dir) && dir.StartsWith(dstRoot, StringComparison.OrdinalIgnoreCase))
                {
                    DateTime cur;
                    if (!map.TryGetValue(dir, out cur) || cur < f.LastWriteLocal) map[dir] = f.LastWriteLocal;
                    var parent = Path.GetDirectoryName(dir);
                    if (string.IsNullOrEmpty(parent) || string.Equals(parent, dir, StringComparison.OrdinalIgnoreCase)) break;
                    dir = parent;
                }
            }
            // 深い順で反映
            foreach (var kv in map.OrderByDescending(k => k.Key.Length))
            {
                try { if (Directory.Exists(kv.Key)) Directory.SetLastWriteTime(kv.Key, kv.Value); } catch { }
            }
        }

        // ===== 同一性判定 =====

        static bool AreFilesIdentical(SyncConfig cfg, string src, string dst)
        {
            var sfi = new FileInfo(src);
            var dfi = new FileInfo(dst);
            if (!sfi.Exists || !dfi.Exists) return false;

            if (sfi.Length != dfi.Length) return false;
            if (sfi.LastWriteTime != dfi.LastWriteTime) return false;

            if (cfg.HashMode == "none") return true;

            if (cfg.HashMode == "full" || sfi.Length <= cfg.PartialBytes * 3)
            {
                byte[] h1 = HashAll(src);
                byte[] h2 = HashAll(dst);
                return StructuralComparisons.StructuralEqualityComparer.Equals(h1, h2);
            }
            else
            {
                byte[] h1 = HashPartial(src, cfg.PartialBytes);
                byte[] h2 = HashPartial(dst, cfg.PartialBytes);
                return StructuralComparisons.StructuralEqualityComparer.Equals(h1, h2);
            }
        }

        static bool AreFilesIdenticalWithDest(SyncConfig cfg, FileItem src, string destPath)
        {
            var fi2 = new FileInfo(destPath);
            if (src.Length != fi2.Length) return false;
            if (src.LastWriteLocal != fi2.LastWriteTime) return false;

            if (cfg.HashMode == "none") return true;

            if (cfg.HashMode == "full" || src.Length <= cfg.PartialBytes * 3)
            {
                byte[] h1 = (!string.IsNullOrEmpty(src.ZipEntryName))
                    ? HashAllFromZipPath(cfg.UnzipInputPath ?? src.ZipPath, src.ZipEntryName)
                    : HashAll(src.AbsolutePath);
                byte[] h2 = HashAll(destPath);
                return StructuralComparisons.StructuralEqualityComparer.Equals(h1, h2);
            }
            else
            {
                byte[] h1 = (!string.IsNullOrEmpty(src.ZipEntryName))
                    ? HashPartialFromZipPath(cfg.UnzipInputPath ?? src.ZipPath, src.ZipEntryName, cfg.PartialBytes)
                    : HashPartial(src.AbsolutePath, cfg.PartialBytes);
                byte[] h2 = HashPartial(destPath, cfg.PartialBytes);
                return StructuralComparisons.StructuralEqualityComparer.Equals(h1, h2);
            }
        }

        // ZIP の root レイアウト名
        static string GetZipRootFolderName(string inputDir)
        {
            try
            {
                var full = Path.GetFullPath(inputDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
                var name = new DirectoryInfo(full).Name;
                if (string.IsNullOrEmpty(name) || name.EndsWith(":"))
                {
                    string root = Path.GetPathRoot(full).TrimEnd('\\', '/');
                    name = root.Replace(":", "") + "_root";
                }
                name = name.Replace('/', '_').Replace('\\', '_');
                return name;
            }
            catch { return "root"; }
        }

        // ===== 進捗付きコピー =====
        static void CopyStreamWithProgress(Stream src, Stream dst, RunContext run, SyncConfig cfg, long totalBytes)
        {
            const int BUF = 1024 * 1024; // 1MB
            byte[] buffer = new byte[BUF];
            int r;
            while ((r = src.Read(buffer, 0, buffer.Length)) > 0)
            {
                dst.Write(buffer, 0, r);
                run.StepProgress(false, r, cfg.ProgressDetail, cfg.ProgressTitle);
            }
            run.StepProgress(true, 0, cfg.ProgressDetail, cfg.ProgressTitle);
        }

        // ===== コピー後検証・履歴ハッシュ =====

        static bool VerifyAfterCopy(SyncConfig cfg, FileItem src, string destPath, ZipArchive sharedZip)
        {
            if (cfg.HashMode == "none") return true;

            if (cfg.HashMode == "full" || src.Length <= cfg.PartialBytes * 3)
            {
                byte[] h1;
                if (!string.IsNullOrEmpty(src.ZipEntryName))
                {
                    if (sharedZip != null) h1 = HashAll(sharedZip, src.ZipEntryName);
                    else h1 = HashAllFromZipPath(cfg.UnzipInputPath ?? src.ZipPath, src.ZipEntryName);
                }
                else h1 = HashAll(src.AbsolutePath);

                byte[] h2 = HashAll(destPath);
                return StructuralComparisons.StructuralEqualityComparer.Equals(h1, h2);
            }
            else
            {
                byte[] h1;
                if (!string.IsNullOrEmpty(src.ZipEntryName))
                {
                    if (sharedZip != null) h1 = HashPartial(sharedZip, src.ZipEntryName, cfg.PartialBytes);
                    else h1 = HashPartialFromZipPath(cfg.UnzipInputPath ?? src.ZipPath, src.ZipEntryName, cfg.PartialBytes);
                }
                else h1 = HashPartial(src.AbsolutePath, cfg.PartialBytes);

                byte[] h2 = HashPartial(destPath, cfg.PartialBytes);
                return StructuralComparisons.StructuralEqualityComparer.Equals(h1, h2);
            }
        }

        static string ComputeHashForHistoryIfNeeded(SyncConfig cfg, FileItem item, ZipArchive sharedZip)
        {
            if (cfg.HashMode == "none") return "";
            byte[] h;
            bool full = (cfg.HashMode == "full") || (item.Length <= cfg.PartialBytes * 3);
            if (full)
            {
                if (!string.IsNullOrEmpty(item.ZipEntryName))
                {
                    if (sharedZip != null) h = HashAll(sharedZip, item.ZipEntryName);
                    else h = HashAllFromZipPath(cfg.UnzipInputPath ?? item.ZipPath, item.ZipEntryName);
                }
                else h = HashAll(item.AbsolutePath);
            }
            else
            {
                if (!string.IsNullOrEmpty(item.ZipEntryName))
                {
                    if (sharedZip != null) h = HashPartial(sharedZip, item.ZipEntryName, cfg.PartialBytes);
                    else h = HashPartialFromZipPath(cfg.UnzipInputPath ?? item.ZipPath, item.ZipEntryName, cfg.PartialBytes);
                }
                else h = HashPartial(item.AbsolutePath, cfg.PartialBytes);
            }
            string hex = BitConverter.ToString(h).Replace("-", "");
            string tag = full ? "full" : ("partial" + HumanSize(cfg.PartialBytes));
            return tag + ":" + hex;
        }

        // ==== ハッシュ系 ====
        static string HumanSize(long b)
        {
            if (b % (1024L * 1024 * 1024) == 0) return (b / (1024L * 1024 * 1024)) + "gb";
            if (b % (1024L * 1024) == 0) return (b / (1024L * 1024)) + "mb";
            if (b % 1024L == 0) return (b / 1024L) + "kb";
            return b + "b";
        }

        static byte[] HashAll(string path)
        {
            using (var sha = SHA256.Create())
            using (var s = File.OpenRead(path))
                return sha.ComputeHash(s);
        }
        static byte[] HashAll(ZipArchiveEntry e)
        {
            using (var sha = SHA256.Create())
            using (var s = e.Open())
                return sha.ComputeHash(s);
        }
        static byte[] HashAll(ZipArchive zip, string entryName)
        {
            var e = GetEntryCI(zip, entryName);
            if (e == null) throw new FileNotFoundException("ZIP entry が見つかりません: " + entryName);
            return HashAll(e);
        }
        static byte[] HashAllFromZipPath(string zipPath, string entryName)
        {
            using (var zip = ZipFile.OpenRead(zipPath))
                return HashAll(zip, entryName);
        }

        static byte[] HashPartial(string path, int n)
        {
            using (var sha = SHA256.Create())
            using (var stream = File.OpenRead(path))
                return HashPartialInner(stream, sha, n);
        }
        static byte[] HashPartial(ZipArchiveEntry e, int n)
        {
            using (var sha = SHA256.Create())
            using (var stream = e.Open())
                return HashPartialInner(stream, sha, n);
        }
        static byte[] HashPartial(ZipArchive zip, string entryName, int n)
        {
            var e = GetEntryCI(zip, entryName);
            if (e == null) throw new FileNotFoundException("ZIP entry が見つかりません: " + entryName);
            return HashPartial(e, n);
        }
        static byte[] HashPartialFromZipPath(string zipPath, string entryName, int n)
        {
            using (var zip = ZipFile.OpenRead(zipPath))
                return HashPartial(zip, entryName, n);
        }

        static byte[] HashPartialInner(Stream stream, HashAlgorithm sha, int n)
        {
            long len = stream.CanSeek ? stream.Length : -1;
            if (len >= 0 && len <= n * 3)
            {
                using (var sha2 = SHA256.Create())
                {
                    if (stream.CanSeek) stream.Seek(0, SeekOrigin.Begin);
                    return sha2.ComputeHash(stream);
                }
            }

            byte[] buf = new byte[n * 3];
            int off = 0;

            SeekSafe(stream, 0);
            off += stream.Read(buf, off, n);

            if (len > n * 2) { SeekSafe(stream, len / 2); off += stream.Read(buf, off, n); }
            if (len > n) { SeekSafe(stream, len - n); off += stream.Read(buf, off, n); }

            return sha.ComputeHash(buf, 0, off);
        }
        static void SeekSafe(Stream s, long pos)
        {
            if (s.CanSeek) { s.Seek(pos, SeekOrigin.Begin); return; }
            const int chunk = 64 * 1024;
            long remain = pos;
            byte[] dump = new byte[chunk];
            while (remain > 0)
            {
                int want = (int)Math.Min(chunk, remain);
                int r = s.Read(dump, 0, want);
                if (r <= 0) break;
                remain -= r;
            }
        }

        static ZipArchiveEntry GetEntryCI(ZipArchive zip, string fullName)
        {
            var e = zip.GetEntry(fullName);
            if (e != null) return e;
            return zip.Entries.FirstOrDefault(x => string.Equals(x.FullName, fullName, StringComparison.OrdinalIgnoreCase));
        }

        // ---- ヘルパー:HH:MM:SS ----
        static string HMS(TimeSpan ts)
        {
            return string.Format("{0:D2}:{1:D2}:{2:D2}", (int)ts.TotalHours, ts.Minutes, ts.Seconds);
        }
    }
}

・説明書はこちら

Readme.md # FileSync 実行ガイド(Windows / .NET Framework 4.8)

このドキュメントは FileSync.exe の使い方を、サンプルと共に 1 つの Markdown にまとめたものです。
すべてのオプションは ---どちらでも指定可能(例:--input / -input)。


目次


概要

  • FileSync.exe は、フォルダ/ファイルのコピー・移動・差分同期・ZIP 作成/解凍を行う CLI ツールです。
  • 速度・残り時間・完了サイズなどの 詳細進捗を出しつつ、同一行更新表示にも対応。
  • 更新日時とサイズでの判定 → 必要時のみハッシュ の順に比較して高速化。
  • 元の更新日時を保持(コピー/解凍/ZIP 生成時に反映)。
  • UTF-8 CSV ログにより、次回実行で履歴スキップが可能。

主な機能

  • フォルダ/ファイルのコピー・移動(再帰、並列、リトライ)
  • フィルタ(拡張子 include/exclude、ファイル名ワイルドカード/正規表現、更新日時)
  • 差分同期(出力側にだけ残っているファイルを削除)
  • ZIP 作成(圧縮レベル、重複ポリシー、ZIP 内レイアウト)
  • ZIP 解凍(共有 ZipArchive 最適化・進捗表示)
  • ハッシュ比較(なし/部分/全体、部分は先頭・中間・末尾の N バイトずつ)
  • 履歴ログ(UTF-8、最大サイズ、履歴によるスキップ)
  • 進捗(速度は MB/s・1 桁小数、完了サイズの小数桁は自動調整、右端に現在処理ファイル/マッピング)

クイックスタート

FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --progress-detail

代表的な使用例

A) フォルダ → フォルダ(コピー)

  • 再帰なし
FileSync.exe --input="D:\in" --output="E:\out"
  • サブフォルダ含む
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory

B) フォルダ → フォルダ(移動)

FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --move

C) 差分同期

FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --sync-delete

D) フィルタ(拡張子・名前・更新日時)

  • 拡張子を 通す(include)
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --filter-ext=.jpg,.png,.mp4
  • 拡張子を 除外
FileSync.exe --input="D:\in" --output="E:\out" --exclude-ext=.tmp,.db
  • ファイル名ワイルドカード
FileSync.exe --input="D:\in" --output="E:\out" --name-match="IMG_*.JPG" --name-match="*.CR3"
  • 正規表現
FileSync.exe --input="D:\in" --output="E:\out" --name-regex="^ZFC_\d+\.JPG$"
  • フォルダ除外(相対フォルダ名)
FileSync.exe --input="D:\in" --output="E:\out" --exclude-folder=cache --exclude-folder=_tmp
  • 更新日時で絞る(YYYY-MM-DD
FileSync.exe --input="D:\in" --output="E:\out" --modified-after="2025-01-01" --modified-before="2025-09-01"

E) 並列・リトライ・コピー後検証

FileSync.exe --input="D:\in" --output="E:\out" --subdirectory ^
  --parallel=4 --retry=2 --copy-check

F) 進捗表示(詳細・同一行)

  • 詳細
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --progress-detail
  • 同一行
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --progress-detail --progress-singleline
  • 強制同一行(リダイレクト環境でも)
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --progress-detail --progress-singleline --progress-force-inline

G) 履歴ログ(UTF-8 CSV)

  • ログ出力(履歴活用)
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --log="D:\logs\copy_history.csv"
  • ログ最大サイズ(超過時はファイルごとに最新のみ保持)
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --log="D:\logs\copy_history.csv" --log-max-size=50mb
  • エラーファイル一覧
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --error-list="D:\logs\error_files.txt"

H) ZIP 作成

  • 新規/追記(既存 ZIP があれば Update)
FileSync.exe --input="D:\in" --zip-output="D:\out\backup.zip" --subdirectory
  • 圧縮レベル:optimal | fastest | none
FileSync.exe --input="D:\in" --zip-output="D:\out\backup.zip" --zip-compression=fastest
  • ZIP 内レイアウト

    • flat(既定):D:\in\A\B.txt → A/B.txt
    • root:入力フォルダ名を ZIP 先頭に付与(in/A/B.txt
FileSync.exe --input="D:\in" --zip-output="D:\out\backup.zip" --subdirectory --zip-layout=root
  • 重複ポリシー:overwrite | skip | seq
FileSync.exe --input="D:\in" --zip-output="D:\out\backup.zip" --zip-duplicate=overwrite

I) ZIP 解凍

FileSync.exe --unzip-input="D:\in\data.zip" --output="D:\out" --progress-detail
  • 共有 ZipArchive を使って高速化(並列 1 指定)
FileSync.exe --unzip-input="D:\in\data.zip" --output="D:\out" --parallel=1 --unzip-share-zip --progress-detail --progress-singleline

J) ハッシュ判定

  • フルハッシュ
FileSync.exe --input="D:\in" --output="E:\out" --hash-mode=full
  • 部分ハッシュ(先頭/中間/末尾の N バイト)
FileSync.exe --input="D:\in" --output="E:\out" --hash-mode=partial --partial-bytes=256kb
  • ハッシュ無し(サイズ・更新日時のみ)
FileSync.exe --input="D:\in" --output="E:\out" --hash-mode=none

--partial-bytes の単位は b|kb|mb|gb(例:1024 / 1024b / 100kb / 1gb)。未指定はバイト。

K) オールインワン例

FileSync.exe --input="D:\photo" --output="E:\backup\photo" --subdirectory --move ^
  --filter-ext=.jpg,.cr3 --name-match="IMG_*.JPG" ^
  --parallel=4 --retry=2 --copy-check ^
  --hash-mode=partial --partial-bytes=128kb ^
  --log="E:\logs\photo_copy.csv" --log-max-size=100mb ^
  --progress-detail --progress-singleline

TOML 設定での実行(CLI が優先)

最小例(config.toml

# 入出力
input = "D:/in"
output = "E:/out"
subdirectory = true

# 進捗
progress_detail = true
progress_singleline = true

# ハッシュ
hash_mode = "partial"
partial_bytes = "256kb"

# ログ
log = "D:/logs/history.csv"
log_max_size = "50mb"

実行(TOML + CLI で一部上書き)

FileSync.exe --config="D:\cfg\config.toml" --parallel=8 --zip-output="E:\out\backup.zip"

ルール:TOML を読み込んだ後に CLI で上書き(CLI が優先)。


オプション一覧(統一名称)

入出力

  • --input=<dir> / --output=<dir>
  • --subdirectory(サブフォルダも処理)
  • --move(コピー後に元を削除)

差分同期

  • --sync-delete(出力にだけ残ったファイルを削除)

進捗

  • --progress-detail
  • --progress-singleline(同一行)
  • --progress-force-inline(リダイレクト環境でも強制)

並列・信頼性

  • --parallel=<N>
  • --retry=<N>
  • --copy-check(コピー後ハッシュ検証)
  • --pause-on-error(失敗時に小休止)
  • --dry-run(試走)

フィルタ

  • --filter-ext=.jpg,.png(include)
  • --exclude-ext=.tmp(exclude)
  • --name-match="*.JPG"(ワイルドカード・複数可)
  • --name-regex="^IMG_\d+\.JPG$"(正規表現・複数可)
  • --exclude-folder=cache(相対フォルダ名・複数可)
  • --modified-after="YYYY-MM-DD" / --modified-before="YYYY-MM-DD"

ハッシュ

  • --hash-mode=full|partial|none
  • --partial-bytes=256kbb|kb|mb|gb 対応)

ZIP

  • --zip-output="path.zip"
  • --zip-compression=optimal|fastest|none
  • --zip-layout=flat|root
  • --zip-duplicate=overwrite|skip|seq

UNZIP

  • --unzip-input="path.zip"
  • --unzip-share-zip(並列 1 と併用)

ログ・エラー

  • --log="path.csv"(UTF-8)
  • --log-max-size=50mb
  • --error-list="errors.txt"

設定ファイル

  • --config="config.toml"

進捗表示の仕様

  • 表示例(単一行/詳細):

    進捗: 21/ 21  速度:    34.5 MB/s  残り:00:00:00  完了:    2.939 GB/    2.939 GB  <右側: ファイル・src→dst・マッピング>
    
  • 速度は常に MB/s(小数 1 桁)

  • 完了サイズの小数桁は、右側の表示を優先して 3→2→1→0 と自動調整。

  • 右側(ファイル名・src → dst[input → output])は 中央省略 ... を優先。

  • --progress-singleline同一行上書き--progress-force-inline強制

  • 最終行は

    [DONE] 全成功  完了:YYYY-MM-DD HH:MM:SS  処理時間:HH:MM:SS
    

    の形式で終了時刻・処理時間を表示(エラー時は「エラーあり」)。


ZIP/UNZIP の仕様

  • ZIP 作成:Create/Update に対応。Create モードでは .Entries/GetEntry に触れない安全実装。

  • 圧縮レベルoptimal / fastest / none

  • ZIP 内レイアウト

    • flat(既定):入力ディレクトリの相対をそのまま
    • root:ZIP 先頭に入力フォルダ名を付与
  • 重複ポリシーoverwrite(置換) / skip(飛ばす) / seq(連番付与)。

  • 更新日時の保持:ファイル/ディレクトリエントリともに 元の更新日時をエントリに付与。

  • 解凍:ファイルの更新日時を復元。ディレクトリは ZIP に dir エントリが無い場合でも、配下ファイルの最大時刻で復元。

  • 共有Zip最適化--unzip-share-zip--parallel=1同一 ZipArchive を共有し高速化。


ハッシュ判定の仕様

  1. まず サイズ更新日時で判定。
  2. 必要時のみ ハッシュへ(none / partial / full)。
  3. partial先頭・中間・末尾N バイトずつを読み、合算ハッシュ。
  4. 小さいファイル3N 以下)は自動で フルハッシュに切替。
  5. 履歴ログにハッシュがあれば、次回はそれも活用してスキップ。

履歴・ログの仕様

  • UTF-8 CSV--log=<path> で出力・読み込みを兼用。
  • --log-max-size を超えた場合、同一ファイル単位で最新のみ残すローテーション。
  • 履歴キーは 入力相対パス + 出力相対パス
  • まず 更新日時とサイズで照合し、必要なら ハッシュで最終確認してスキップ。

終了表示・終了コード

  • 終了表示:[DONE] 全成功 または [DONE] エラーあり(完了日時・処理時間付き)

  • 終了コード:

    • 0 … 成功
    • 1 … 一部エラー
    • 2 … パラメータ不備など開始前エラー

よくある質問(FAQ)

  • Q. 速度表示が 0 になる?
    A. 極小ファイル連続時や I/O バーストで瞬間的に 0 に見えることがあります。処理自体に問題はありません。
  • Q. 単位指定は?
    A. --partial-bytes--log-max-sizeb|kb|mb|gb をサフィックスで指定可(例:256kb1gb)。未指定はバイト。
  • Q. 更新日時が変わってしまう?
    A. コピー/解凍/ZIP 生成の各所で 元の更新日時を復元する実装にしています。
  • Q. Create モードの ZIP で例外が出た
    A. 修正済み。Create では .Entries/GetEntry に触れず作成します。

注意事項

  • Windows 向け。.NET Framework 4.8 を対象。
  • パスは クォート推奨(スペース・記号対策)。
  • 大量ファイル時はログも大きくなりがちです。--log-max-size の設定を推奨。
  • 解凍の高速化は --parallel=1 --unzip-share-zip の併用が効果的。
  • --progress-force-inline は TTY 判定を無視して「同一行上書き」を強制します(ログに流す用途では非推奨)。

以上

備考

・基本的には、xcopyやrobocopyなどで十分・・・?

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?