8
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

C#のみを使って、変更前後の差分表示のできる、ファイル名変更ソフトの作り方

Last updated at Posted at 2020-07-21

概要

2020年にもなってファイル名変更ソフトを新しく作りました。名前はFileRenamerDiffです。
この記事では、ソフトウェアの記述解説をします。

ソフトウェアの内容やスクリーンショットは前回の記事を参照ください。

screenshot_ja.png

ざっくりいうと、WPF on .NET Core by C#です。
MVVMライブラリはLivetとReactivePropertyを使用しています。
ソースコード全体はGitHubにあります。

使用している主なライブラリ

ライブラリ名 使用目的
ReactiveProperty MVVMライブラリとして、主にViewModelで使用
Livet MVVMライブラリとして、ViewとViewModelの連携やINotifyPropertyChanged実装基底クラスなど
DiffPlex 差分表示、ViewModelで使用。ViewではDataGrid内のRichTextBoxにBindingされます。
MahApps Viewのデザインライブラリ
MaterialDesignThemes Viewのデザインライブラリ
Markdig Viewのライセンス表記などのMarkdownの表示
Utf8Json JSON設定ファイルの読込/保存
Serilog ロギング

全体構成

MVVMパターンですので、Model-ViewModel-Viewの3層に分割して、ViewとViewModelの間はBindingによってつながっています。

メインとなるクラスだけ抜き出したクラス図は以下のようになっています。PlantUMLで書きました。

FileRenamerDiff.png

各層ごとに解説します。

Model

Model層ではファイルの探索、正規表現を使ったリネーム、リネーム保存、設定ファイル読み書きといったことを行っています。

ファイル探索

指定されたパス以下のファイルパスを取得します。このとき設定によって、隠しファイルやフォルダ・(狭義の)ファイルの両方か片方だけリストにするか切り替えます。
探索されたファイルの情報をFileElementModelにまとめて、そこにリネーム後のファイル名もあとで加えます。

リネーム処理(プレビュー)

設定はSettingAppModelにまとめてあり、このクラス全体をJSONにシリアライズして設定ファイルとして保存しています。設定には探索対象ディレクトリや複数の削除パターン・置換パターンが含まれています。

リネームの実行は.NETのRegexに置換後の文字列を加えたReplaceRegexクラスで行います。複数のファイル名に対して行うのを想定しているので、RegexOptionCompiledにしておきます。
ソフトウェアの設定では削除パターンと置換パターンで2種類がありますが、Regexを生成する際には、この削除パターンは全て|で結合した1つのRegexにし、置換後の文字列は""にしておきます。

例)"a"、"b"、"c"、を削除
Regex.Replace("a1b2c3", "(a|b|c)", "") //結果"123"

重複判定

リネーム処理後に同じファイルパスが含まれていた場合、ファイルパスが重複しているとみなし、リネーム保存を禁止しています。なお、Windowsは大文字・小文字は同じとみなします。
リネーム後のファイルパスだけでなく、リネーム前のファイルパスとも重複していないかを判定しています。これは保存の順番によっては失敗する場合があるためです。
例)"A.txt", "B.txt"というファイル名が同じディレクトリにあり、"A"を"b"に、"B"を"c"に置き換えるリネームをした場合、先に"A.txt"→"b.txt"が保存されると既存の"B.txt"と重複して失敗してしまいます。

リネーム保存

リネーム保存時は深い階層のファイルから保存します。フォルダもリネームする場合、保存前のファイルが移動したことになり失敗してしまいます。
例)"/Main/Sub/"という階層のフォルダがあり、"/Main"と"/Sub"をリネームする場合、"/Main"を先にリネーム保存してしまうと、"/Main/Sub"が見つからなくなります。

また、リネーム処理自体もファイルかフォルダかで処理が微妙に異なります。これは過去記事を参照ください。
C#で確実にファイル名を変更する

ReactiveProperty(Model)

シリアライズされるSettingAppModelクラス以外はViewModelから参照される可変プロパティはだいたいReactivePropertyにしてあります。
これにより、Modelでの変更がRxなストリームとしてViewModel・Viewに伝わります。

ViewModel

基本的にはModelの上記機能を呼び出すためのReactiveCommandとModelの状態を反映するReactivePropertyがメインです。
ViewModelはなるべく薄く作るべきなのですが、差分表示(DiffPlex)やDataGrid用のCollectionViewなどで、ふくらんでいます。

ReactiveProperty(ViewModel)

リネーム保存などの時間のかかる処理は非同期で行うべきですが、ViewにBindingされるプロパティはUIスレッド以外から変更すると例外が発生してしまいます。
こういった事情もReactivePropertyのSchedulerやRxのObserveOnUIDispatcher()を使うと解決できます。

例えば、Modelでの非同期処理の結果、リネームファイル数プロパティCountReplacedが変更になり、それをViewにも表示するためViewModelで同名のプロパティを作る場合はこうします。

FileElementsGridViewModel.cs
//プロパティ宣言
public IReadOnlyReactiveProperty<int> CountConflicted { get; }

//コンストラクタ内で
this.CountReplaced = model.CountReplaced.ObserveOnUIDispatcher().ToReadOnlyReactivePropertySlim();

これにより、Modelのプロパティ変更時のスレッドに関係なく、ViewModelのプロパティ変更はUIスレッドから行われます。

差分表示

DiffPlexというライブラリを使用して、リネーム前後のファイル名の差分情報を生成します。

DiffPlexの詳しい解説は過去記事を参照ください。
WPFでGitのDiffっぽい差分表示をするDiffPlexライブラリ。~あるいは、あるジェダイの変遷

ファイル名の性質上、1行全体のハイライトをしても意味がないので、ワード境界を細かく区切りました。
デフォルトの差分表示時のワード境界が全角文字などに対応していなかったので、追加しました。

public static SideBySideDiffModel CreateDiff(string inputText, string outText)
{
    char[] wordSeparaters =
    {
        ' ', '\t', '.', '(', ')', '{', '}', ',', '!', '?', ';', //MarkDiffデフォルトからコピー
        '_','-','[',']','~','+','=','^',    //半角系
        ' ','、','。','「','」','(',')','{','}','・','!','?',';',':','_','ー','-','~','‐','+','*','/','=','^',    //全角系
    };
    var diff = new SideBySideDiffBuilder(new Differ(), wordSeparaters);
    return diff.BuildDiffModel(inputText, outText);
}

これにより、どこが変更されたかが分かりやすくなります。
$\style{background-color:pink;}{報告書「最終版」}$.txt
→ $\style{background-color:LightGreen;}{報告書「最終手前版」}$.txt
👇
報告書「$\style{background-color:pink;}{最終版}$」.txt
→ 報告書「$\style{background-color:LightGreen;}{最終手前版}$」.txt

ファイル情報DataGrid用ViewModel

Modelでのファイル情報クラスFileElementModelに対応したFileElementViewModelを作成し、上記差分情報もその中に含めています。
ただのDataGridにBindingするなら、これをObservableCollectionなどに入れればよいのですが、並び替えや行フィルタ処理に対応するためにはICollectionViewに変換する必要があります。
変換自体はCollectionViewSource.GetDefaultView()を呼ぶだけでよいですが、行フィルタを使用する場合はICollectionViewerFilterプロパティに表示判定用コールバックを指定して、行フィルタ基準が変わるたびにRefresh()メソッドを呼ぶ必要があります。
実際のコードのうち関係する部分を抜き出すと以下のようになります。

FileElementsGridViewModel.cs(部分)
/// <summary>
/// ファイル情報コレクションのDataGrid用のICollectionView
/// </summary>
public ReadOnlyReactivePropertySlim<ICollectionView> CViewFileElementVMs { get; }

/// <summary>
/// 置換前後で差があったファイルのみ表示するか
/// </summary>
public ReactivePropertySlim<bool> IsVisibleReplacedOnly { get; } = new ReactivePropertySlim<bool>(false);

public FileElementsGridViewModel()
{
    this.CViewFileElementVMs = model
        .ObserveProperty(x => x.FileElementModels)
        .Select(x => CreateFilePathVMs(x))
        .ObserveOnUIDispatcher()
        .Select(x => CreateCollectionViewFilePathVMs(x))
        .ToReadOnlyReactivePropertySlim();

    //表示基準に変更があったら、表示判定対象に変更があったら、CollectionViewの表示を更新する
    new[]
    {
        this.IsVisibleReplacedOnly,
        ...
    }
    .CombineLatest()
    .Throttle(TimeSpan.FromMilliseconds(100))
    .ObserveOnUIDispatcher()
    .Subscribe(_ => RefleshCollectionViewSafe());
}

private ICollectionView CreateCollectionViewFilePathVMs(ObservableCollection<FileElementViewModel> fVMs)
{
    var cView = CollectionViewSource.GetDefaultView(fVMs);
    cView.Filter = (x => GetVisibleRow(x));
    return cView;
}

/// <summary>
/// 2つの表示切り替えプロパティと、各行の値に応じて、その行の表示状態を決定する
/// </summary>
/// <param name="row">行VM</param>
/// <returns>表示状態</returns>
private bool GetVisibleRow(object row)
{
    if (!(row is FileElementViewModel pathVM))
        return true;

    var replacedVisible = !IsVisibleReplacedOnly.Value || pathVM.IsReplaced.Value;
    var conflictedVisible = !IsVisibleConflictedOnly.Value || pathVM.IsConflicted.Value;

    return replacedVisible && conflictedVisible;
}

private void RefleshCollectionViewSafe()
{
    if (!(CViewFileElementVMs?.Value is ListCollectionView currentView))
        return;

    //なぜかCollectionViewが追加中・編集中のことがある。
    if (currentView.IsAddingNew)
    {
        LogTo.Warning("CollectionView is Adding");
        currentView.CancelNew();
    }
    if (currentView.IsEditingItem)
    {
        LogTo.Warning("CollectionView is Editing");
        currentView.CommitEdit();
    }

    currentView.Refresh();
}

View

MahAppsとMaterialDesignThemes

MahAppsとMaterialDesignThemesという2つのライブラリを使用して、見た目をカッチョイイ感じにします。
MahAppsはウインドウのタイトル部分とウインドウの周りにボヤッとした色をつける機能 GlowBrush を使用しています。

image.png

ウインドウの周りがちょっと青いのがわかりますでしょうか。
それ以外のコントロールはMaterialDesignThemesのテーマが適用されています。

今回はプリセット色は使わず、すべてカラーコードを指定したかったので、プリセットの色に上書きします。

App.xaml
<Color x:Key="Primary900">#1A537C</Color>
<Color x:Key="Primary800">#286591</Color>
...
<Color x:Key="Primary50">#E8EFF4</Color>

MaterialDesignThemesの詳しい内容は # 参考 を参照してください。

テーマ変更

Ligth/Darkテーマ切り替えをやってみたかったので、作ってみました。
MaterialDesignThemesのPaletteHelperを使ってデフォルトのテーマをGet、変更後にSetします。
メイン色と前景色のペアで設定します。両者はある程度の明暗差がないと見づらいです。

App.xaml.cs
private static void ChangeTheme()
{
    var paletteHelper = new PaletteHelper();
    var theme = paletteHelper.GetTheme();

    bool isDark = Model.Instance.Setting.IsAppDarkTheme;
    theme.SetBaseTheme(
        isDark
            ? Theme.Dark
            : Theme.Light);

    theme.PrimaryDark = new ColorPair((Color)Current.Resources["Primary700"], Colors.White);
    theme.PrimaryMid = new ColorPair((Color)Current.Resources["Primary500"], Colors.White);
    theme.PrimaryLight = (Color)Current.Resources["Primary300"];
    theme.Paper = AppExtention.ToColorOrDefault(isDark
        ? "#272E33"
        : "#E6EDF2");

    //ベース色とのコントラストが
    Current.Resources["HighContrastBrush"] =
        (isDark ? theme.PrimaryLight : theme.PrimaryDark)
        .Color.ToSolidColorBrush(true);

    paletteHelper.SetTheme(theme);
}

ただし、各種色やBrushはStaticResourceで指定されているので、再起動後に反映されます。

ファイル情報DataGrid(差分以外)

ViewModelのファイル情報クラスFileElementViewModelを含んだICollectionViewをDataGridにBindingしています。

重複判定とリネーム有無の表示列のヘッダにはカウント数と行フィルタを切り替えるToggleSwitchを配置しておきます。
image.png

FileElementGrid.xaml
<DataGridTemplateColumn.Header>
   <materialDesign:Badged
      BadgeColorZoneMode="PrimaryMid"
      DataContext="{Binding DataContext, ElementName=rootObj}">
      <materialDesign:Badged.Badge>
         <TextBlock Text="{Binding DataContext.CountReplaced.Value, ElementName=rootObj}" />
      </materialDesign:Badged.Badge>
      <ToggleButton IsChecked="{Binding IsVisibleReplacedOnly.Value}">
         <materialDesign:PackIcon Kind="CheckBold" />
      </ToggleButton>
   </materialDesign:Badged>
</DataGridTemplateColumn.Header>

ディレクトリ表示列は中身をButtonにして、クリックされたらエクスプローラーで開くようにします。特定のファイルだけ手修正したい場合などに使用します。
image.png

FileElementGrid.xaml
<DataGridTemplateColumn>
   <DataGridTemplateColumn.CellTemplate>
      <DataTemplate>
         <Button
         Command="{Binding OpenInExploreCommand, Mode=OneTime}"
         Style="{StaticResource MaterialDesignFlatButton}"
         ToolTip="{Binding DirectoryPath, Mode=OneTime}">
            <StackPanel Orientation="Horizontal">
               <materialDesign:PackIcon Kind="FolderEditOutline" />
               <TextBlock Text="{Binding DirectoryPath, Mode=OneTime}" />
            </StackPanel>
         </Button>
      </DataTemplate>
   </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>

DataGridで使用している細かい技術は以前の記事を参照ください。
DataGridやListBox内でクリックされたら自身の行を上下に移動するButton
DataGridやListBox内でクリックされたら自身の行を削除するButton
C#のColor関連の便利拡張メソッド+α 24選(HSV色空間への変換も)

差分表示

screenshot2.png
ViewModel内のDiffPlexで作成した差分情報をDataGrid内のRichTextBoxにBindingしています。

FileElementGrid.xaml
<DataGridTemplateColumn Header="{x:Static properties:Resources.Grid_OldText}">
   <DataGridTemplateColumn.CellTemplate>
      <DataTemplate>
         <RichTextBox v:RichTextBoxHelper.Document="{Binding Diff.Value.OldText, Converter={StaticResource DiffPaneModelToFlowDocumentConverter}}" />
      </DataTemplate>
   </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>

独自のConverterを使用して、DiffPlexの差分情報からRichTextBoxの中身のFlowDocumentに変換しています。
差分情報内のワード境界ごとに無変更・削除(Deleted)・挿入(Inserted)・挿入前(Imaginary)・修正(Modified)のどれかに判定されます。ただし1行単位で比較していないため、挿入前・修正は実際には使われないようです。
削除の場合はピンク、挿入の場合は黄緑色を指定しています。

DiffPaneModelToFlowDocumentConverter.cs
public class DiffPaneModelToFlowDocumentConverter : IValueConverter
{
    private static readonly Brush unchangeBrush = Colors.Transparent.ToSolidColorBrush(true);
    private static readonly Brush deletedBrush = AppExtention.ToColorOrDefault($"#FFAFD1").ToSolidColorBrush(true);
    private static readonly Brush insertedBrush = AppExtention.ToColorOrDefault($"#88E6A7").ToSolidColorBrush(true);
    private static readonly Brush imaginaryBrush = Colors.SkyBlue.ToSolidColorBrush(true);
    private static readonly Brush modifiedBrush = Colors.Orange.ToSolidColorBrush(true);
    private static readonly Brush changedTextBrush = Colors.Black.ToSolidColorBrush(true);
    private static readonly Brush normalTextBrush = (SolidColorBrush)App.Current.Resources["MaterialDesignBody"];

    public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        if (!(value is DiffPaneModel diffVM))
            return Binding.DoNothing;

        if (!diffVM.Lines.Any())
            return Binding.DoNothing;

        if (diffVM.Lines.Count > 1)
            LogTo.Warning("Lines Count is over. {@LinesCount}", diffVM.Lines.Count);

        List<Run> lineView = ConvertLinveVmToRuns(diffVM.Lines.First());

        var paragraph = new Paragraph();
        paragraph.Inlines.AddRange(lineView);
        return new FlowDocument(paragraph);
    }

    private static List<Run> ConvertLinveVmToRuns(DiffPiece lineVM) =>
        lineVM.Type switch
        {
            //ChangeType.Modifiedだったら変更された部分だけハイライトしたいのでSubPieceからいろいろやる
            ChangeType.Modified => lineVM
                .SubPieces
                .Select(x => ConvertPieceVmToRun(x))
                .ToList(),

            //ChangeType.Modified以外は行全体で同じ書式
            _ => new List<Run> { ConvertPieceVmToRun(lineVM) },
        };

    private static Run ConvertPieceVmToRun(DiffPiece pieceVM) =>
        new Run
        {
            Text = pieceVM.Text,
            Foreground = (pieceVM.Type == ChangeType.Unchanged)
                ? normalTextBrush
                : changedTextBrush,
            //差分タイプによって、背景色を決定
            Background = (pieceVM.Type switch
            {
                ChangeType.Deleted => deletedBrush,
                ChangeType.Inserted => insertedBrush,
                ChangeType.Imaginary => imaginaryBrush,
                ChangeType.Modified => modifiedBrush,
                _ => unchangeBrush
            }),
        };

    public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        LogTo.Error("Not Implemented");
        return Binding.DoNothing;
    }
}

RichTextBoxにFlowDocumentをBindingする方法は過去記事を参照ください。
WPFのRichTextBoxにBindingする

ファイルダイアログ

Livet.Extensionsを使用しています。
ButtonのTriggerに設定することで、Viewだけでダイアログ処理を完結することができます。
ViewModelにはユーザーが指定したファイルパス等の必要な情報だけが伝わります。

MainWindow.xaml
<Button Style="{StaticResource MaterialDesignRaisedButton}">
   <behaviors:Interaction.Triggers>
      <behaviors:EventTrigger EventName="Click">
         <l:FolderBrowserDialogInteractionMessageAction>
            <l:DirectInteractionMessage CallbackCommand="{Binding LoadFilesFromDialogCommand, Mode=OneTime}">
               <l:FolderSelectionMessage
                  Description="Select Target Folder"
                  DialogPreference="None"
                  SelectedPath="{Binding SettingVM.Value.SearchFilePath.Value}" />
            </l:DirectInteractionMessage>
         </l:FolderBrowserDialogInteractionMessageAction>
      </behaviors:EventTrigger>
   </behaviors:Interaction.Triggers>
   <materialDesign:PackIcon Kind="FolderOpen" />
</Button>

多言語対応

ResX Resource Managerを使用しました。
各言語を横に並べて入力できるので便利です。
image.png

デフォルトの言語はOSに合わせて変わります。ただし、日本語OS以外は持っていないので検証できていません。
設定メニューから変更することもできます。ただし、リソース文字列はx:Staticで取得しているので再起動後でないと反映されないです。

Markdown表示

アプリケーション情報ページではMarkdownを表示しています。
サードパーティライセンスのMarkdownファイルをリソースとして追加した上で、MarkDigライブラリで表示しています。
MarkDigの詳しい解説は以前の記事を参照ください。
C#でMarkdownを表示するライブラリMarkDigの紹介

ログ

ロギングはSerilogを使用しています。
メッセージテンプレートを使用して、以下の内容を保存しています。
現在時刻、ログレベル、スレッドID・名称、メッセージ本文、呼び出し元名前空間+クラス名、呼び出し元メソッドシグネチャ、行番号、使用メモリ量、(あれば例外)

より詳しくは過去記事を参照ください。
C#でSerilogとFody.Anotarを使って、全部盛りのログをとる
エラー時のログなどをMicrosoftStoreに送ったりも出来るらしいですが、やり方がわかっていません。

アイコン

パワーポイントでがんばりました。

デプロイ

Release

単一ファイルかつ、自己完結型で生成しました。
これにより、エンドユーザーは.NET Coreを事前にインストールする必要がありません。

Store配布

Microsoft Storeにアプリケーションを登録しました。
意外と簡単にできて、コストも2000円弱を初回に払うだけです。
有料アプリの場合は売上から割合で取るようです。

詳しい内容は過去記事、、、ではなくてそのうち書く記事を参考にしてください(もはや登録方法を忘れつつある)。

環境

VisualStudio 2019
C# 8
.NET Core 3.1

FileRenamerDiff.csproj
<ItemGroup>
  <PackageReference Include="DiffPlex" Version="1.6.3" />
  <PackageReference Include="LivetCask" Version="3.2.3.1" />
  <PackageReference Include="LivetExtensions" Version="3.2.3.1" />
  <PackageReference Include="MahApps.Metro" Version="2.1.1" />
  <PackageReference Include="Markdig" Version="0.18.0" />
  <PackageReference Include="Markdig.Wpf" Version="0.3.1" />
  <PackageReference Include="MaterialDesignColors" Version="1.2.6" />
  <PackageReference Include="MaterialDesignThemes" Version="3.1.3" />
  <PackageReference Include="MaterialDesignThemes.MahApps" Version="0.1.4" />
  <PackageReference Include="ReactiveProperty" Version="6.2.0" />
  <PackageReference Include="System.Interactive" Version="4.1.1" />
  <PackageReference Include="System.Reactive.Compatibility" Version="4.4.1" />
  <PackageReference Include="Anotar.Serilog.Fody" Version="5.1.3" />
  <PackageReference Include="Serilog" Version="2.9.0" />
  <PackageReference Include="Serilog.Enrichers.Memory" Version="1.0.4" />
  <PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" />
  <PackageReference Include="Serilog.Exceptions" Version="5.6.0" />
  <PackageReference Include="Serilog.Formatting.Compact" Version="1.1.0" />
  <PackageReference Include="Serilog.Sinks.Debug" Version="1.0.1" />
  <PackageReference Include="Serilog.Sinks.File" Version="4.1.0" />
  <PackageReference Include="Utf8Json" Version="1.3.7" />
</ItemGroup>

参考

MVVMとリアクティブプログラミングを支援するライブラリ「ReactiveProperty v2.0」オーバービュー
Livet/README.md
方法: DataGrid コントロールでデータをグループ化、並べ替え、およびフィルター処理する - WPF | Microsoft Docs
自己完結型アプリケーションのトリミング - .NET Core | Microsoft Docs
MaterialDesignInXamlToolkit/README.md

余談

クラス図のBindingの書き方はこれで良いか、あまり自信ありません。クラス図には書かないほうがよいのかな?でも書かないとViewとViewModelの対応関係わからんしな。
UML詳しい人いたら教えてほしいです。

8
14
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
8
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?