2020/07/14

CSVファイルでデータを読み書きする月間予定表

年月をスクロールバーで選択する予定表ひな型」の後編




1.背景

小さなグループ内の共通の会議や打合せは、日付形式のホワイトボードに書き込まれたり、OutLookの様な日程アプリで管理する事も多いかと思います。また、自前のホームページ上に表示している場合もあるかもしれません。
しかしその運営面を考えてみると、ホワイトボードでは近くの人で無いと見えませんし、アプリやホームページ維持にはお金もメンテナンス技術も必要です。
今回紹介する「Excelマクロ+CSVファイル」なら、共有のファイルサーバーさえあれば皆で見たり書き込めたりが出来ます。
また、データ先としてCSVファイルを使用する事で、Excel以外の様々なツールで読み書き出来るため、例えば「Excelでデータ追加・修正して閲覧はブラウザを使う」等、使い方にも幅が出てくると思います。

2.概要

今回紹介するのは、図2-1の様な「日毎の予定を表示したカレンダー」です。
予定を記入できるカレンダー
図2-1

前回「年月をスクロールバーで選択する予定表ひな型」では、「カレンダー型予定表のひな型」を紹介しました。日付の下にデータ記入行はあらかじめ設定しておきましたが、データは入っておらず空欄でした。
今回はCSVファイルからデータを読み書きすることで、そのデータ記入行にデータを入れた状態でカレンダーを閲覧できるようにしました。

また、データの書き換えは、右上の「データ追加・修正」ボタンで起動するダイアログ上で行います。ダイアログの中では、追加変更する日付をスクロールバーで選択し、テキストボックスの中で追加修正を行います。
追加修正が完了したら「ファイル書込み」ボタンによりCSVファイルを書き換え、変更内容は表示しているカレンダーに反映されます。

カレンダーの年月は「前月」「次月」ボタンでの1か月移動に加え、図2-2のように1年先までスクロールバーで一発選択できるようになっています。(カレンダー年月移動については、前編を参照下さい)

図2-2

3.プログラム

今回の内容は、前編「年月をスクロールバーで選択する予定表ひな型」の後編です。カレンダーを表示する部分は、前編をほぼそのまま使用しているため説明は極簡単にし、追加した「CSVファイルとのやり取り」「データ記入ダイアログ」の部分を中心に説明していきます。
カレンダー部分の詳細については、前編を参照してください。

尚、プログラム的には前編と区別できるように、「CSVファイルとのやり取り」については Module2 に、「データ記入ダイアログ」は UserForm2 にコードを記載し、前編(Module1、UserForm1)と分けました。

4.フォームへのコントロールの配置

4-1.年月設定ダイアログ(UserForm1)

年月設定ダイアログのUserForm1は、各コントロールをフォーム上に図4-1のように配置します。

図4-1

4-2.予定データ追加修正ダイアログ(UserForm2)

予定データ追加修正ダイアログのUserForm2は、各コントロールをフォーム上に図4-2のように配置します。

図4-2

ボタン表面の文字は、配置時に変更しています。その他についてはInitialize等でプロパティを変更しています。

5.フォームコード

5-1.年月設定ダイアログ(UserForm1)

年月を設定するダイアログ(UserForm1)のコードは図5-2です。

流れを簡単に説明すると図5-1のようになります。
年月変更ボタンのクリックにより、Module1経由でUserForm1が起動します。フォーム内では、共有変数Tdateをコントロール間でやり取りして設定する年月表示を行い、年月が決定したらその日付を標準モジュール(Module1)へ戻しています。
なおUserForm1のコードは、前編と同じです。

図5-1

  1. '========== ⇩① 変数の宣言(UserForm1) ===================
  2. Dim Tdate As Date
  3. '========== ⇩② フォームの起動(UserForm1:Subプロシージャでデータ受け渡し) =====
  4. Public Sub Start1(TD As Date)
  5.  If TD = 0 Then TD = Now
  6.  Tdate = TD
  7.  Me.Show
  8.  TD = Tdate
  9. End Sub
  10. '========== ⇩③ フォームの起動(UserForm1:Functionプロシージャでデータ受け渡し) ====
  11. Public Function Start2(TD As Date) As Date
  12.  If TD = 0 Then TD = Now
  13.  Tdate = TD
  14.  Me.Show
  15.  Start2 = Tdate
  16. End Function
  17. '========== ⇩④ フォーム初期化(UserForm1) =================
  18. Private Sub UserForm_Initialize()
  19.  ScrollBar1.Min = -12
  20.  ScrollBar1.Max = 12
  21.  ScrollBar1.Value = 0
  22.  Me.Label1.TextAlign = fmTextAlignCenter
  23.  Me.Label2.TextAlign = fmTextAlignCenter
  24.  Me.Label3.TextAlign = fmTextAlignCenter
  25.  Me.Label4.TextAlign = fmTextAlignCenter
  26.  Me.Label5.TextAlign = fmTextAlignCenter
  27.  Me.Label6.TextAlign = fmTextAlignCenter
  28.  Me.Label7.TextAlign = fmTextAlignCenter
  29.  Me.Label8.TextAlign = fmTextAlignCenter
  30.  Me.Label9.TextAlign = fmTextAlignCenter
  31.  Me.Label10.TextAlign = fmTextAlignCenter
  32.  Me.Label1.ForeColor = RGB(128, 128, 128)
  33.  Me.Label2.ForeColor = RGB(64, 64, 64)
  34.  Me.Label3.ForeColor = RGB(0, 0, 0)
  35.  Me.Label4.ForeColor = RGB(64, 64, 64)
  36.  Me.Label5.ForeColor = RGB(128, 128, 128)
  37.  Me.Label6.ForeColor = RGB(128, 128, 128)
  38.  Me.Label7.ForeColor = RGB(64, 64, 64)
  39.  Me.Label8.ForeColor = RGB(0, 0, 0)
  40.  Me.Label9.ForeColor = RGB(64, 64, 64)
  41.  Me.Label10.ForeColor = RGB(128, 128, 128)
  42.  Me.Label1.Font.Size = 9
  43.  Me.Label2.Font.Size = 10
  44.  Me.Label3.Font.Size = 12
  45.  Me.Label4.Font.Size = 10
  46.  Me.Label5.Font.Size = 9
  47.  Me.Label6.Font.Size = 9
  48.  Me.Label7.Font.Size = 10
  49.  Me.Label8.Font.Size = 12
  50.  Me.Label9.Font.Size = 10
  51.  Me.Label10.Font.Size = 9
  52.  Me.Label3.Font.Bold = True
  53.  Me.Label8.Font.Bold = True
  54.  Me.Caption = "予定表の年月を選択して下さい"
  55. End Sub
  56. '========== ⇩⑤ フォームアクティブ時(UserForm1) ====================
  57. Private Sub UserForm_Activate()
  58.  Call YMout
  59. End Sub
  60. '========== ⇩⑥ 年月の表示(UserForm1) =================
  61. Private Sub YMout()
  62.  Dim Ldate As Date, Fdate As Date, LLdate As Date, FFdate As Date
  63.  Ldate = DateAdd("m", -1, Tdate)    '前月
  64.  LLdate = DateAdd("m", -2, Tdate)   '前々月
  65.  Fdate = DateAdd("m", 1, Tdate)    '次月
  66.  FFdate = DateAdd("m", 2, Tdate)    '次々月
  67.  Me.Label3 = Year(Tdate)
  68.  If Not Year(Tdate) = Year(Ldate) Then
  69.   Me.Label2 = Year(Ldate)
  70.  Else
  71.   Me.Label2 = ""
  72.  End If
  73.  If Not Year(Ldate) = Year(LLdate) Then
  74.   Me.Label1 = Year(LLdate)
  75.  Else
  76.   Me.Label1 = ""
  77.  End If
  78.  If Not Year(Tdate) = Year(Fdate) Then
  79.   Me.Label4 = Year(Fdate)
  80.  Else
  81.   Me.Label4 = ""
  82.  End If
  83.  If Not Year(Fdate) = Year(FFdate) Then
  84.   Me.Label5 = Year(FFdate)
  85.  Else
  86.   Me.Label5 = ""
  87.  End If
  88.  Me.Label6 = Month(LLdate)
  89.  Me.Label7 = Month(Ldate)
  90.  Me.Label8 = Month(Tdate)
  91.  Me.Label9 = Month(Fdate)
  92.  Me.Label10 = Month(FFdate)
  93. End Sub
  94. '========== ⇩⑦ スクロールバー操作時の動作(UserForm1) ==================
  95. Private Sub ScrollBar1_Change()
  96.  Tdate = DateAdd("m", ScrollBar1.Value, Tdate)
  97.  ScrollBar1.Value = 0
  98.  Call YMout
  99. End Sub
  100. Private Sub ScrollBar1_Scroll()
  101.  Static LastValue As Integer
  102.  Tdate = DateAdd("m", ScrollBar1.Value - LastValue, Tdate)
  103.  LastValue = ScrollBar1.Value
  104.  Call YMout
  105. End Sub
  106. '========== ⇩⑧ 「今月ボタン」のクリック時(UserForm1) ==================
  107. Private Sub CommandButton1_Click()   '//「今月」ボタン
  108.  Tdate = Now
  109.  Call YMout
  110. End Sub
  111. '========== ⇩⑨ フォーム終了時の処理(UserForm1) ============
  112. Private Sub CommandButton2_Click()   '//「OK」ボタン
  113.  Me.Hide
  114. End Sub
  115. Private Sub CommandButton3_Click()   '//「Cancel」ボタン
  116.  Tdate = 0
  117.  Me.Hide
  118. End Sub
  119. Private Sub UserForm_QueryClose(Cancel As Integer, CloseMode As Integer)  '//右上×印で終了した時
  120.  Cancel = True
  121.  Tdate = 0
  122.  Me.Hide
  123. End Sub
図5-2

5-2.予定データ追加修正ダイアログ(UserForm2)

5-2-1.フォーム内共有変数の宣言

UserForm2の中では、「カレンダー初日の日付データ」を共有変数 Xdate として保持します。。
役目としては「UserForm1の変数Tdate」とほぼ同じです。TdateもXdateもフォーム内のみで有効な変数ですし、2つのダイアログを同時に起動することもありませんので、変数名が同じでも問題ないのですが、混乱しないように今回は別名にしました。
  1. '========== ⇩⑮ 変数の宣言(UserForm2) ================
  2. Dim Xdate As Date   '←カレンダーの初日
図5-3

5-2-2.フォームの起動

ワークシート上の「データ追加・修正」ボタン(H1セル上に配置したボタン)をクリックし、Module2の「AddData プロシージャ」(図6-4)経由で呼び出されるのが図5-4のStart3プロシージャです。
  1. '========== ⇩⑯ フォームの起動(UserForm2) =================
  2. Public Sub Start3(TD As Date)         '←「データ追加修正」ボタン→AddData経由で起動するプロシージャ
  3.  If TD = 0 Then TD = Now           '←カレンダー日付が無かった場合は今月にする
  4.  Xdate = DateSerial(Year(TD), Month(TD), 1)  '共有変数Xdateに初日の日付を代入
  5.  Me.Show                    '←フォームを起動
  6. End Sub
図5-4

「AddData プロシージャ(図6-4)」は、引数として表示されているカレンダーの日付(TD As Date)を渡してくれます。
もしカレンダーが表示されていない(=日付も無い)場合は、引数TDにはゼロ(日付にすると1899年12月31日)が渡されてしまいます。1899年のデータを今更作っても意味がありませんので、143行目では日付ゼロの場合には今日の日を代入しています。
144行目では、引数の日付(ゼロの場合は今日の日付)を使い、その月の初日の日付をモジュール変数Xdateに代入しています。UserForm1のTdate変数(初日に限っていない)と異なり、Xdateに「初日」を代入するのは、その値とスクロールバーの位置(Value値:ゼロ~(月末日-1))を足すことで、データの日付の役割を与えるためです。
145行目では、UserForm2を起動させています。

5-2-3.フォーム起動時の初期化(Initialize 及び Activate)

フォーム(UserForm2)が表示される前に、発生するイベントがInitializeイベントとActivateイベントです。初めて起動する際にはInitialize が発生した後に Activateが発生し、フォームが隠され(Hide)た後に再表示する時にはActivateが発生します。
  1. '========== ⇩⑰ フォーム起動時の初期化(UserForm2) ====================
  2. Private Sub UserForm_Initialize()
  3.  Me.TextBox1.MultiLine = True
  4.  Me.TextBox1.EnterKeyBehavior = True
  5.  Me.TextBox1.Font.Size = 14
  6.  Me.Label1.Font.Size = 14
  7. End Sub
  8. Private Sub UserForm_Activate()
  9.  Me.ScrollBar1.Min = 0
  10.  Me.ScrollBar1.Max = Day(DateSerial(Year(Xdate), Month(Xdate) + 1, 0)) - 1
  11.  Me.ScrollBar1.Value = 0
  12.  Call TextPut
  13. End Sub
図5-5

Initializeイベント(148~153行目)では、TextBoxと年月日表示用のLabel1のプロパティを設定しています。
149行目の「MultiLine = True」は、TextBox内で複数行表示を可能にするものです。
150行目の「EnterKeyBehavior = True」は「Enterキーを押した時に、TextBox内で改行」の役割を与えるものです。これを設定しない(= False)場合、「Enterキーを押すと次のコントロールに移る」という意味になるのですが、今回は「ワープロの様にTextBoxを使いたい」為に、プロパティを変更(= True)しています。
(「EnterKeyBehavior = False」の場合でも「Ctrl + Enter」で改行できます。)

151行目はTextBoxのフォントサイズ設定、152行目は年月日を表示するLabel1のフォントサイズを設定しています。

起動時、およびHide後再表示する時に発生するActivateイベント(155~160行目)では、表示される度に異なる部分について設定をします。

156~158行目は、日付を変更するためのスクロールバーの可動範囲を設定します。
例えば31日まである月の場合、初日は変数Xdateに代入されていますので、0~30をXdateに足せばその月全部を表せられることになります。ですのでMinをゼロと置き(156行目)、Maxは「月の最終日ー1」とします。
最終日の算出方法は「翌月の初日の1日前」という意味で、「Day(DateSerial(Year(Xdate), Month(Xdate) + 1, 0)) 」としました。(「月の最終日の計算方法」を参照下さい)
ダイアログが起動し最初に現れるデータをどこにするかですが、その月の初日としたため(当月のデータのみを扱うのであれば、最初に操作した日のデータを現すようにするのですが、様々な月のデータを扱うため初日にしました。)158行目で初期Value値にゼロを代入しています。

表示準備が整いましたので159行目でTextPutプロシージャ(TextBoxにデータを書き込み)を呼び出します。

5-2-4.ダイアログのTextBoxにデータを表示

ダイアログのTextBoxにデータを表示するのが図5-6です。
  1. '========== ⇩⑱ TextBoxにデータを表示(UserForm2) ============
  2. Private Sub TextPut()
  3.  Dim Ldate As Long
  4.  Me.Label1.Caption = Format(Xdate + ScrollBar1.Value, "yyyy/mm/dd(aaa)")
  5.  Ldate = Xdate + ScrollBar1.Value
  6.  If Sched.Exists(Ldate) Then
  7.   Me.TextBox1.Value = Sched(Ldate)
  8.  Else
  9.   Me.TextBox1.Value = ""
  10.  End If
  11. End Sub
図5-6

まず、164行目でTextBox1の上側に配置した「年月日を表示するためのLabel1」に日付を代入します。
例えば「2020/5/5」などと普通に表示するのであれば「Me.Label1.Caption = Xdate + ScrollBar1.Value 」で問題ありません。今回は「会社・グループの予定をカレンダーに書き込む」という目的なので「曜日は必須」と考え、Format関数を使って曜日も一緒に表示させました。ちなみに「aaa」の部分が曜日になります。

165行目は表示する(=スクロールバーの指している)日付を計算しています。この値を使い167~171行目の式で、TextBoxに「CSVファイルから取り込んだ日毎のデータ」を表示します。

システムの中で「複数のデータ」をどの様な形で保持するかですが、「配列」「Collectionオブジェクト」「Dictionaryオブジェクト」等、いくつかの方法が考えられます。
今回のシステムの特徴は「予定データがある日もあれば、無い日もある」「データを追加したり削除したりする」である事を考慮し、「Dictionary」を使う事にしました。

詳細は図6-5のところで説明していきますが、Dictionaryは「Key」と「Item」の組み合わせで出来ており、Key値はダブる事ができませんので、日ごとにデータ管理するには都合が良いのです。 そのKey値に使えるデータ型も、文字列や数値と共にDate型も使用できるので、「日付:予定内容」をセットで扱うことにしました。

167行目で「Sched」というものが出てきますが、これは図6-5で「Dictionaryオブジェクトとして生成したオブジェクト」で、「SChed(日付)」とすれば、日付に紐づけられた予定内容が引き出せます。
また「Sched.Exists(日付)」は、「日付のデータは存在するか?」を調べ、存在すればTrueを返します。
ですので167~171行目は「日付のデータが存在すれば予定内容をTextBoxに書き込み、存在しなければ空文字をTextBoxに入れる」という意味になります。

5-2-5.スクロールバー操作時の動作

ある日付の予定内容の編集をするためには、その日の内容をまずTextBoxに表示させなければなりません。
そのため、スクロールバーを操作して日付を変更させるのですが、スクロールバーのどこを操作するかで発生するイベントが異なってきます。
図5-7はスクロールバーの部位名ですが、その部位と発生するイベントを図5-8にまとめました。

スクロールバーの部位名
図5-7

クリックする場所操作動く量・示す値発生イベント
スクロール矢印クリックSmallChangeプロパティの量Changeイベント
スクロールボックスと
スクロール矢印の間の領域(レール)
クリックLargeChangeプロパティの量Changeイベント
スクロールボックスクリックし移動中スクロールボックスの位置のValue値
(動く量はValue=1ずつ)
Scrollイベント
移動後クリックを離すクリックを離した場所のValue値Changeイベント
図5-8

Changeイベントは良く知られていますが、今回の場合は特に「スクロールボックスを移動させている最中にも日付表示を移動する必要がある」ため、「Changeイベント」及び「Scrollイベント」の両方を使います。
  1. '========== ⇩⑲ スクロールバーによるTextBoxの日付の移動(UserForm2) =============
  2. Private Sub ScrollBar1_Change()    '//日付変更用スクロールバーのChange
  3.  Call TextPut
  4. End Sub
  5. Private Sub ScrollBar1_Scroll()    '//日付変更用スクロールバーのScroll
  6.  Call TextPut
  7. End Sub
図5-9

Changeイベント(174~176行目)、Scrollイベント(178~180行目)とも、「TextPutプロシージャ」を呼び出します。こうすることで、スクロールバーのどこを操作してもリアルタイムに日付が移動し、それに対応した予定内容も移動してくれます。

5-2ー6.TextBoxを変更した時のDictionaryデータの追加修正

TextBoxの内容をユーザーが変更させた後、TextBoxを更新しょうとすると「BeforeUpdateイベント」が発生します。
つまり「BeforeUpdateイベントが発生した = ユーザーが内容を追加修正した」らBeforeUpdateイベントを動かし、BeforeUpdateイベント経由で「保持データ」に修正を加えるのが図5-10のDicMakeプロシージャです。
  1. '========== ⇩⑳ TextBoxを変更した時のDictionaryデータの追加修正(UserForm2) ======
  2. Private Sub TextBox1_BeforeUpdate(ByVal Cancel As MSForms.ReturnBoolean)
  3.  Call DicMake(Xdate + Me.ScrollBar1.Value)   '//TextBoxの内容を変更した時
  4. End Sub
  5. Sub DicMake(d As Date)    '←保持データの修正
  6.  If Sched.Exists(d) Then
  7.   Sched.Remove (d)
  8.   ChangeFlag = True
  9.  End If
  10.  If Not TextBox1.Value = "" Then
  11.   Sched.Add Key:=d, Item:=Me.TextBox1.Value
  12.   ChangeFlag = True
  13.  End If
  14. End Sub
図5-10

「BeforeUpdateイベント」は182~184行目で、イベントが発生したら「DicMake」プロシージャしたらを呼び出します。引数として、その時の日付である「Xdate + Me.ScrollBar1.Value」を渡します。

TextBoxのイベントは十数個ありますが、編集に関わるものは図5-11に示す5個です。
(キー、マウスの操作イベントは除いています)
TextBoxを編集した際に発生するイベント
図5-11

図5-11は、TextBoxへの操作に伴い、何のイベントが発生するかを整理したものです。
赤字操作は「TextBoxの内容をマクロで表示し、ユーザーが手作業で書き換えた後、日付を移動・又は操作ボタンをクリック」する流れで、赤線で示すように、BeforeUpdate、AfterUpdateイベントが発生しています。
また、黒字の操作は「TextBoxの内容をマクロで表示し、ユーザーは何もせず、日付を移動・又は操作ボタンをクリック」する流れで、黒線で示すようにBeforeUpdate、AfterUpdateイベントは発生しません。

つまり「BeforeUpdateの発生 = TextBoxの内容をユーザーが書き換えた」ことになるのです。
なお、たとえTextBox内にマウスを乗せても書き換えなければ、BeforeUpdate、AfterUpdateイベントは発生しません。

186~196行目がBeforeUpdateイベントから呼び出される「DicMake」プロシージャです。引数(d As Date)として変更を加えられた予定の日付を受け取ります。
なお、UserForm2の中では「Xdate」も「Me.ScrollBar1.Value」も共有されていますので、日付を引数として受取る必要も無いのですが、「与えられた日付についてのみ処理をする」という意味を持たせて引数を設定しました。

さて186~196行目では保持データを更新しているのですが、「TextBoxの内容を追加修正した」と言う情報だけでは処理が出来ません。元の保持データにどの様な処理をするのかを決めるには、状況をもっと細分化する必要があります。(図5-12)
  編集前の状況編集中編集完了時TextBoxのBeforeUpdateイベントデータ処理方法
1 文字列が存在した
(データがあった)
追記・修正した文字列として残った発生データ更新
2 全部削除した空文字になったデータ削除
3 何もしなかった元の文字列のままそのまま
4 空文字だった記入した文字列として残った発生データ新作
5 記入したが消した空文字になったそのまま
6 何もしなかった空文字のまま
図5-12

図5-12の一番右の列を見てもらうと分かる通り、保持データに対する処理方法は「そのまま」を除けば3種類必要です。

今回使用している「Dictionaryオブジェクト」のプロパティ・メソッドは、以下のようなものがあります(一部のみ)。
 データを更新:Sched(日付) = 更新するデータ
 データを削除:Sched.Remove (日付)
 データの新作:Sched.Add Key:=日付, Item:=内容
 データの存在:Sched.Exists(日付) →返り値Trueは有り、返り値Falseは無し

図5-12の分岐をそのままコードにしようとすれば図5-13のようになります。
  1. Sub DicMake2(d As Date)
  2.  Select Case Sched.Exists(d)
  3.   Case True
  4.    If Not TextBox1.Value = "" Then
  5.     Sched(d) = TextBox1.Value   '←No.1
  6.     ChangeFlag = True
  7.    Else
  8.     Sched.Remove (d)       '←No.2
  9.     ChangeFlag = True
  10.    End If
  11.   Case False
  12.    If Not TextBox1.Value = "" Then
  13.     Sched.Add Key:=d, Item:=Me.TextBox1.Value   '←No.4
  14.     ChangeFlag = True
  15.    End If
  16.  End Select
  17. End Sub
図5-13

この図5-13に対して図5-10のDicMakeプロシージャでは、「データが存在するなら、まずは削除(186~190行目)」→「TextBoxにデータがあるなら新作(192~195行目)」の方法を使っています。コードも約半分で収まります。
どちらか、分かり易い方で実施してもらえば良いと思います。

図5-10の189・194行目では、「データを更新しましたフラグ」を立てるために「ChangeFlag = True」を行っています。
これはダイアログで予定内容を変更(新作・削除含む)したままの状態では、メモリー上のデータが最新になっているだけで、CSVファイルに保存されている訳ではありません。
ダイアログの「ファイル書込み」ボタンをクリックして初めて、CSVファイルへの書き込みを行うようにしています。

このように直接CSVファイルに書き込まない理由は、ハード的な書込みにはある程度の処理時間がかかるのと、間違えて削除してしまった予定を復活させる道を作っておくべきとの考えから、このような流れにしました。
そこでメモリー上のデータ変更のフラグ(ChangeFlag)を立て(=True)、もしダイアログのCancelボタンをうっかりクリックしてしまった時には「本当に終了して良いか」のコメントを出しています。(図5-16)

フラグを立てる・寝かすの場所については、図5-14のように考えました。
データ変更フラグの設定・解除位置
図5-14

この図5-14の中で迷ったのが、「次月ボタン」等でカレンダーを動かす時点でフラグが立っている時の動作です。この時にも「本当にメモリー上の変更データを消去して良いか」のコメントを出す必要があるのかもしれません。
しかし、ダイアログのCancelで一度コメントを出しているので、二度同じようなコメントを出すのも煩わしいと思い、年月変更時は無視する事としました。

もし重要なデータを扱う場合は、「年月の切り替えでもコメントを出す」「前回データをバックアップファイルとして、一定時間残す」などの機能も必要だと思います。

なお今回システムでは、誰でもデータの書き換えが可能な仕様となっていますが、「ある権限以上の人でないと変更できない」機能の追加も考えられます。
もし、全機能をこの1つのファイルだけで運営するのであれば、「データ追加修正」ボタンにパスワード認証を設ける必要があります。但し「閲覧用」と「メンテナンス用」とでファイルを分ける等の対策が取れる様なら、「閲覧用はデータ追加修正ボタンを削除する」ような運用でも良いと思います。

5-2ー7.「ファイル書込・終了」ボタンの動作(UserForm2)

ダイアログの「ファイル書込・終了」ボタンをクリックした時に発生するイベントプロシージャが図5-15です。
  1. '========== ⇩(21) データ書き込みボタンによる書込み指令(UserForm2) ===============
  2. Private Sub CommandButton1_Click()    '//ファイル書込み・終了ボタン
  3.  Call CSVwrite(Xdate)
  4.  ChangeFlag = False
  5.  Call DateMake(Xdate)
  6.  Me.Hide
  7. End Sub
図5-15

217行目は「CSVwrite」を呼び出しています。CSVwriteは「CSVファイルへの書込」でModule2の図6-14になります。
CSVファイルは月毎に作っています(例えば、2020年7月のCSVファイルなら、202007.csv としています)ので、月の初日である変数Xdateを引数で渡し、年月を換算して呼び出します。

「CSVファイルへの書込」が完了しましたら、CSVファイルの中身とメモリー上の保持データが同一になりますので、218行目でデータ変更フラグ「ChangeFlag」を寝かします(=False)。

データはCSVファイルに書き込まれましたので、219行目で改めてカレンダーを再表示し、保持データを目に見える様にすることで、ユーザーは内容をチェックする事が出来ます。
カレンダーが再表示されたら220行目でダイアログを隠します(Hide)。

なお操作の手順として、TextBoxの内容を変更した後、日付を移動しないまま「ファイル書込・終了」ボタンを押す場合もあります。しかし、図5-11で分かる通り「ファイル書込・終了」ボタンを押した瞬間にTextBoxは「BeforeUpdateイベントを発生」させますので、「メモリー上の保持データ」を更新してくれます。

5-2ー8.「Cancel」ボタンの動作(UserForm2)

ダイアログのCancelボタンをクリックした時に発生するイベントプロシージャが図5-16です。
  1. '========== ⇩(22) Cancelボタンによるデータ変更無視(UserForm2) ===========
  2. Private Sub CommandButton2_Click()       '//Cancelボタン
  3.  Dim Ans As Integer
  4.  Dim St As String               '←警告文字列の変数宣言
  5.  If ChangeFlag = True Then
  6.   St = "データが変更されています。" & vbCrLf & "終了しても良いですか?"
  7.   Ans = MsgBox(St, vbOKCancel)
  8.   If Ans = 2 Then Exit Sub
  9.  End If
  10.  Me.Hide
  11. End Sub
図5-16

Cancelボタンでの動作ですが、「データが更新されている時には、更新内容をCSVファイルに反映しなくても良いかの忠告」をする必要があります。
そのため、226行目でデータ更新のフラグ(ChangeFlag)が立っている(=True)かを判断し、228行目でメッセージを出します。メッセージ作成にはMsgBox関数を使用し、第二引数のボタンの種類に「vbOKCancel」を指定することで、図5-17のような表示になります。
データ変更時のCancelボタンの反応
図5-17

「OK」をクリックするとMsgBoxの返り値は1となり、「Cancel」では2が返ります。(MsgBoxの右上の×印も2が返ります。)
この値を228行目のAns変数で受け取り229行目で値を判定し、Cancel(=2)であればプロシージャを抜けて「予定内容ダイアログ」を残します。ユーザーは「更新したデータを消したらマズイ」と思い、改めて「ファイル書込」ボタンでCSVファイル保存してくれるはずです。

「OK」ボタンをクリック(返り値=1)した際は、If文を抜けて231行目でダイアログを終了(Hide)します。
但し、ダイアログ終了しても ChangeFlagは立ったままですので、再度「追加修正」ボタンを押してダイアログを起動し、何も編集せずにCancelボタンをクリックしても、「データが変更されています」が現れます。
これはChangeFlag が、「1つのダイアログを起動し閉じる間」ではなく「カレンダーの表示年月を開いている間」が生きている時間、と考えれば納得できると思います。

5-2ー9.ダイアログ右上×印の動作(UserForm2)

ダイアログ右上の×印をクリックして閉じようとした時の動作が図5-18です。
  1. '========== ⇩'(23)' ダイアログ右上×印の操作禁止(UserForm2) ==========
  2. Private Sub UserForm_QueryClose(Cancel As Integer, CloseMode As Integer)
  3.  MsgBox "ボタンで操作して下さい"
  4.  Cancel = True
  5. End Sub
図5-18

今回は「右上×印は、操作禁止」という考えで、214行目でコメントを出し、215行目の「Cancel = True」で×印操作を無視しています。操作は正規のボタンのみを許している形です。

なお、「右上×印は、Cancelと同等」という考え方もあるかと思います。ですので、図5-19のようにプログラムがCancelボタンに流れるようにも考えてみました。
  1. Private Sub UserForm_QueryClose(Cancel As Integer, CloseMode As Integer)
  2.  Cancel = True    '←これがあると、BeforeUpdateイベントが発生しない
  3.  Call CommandButton2_Click
  4. End Sub
図5-19

しかし、これには欠点があります。「QueryCloseイベントのコードが存在し、Cancel=True でダイアログを閉じなくすると、ダイアログ右上の×印をクリックしても BeforeUpdateイベントが発生しない」のです。
つまり「ダイアログ右上×印の強制終了機能を殺すと、×印はコントロールでは無くなる」とも言えるかもしれません。

現象的に言うと「TextBoxを編集した直後にCancelボタンをクリックすると「データ変更されています」が表示される」のに、「TextBoxを編集した直後に×印をクリックすると、そのままダイアログが閉じる」ことになります。
ですので、無念ですが「×印は操作禁止」としました。

6.標準モジュールのコード

6-1.カレンダーの作成(Module1)

年月選定のダイアログをユーザーが操作し、表示年月が決定したらその日付をも標準モジュール側へ戻してきます。その戻された日付を元にカレンダーを配列上に並べ、、最後にワークシートにデータを貼り付けてカレンダーを完成させます。
(図5-1を参照下さい)

この図6-1のコードは「年月をスクロールバーで選択する予定表ひな型」とほとんど同じですが、カレンダーの日付を作る「DateMakeプロシージャ」の中で、二か所のみ追加しています。(248行目、255~256行目)
これは、表示するカレンダー年月に対応したCSVファイルを読み込み、予定データをメモリー上に展開し(248行目)、カレンダーの日付を配列に代入していく際に、予定内容が存在したら一緒に配列に代入する(255~256行目)というものです。
これにより、完成したカレンダーの日付の下に、その日の予定が記載されることになります。
  1. '========== ⇩⑩ 変数の宣言(標準モジュール) =============
  2. Const CalTitleRow As Integer = 2   'カレンダーのタイトル行の行位置
  3. Const CalStartRow As Integer = 3   'カレンダーの日付範囲の開始行
  4. Const CalStartCol As Integer = 2   'カレンダーの日付範囲の開始列
  5. Const YM As String = "D1"      '年月表示してあるセル位置
  6. '========== ⇩⑪ 「年月変更」ボタンで作動(標準モジュール) ===========
  7. Sub Ymake()   '「年月変更」ボタン
  8.  Dim TD As Date
  9.  TD = Sheet1.Range(YM)
  10.  Call UserForm1.Start1(TD)
  11.   ' TD = UserForm1.Start2(TD) 
  12.  If TD = 0 Then Exit Sub
  13.  Call DateMake(TD)
  14. End Sub
  15. '========== ⇩⑫ カレンダーの日付を記入 =================
  16. Sub DateMake(TD As Date)     'カレンダーの数値記入
  17.  Dim FirstDay As Date
  18.  Dim LastDay As Integer
  19.  Dim FirstWeek As Integer
  20.  Dim CalArray(1 To 12, 1 To 7) As Integer
  21.  Dim i As Integer, j As Integer, k As Integer
  22.  FirstDay = DateSerial(Year(TD), Month(TD), 1)
  23.  LastDay = Day(DateSerial(Year(TD), Month(TD) + 1, 0))
  24.  FirstWeek = Weekday(FirstDay)
  25.  Call CSVread(TD)          '←CSVファイルの呼び出し
  26.  For i = FirstWeek To LastDay + FirstWeek - 1
  27.   k = Int((i - 1) / 7) + 1
  28.   j = i - (k - 1) * 7
  29.   CalArray(k * 2 - 1, j) = i - FirstWeek + 1
  30.  Ldate = DateSerial(Year(TD), Month(TD), i - FirstWeek + 1)      '←日付を計算
  31.  If Sched.Exists(Ldate) Then CalArray(k * 2, j) = Sched(Ldate)     '←データ有れば書き込み
  32.  Next i
  33.  Sheet1.Cells(CalStartRow, CalStartCol).Resize(12, 7) = CalArray
  34.  Sheet1.Range(YM).Value = FirstDay
  35. End Sub
  36. '========== ⇩⑬ 「前月」「次月」ボタンで作動 ===================
  37. Sub IncMonth()    '次月カレンダーの表示
  38.  Call DateMake(DateAdd("m", 1, Sheet1.Range(YM)))
  39. End Sub
  40. Sub DecMonth()    '前月カレンダーの表示
  41.  Call DateMake(DateAdd("m", -1, Sheet1.Range(YM)))
  42. End Sub
  43. '========== ⇩⑭ カレンダー外観作成 ==================
  44. Public Sub CalMake()    'カレンダーの外観作成。罫線・彩色は別(1回だけ実施)
  45.  Dim i As Integer
  46.  Windows(ThisWorkbook.Name).DisplayZeros = False   '数値ゼロは非表示
  47.  Sheet1.Range(YM).NumberFormatLocal = "yyyy""年""m""月"""
  48.  With Sheet1.Cells(CalTitleRow, CalStartCol).Resize(1, 7)   '曜日行
  49.   .HorizontalAlignment = xlCenter
  50.   .Font.Size = 11
  51.   .Font.Bold = True
  52.   .EntireRow.RowHeight = 19
  53.  End With
  54.  For i = 0 To 11 Step 2    '日付行
  55.   With Sheet1.Cells(CalStartRow + i, CalStartCol).Resize(1, 7)
  56.    .HorizontalAlignment = xlCenter
  57.    .Font.Size = 16
  58.    .Font.Bold = True
  59.    .EntireRow.RowHeight = 21
  60.   End With
  61.  Next i
  62.  For i = 1 To 11 Step 2    'コメント行
  63.   With Sheet1.Cells(CalStartRow + i, CalStartCol).Resize(1, 7)
  64.    .HorizontalAlignment = xlGeneral
  65.    .Font.Size = 10
  66.    .Font.Bold = False
  67.    .EntireRow.RowHeight = 73
  68.   End With
  69.  Next i
  70.  For i = 0 To 6       '列の調整
  71.   With Sheet1.Cells(CalStartRow, CalStartCol + i)
  72.    .EntireColumn.ColumnWidth = 19
  73.   End With
  74.  Next i
  75. End Sub
図6-1

6-2.CSVファイルとのやり取り(Module2)

前編「年月をスクロールバーで選択する予定表ひな型」と分けて説明できるように、CSVファイルとのやり取りについてはModule2に記載しました。

6-2-1.モジュール定数・変数の宣言(Module2)

Module2の先頭でモジュール定数・変数の宣言をします。(図6-2)
  1. '========== ⇩(24) 定数・変数の宣言(Module2) =============
  2. Const CSV_PATH As String = "C:¥User¥USER¥csv¥"    '←各PCの環境に合わせて変更して下さい
  3. Const HEADER As Integer = 1              'header有り=1、header無し=0
  4. Const CHARACTER As String = "utf-8"
  5. Public Sched As Object
  6. Public ChangeFlag As Boolean
図6-2

309行目は、CSVファイルの保管場所を絶対パスで指定します。相対パスではエラーが発生します。
なおサンプルファイルを試行する際には、「存在するパス」をまず設定して下さい。

310行目は、CSVファイルのヘッダ行の有無を設定します。
今回システム(サンプルファイル)は、Header有り(=1)でCSVファイル(Header行のあるファイル)を作成しています。
しかし、もし既存のシステムの置き換えとして考えるのであれば、既存データ(Header有無の双方)に合わせられる様にと考え、定数として設定しました。

311行目はCSVファイルの文字コードを設定します。これもサンプルファイルはUTF-8ですが、既存データに合わせられるようにしています。

312行目は「Sched」オブジェクトの宣言で、schedule(スケジュール)の略のつもりです。
CSVファイルを読み込み、PCメモリー内では予定内容を保持データとして記憶していますが、その保持方法には「配列」「Collection」「Dictionary」等の方法が考えられます。その3つを図6-3で比較してみます。
配列CollectionDictionary
イメージワークシートの様なテーブル頁が振られたファイリング資料
(Key有は、目次付)
単語カード(表:Key 裏:Item)
使用に必要な手順Dim 配列名(要素数) ・・・静的配列
Dim 配列名() ・・・動的配列
Dim コレクション名 As New Collection
と先頭で宣言要
Set ディクショナリ名=
CreateObject (Scripting.Dictionary)
とオブジェクト生成要
Itemのデータ型全て(配列、オブジェクトも)OK全て(配列、オブジェクトも)OK
Keyの設定任意指定必ず指定
Keyの存在確認ないExists(Key値)=Trueなら存在
Keyに使える型String型のみ全ての型(配列以外)
データ行の追加動的配列で宣言し
ReDimでサイズUP
Add(Item,[Key])Add(Key,Item)
データの呼出し配列名(インデックス番号,・・・)コレクション名(インデックス番号)
コレクション名(Key値)
ディクショナリ名(key値)
データ行の削減必要行のみを
別配列にCopy
Remove インデックス番号
Remove Key値
Remove(Key)
データ行の全削減Preserve無しで
Redim
Removeメソッドで
1行ごとに削除
RemoveAllメソッドで削除
データの重複許可Key無しの時は許可
Key設定時はKey重複→エラー発生
Keyの重複不可
ワークシートとの
データ授受
そのまま代入
そのまま貼付け
1行ずつ代入・貼付1行ずつ代入・貼付
その他n次元が可能Addする時に、データ
追加位置の指定可
KeyとItemの一覧を
配列に出力可
図6-3

今回は、以下の様なデータの特徴から「Dictionary」を使用することにしました。
 ●「日付」⇔「予定内容」が1対1でつながっている。
 ●年月を変更する時は、一旦全データを削除する。
 ●追加、削除、修正の作業が多い。
 ●日付をKeyにするので、KeyにDate型が使えるものが良い。

尚、CollectionのKeyは文字列ですが、例えば「2020/07/01」と文字列をKeyにする方法はあると思います。しかし「2020/7/1」と比べると、Date型だと同一値なのですがString型だと違う値と判断されてしまいます。この点からもDictionaryにしました。

313行目の「changeFlag」は、PCメモリー上の保持データに変更を加えた場合に「False → True」とフラグを立てて、「データ変更したのに、CSVファイルへの保存をしなかった」というミスを防ぐものです。
どの時点でフラグを立てたり寝かしたりするかは、図5-14を参照下さい。

6-2-2.「データ追加修正」ボタンによる実行(Module2)

カレンダー上部のH1セルの位置に配置した「データ追加修正」ボタンに登録してあるマクロが図6-4です。
  1. '========== ⇩(25)「データ追加修正」ボタンから呼び出されるマクロ ==============
  2. Public Sub AddData()
  3.  Call UserForm2.Start3(Sheet1.Range(YM))
  4. End Sub
図6-4

316行目は、カレンダーの日付(=Range(YM))を引数として、UserForm2のStart3プロシージャ(図5-4)を呼び出し、Start3の中から予定変更ダイアログを表示させます。そのダイアログに表示されているデータは「カレンダーの日付と同じ月の予定内容」になります。

6-2-3.CSVファイルからデータを読み取り(Module2)

図6-1の248行目から呼び出されるのが、図6-5です。
役割としては、引数として受取った年月に対応したCSVファイルからデータを読み取り、メモリー上の保持データに展開します。
  1. '========== ⇩(26) CSVファイルからデータを読み取り、メモリー上の保持データにする =======
  2. Public Sub CSVread(YM As Date)
  3.  Dim St As Object
  4.  Dim buf As String
  5.  Dim buf1() As String
  6.  Dim buf2() As String
  7.  Dim URL As String
  8.  Dim CSVrow As Long
  9.  Dim i As Long
  10.  URL = Format(YM, "yyyymm")
  11.  URL = CSV_PATH & URL & ".csv"
  12.  Set Sched = CreateObject("Scripting.Dictionary")
  13.  ChangeFlag = False
  14.  If Sched.Count > 0 Then Sched.RemoveAll
  15.  If Dir(URL) = "" Then Exit Sub
  16.  Set St = CreateObject("ADODB.Stream")
  17.  St.Charset = CHARACTER
  18.  St.Open
  19.   St.LoadFromFile URL
  20.   buf = St.ReadText
  21.  St.Close
  22.  Do While Right(buf, Len(vbCrLf)) = vbCrLf
  23.   buf = Left(buf, Len(buf) - Len(vbCrLf))
  24.  Loop
  25.  buf1 = mySplit(buf, vbCr)
  26.   'buf1 = mySplit2(buf, vbCrLf)
  27.  CSVrow = UBound(buf1, 1)
  28.  For i = HEADER To CSVrow
  29.   buf2 = mySplit(buf1(i), ",")
  30.   Sched.Add Key:=CDate(buf2(0)), Item:=buf2(1)
  31.  Next i
  32.  Set St = Nothing
  33. End Sub
図6-5

まず、読み取るCSVファイルは、図6-6の様なものです。
CSVファイルの内容
図6-6

この中で「改行印」は改行の印で、Excelで言えば「vbCrLf」になります。また「改行印」はスペースです。
1つ1つのデータは「日付」と「内容」が「,(カンマ)」で区切られており、データとデータの間は改行「改行印」で区切られています。また「内容」は、両端を「"(ダブルクォーテーション)」で囲まれています。

もう一度、図6-6を見てみると、1行目は「タイトル行」と分かります。2行目は、「日付」+「,(カンマ)」+「内容」みたいですが、「内容」を囲っているはずの「"(ダブルクォーテーション)」が最後に付いていません。
そこで3行目を見てみると、最後に「"(ダブルクォーテーション)」が付いており、ここまでが「内容」と分かります。
つまり、タイトル行の次の1つ目のデータは2~3行目となり、「内容」の文字列の中には改行が含まれている、となります。

同様に2つ目のデータは図6-6の4~7行目で、「内容」には3つの改行が含まれています。3つ目のデータは8行目のみで、この「内容」には改行は含まれていません。
この様に、今回のCSVファイル中のデータは「改行を含む内容データ」が有ることが特徴です。

図6-5のコードを見ていきます。
328行目は、引数として受取ったカレンダーの日付「YM」を加工しています。加工の方法は、年を「yyyy(4桁)」月を「mm(2桁)」と指定していますので、例えば2020年7月1日でしたら「202007」となります。
代入する先の変数URLはString型で宣言しています(324行目)ので「202007」も文字列です。

329行目では、3つの文字列を結合しています。1つ目は、定数CSV_PATH(図6-2の309行目)で、CSVファイルが置いてある場所を示しています。2つ目は328行目で加工した年月で「202007」の様な文字列になります。3つ目は固定で「.csv」でCSVファイルの拡張子となります。
この3つを結合することで、例えば「"C:¥User¥USER¥csv¥202007.csv"」とファイルを一意に示すことができます。

331行目は、Dictionaryを使用するために、Dictionaryオブジェクトを生成しています。

332行目は、変数ChangeFlagにFalseを代入し、フラグを寝かしています。ChangeFlagはメモリー内の保持データを変更した際にフラグを立て、CSVファイルへの保存を促すものです。
しかし次の333行目の「保持データ削除」を実行することで、このフラグも不要になりますので、ChangeFlag=Falseとするのです。

333行目は「Sched.Count」でDictionaryデータの数を調べてデータがあれば( > 0)、データを全て削除(Sched.RemoveAll)しています。
このCSVreadプロシージャは、年月を変えるごとに呼び出され、CSVファイルが存在すればDictionaryデータを作ります。一方でカレンダーの表示にはその表示月のデータだけを選んで内容表示しますし、CSVファイルへの書込みも表示月のデータだけを選んで書込みますので、「表示に使われていないDictionaryデータが残っていても問題無」さそうです。

しかし、残っている事が前提となると、354行目の「Sched.Add」でDictionaryデータを追加する前に、その存在をExistsメソッドで確かめる必要が出てきます。また、使っていないデータにメモリーを使うのも無駄ですので、今回は「不要になった時点で、古いデータは捨てる」ことにしました。

334行目は「Dir(URL)」を実行しています。Dir関数は、引数にファイルの「パス+ファイル名」を指定すると、返り値として「指定したファイルが存在すればファイル名」が、「存在しなければ空文字("")」が返ってきます。
今回のCSVファイルは年月ごとに作られますが、図6-7の様に「予定の無い年月にはCSVファイルが存在しません」。

データの存在する年月のみCSVファイルが存在
図6-7

ですので、334行目では返り値が空文字("")の時は「CSVファイルが存在しないため、メモリー上の保持データを作らない」事になりますので、CSVreadプロシージャを抜けます。

尚「作らないのであれば、331行目のDictionaryオブジェクトを作る前に実行しても良いのでは」と思う方もいるかもしれません。しかし、データが無くても「Dictionaryオブジェクトは作っておかないといけない」のです。
331行目で作られたSchedオブジェクトを使って、例えば図6-1の256行目では「Sched.Exists(Ldate)」とデータの存在を調べていますが、Schedオブジェクトが無ければエラーが発生してしまいます。同様に、UserForm2を開いた時にもSchedオブジェクトを探して処理しています。
ということで、図6-5の目的は「Schedオブジェクトを作る」ことも目的の1つなのです。

336~341行目は、CSVファイルを開き、データを吸い上げる部分です。
まず336行目はStreamオブジェクトを生成しています。Streamオブジェクトは、ファイルを読み書きする時に使うものですが、当サイトの「CSVファイルの読み込み」も参照願います。

337行目は、文字コードを設定します。設定値である定数CHARACTERは図6-2の311行目で設定していますが、もし既存システムが存在しているならば、そちらに合わせて下さい。全くの新規システムであれば何でもOKです。

338行目でStreamオブジェクトを開き、339行目でファイルを呼び出します。呼び出すファイルは目的のCSVファイルで、334行目で「存在することは確認済み」なので、ここでエラーは出ないはずです。
340行目では、呼び出したファイルの内容をテキストとして読み込みます。読み込んだ文字列は変数bufに代入されます。
341行目で、開いていたStreamオブジェクトを閉じます。

読み込んだデータbufは、一列の文字列です。改行印もvbCrLfとして文字列の中に含まれており、図6-6の9行目の様に「最後端に改行が存在」するファイルも良く見掛けます。
この最後端の改行はデータ処理には邪魔ですので、取り除いておく必要があります。その「最後端の改行を取り除く」のが343~345行目になります。

文字列は図6-8の様に一列に並んでおり、最後端の「Cr + Lf」を1セットずつ取り除き、最後端が「Cr + Lf」では無くなるまで続けます。(図6-8で言えば「"」が最後端になるまで)
読み込んだ文字列の最後端の改行を削除
図6-8

次に、タイトル行、及びデータを1つ区切りに分割する作業をします。タイトル行、及びデータ間は改行である「vbCrLf」で区切られていますので、単純に Split(buf,vbCrLf) とすれば良い様に思えますが、データの中に「Cr + Lf」(=vbCrLf)が含まれていますので、そこで区切られてしまってはデータがバラバラになってしまいます。
そこで347行目では、mySplit関数(図6-24)を使い「"(ダブルクォーテーション)で囲まれた中にある特定の文字列(この場合は改行印)を除いて、特定の文字列で区切る」作業をしています。

ある特定の文字列は今回vbCrLfなので、式で表すと「mySplit(buf , vbCrLf)」のはずです。しかし347行目は「mySplit(buf , vbCr)」となっています。
引数としたいvbCrLfは、実は1文字では無く「vbCr + vbLf」で1+1=2文字列です。図6-24のmySplit関数は「第二引数の区切り文字列は1文字列」とし、第一引数の文字列bufの1文字1文字を第二引数の区切り文字との比較を繰り返す事をしています。
当然ながら1文字≠2文字ですので、mySplit関数は何も返さないということになりエラーが発生してしまいます。

では「mySplit(buf , vbCr)」で分割するとどうなるかを見ていきます。
まず、CSVファイルからデータを読み取り、最後端のvbCrLfを取り除いた「345行目を抜けた時の変数buf」は、図6-9の様になっています。
分割処理をする前のbuf文字列の状態
図6-9

変数bufの文字列の中には「Cr + Lf (=vbCrLf)」がいくつか含まれていますが、濃い色のvbCrLfがデータを区切っているvbCrLfです。薄い色はデータ中の「改行」です。

これを「mySplit(buf , vbCr)」で、「vbCr」で分割します。
mySplit関数は、「"(ダブルクォーテーション)」で囲まれた文字列データの中の改行印は無視しますので、濃い色の「Cr」でデータが切れていき、図6-10の様な行データになります。
データ行分割処理されたbuf1の状態
図6-10

この1つ1つの行データを353行目でもう一度mySplit関数にかけます。但し今度は、行データの「日付」と「内容」とを区切っている「,(カンマ)」で区切ります。(図6-11)
今回のデータでは、「"(ダブルクォーテーション)」で囲まれた中には「,(カンマ)」は含まれていませんが、もし含まれていても無視されます。
データ行内のカンマで分割処理したbuf2の状態
図6-11

図6-11の状態でデータ分割は完了です。ただし日付の先頭にくっついている「Lf」が気になります。この「Lf」は、本当はvbCrLfで区切るべきところを「vbCrで区切ってしまった」ために、残されたカスみたいな存在です。
この後この日付データは、「buf2(0)」として354行目の「Sched.Add Key:=CDate(buf2(0)), Item:=buf2(1)」でDictionaryオブジェクトのキーになりますが、その前に「CDate(buf2(0))」とDate型変換されています。
Date型変換では、文字列が日付っぽかったら日付にしてくれます。先頭に目には見えない「vbLf」がくっついていても何の問題も無く日付になるのです。

反対に347行目で「buf1 = mySplit(buf, vbLf)」と、「vbLf」を使って、分割すると様相は全く変わります。(図6-12)
vbLfでデータを区切るとデータ後端にCrが残る
図6-12

内容データの後端に「vbCr」が残ってしまうのです。
「vbCr」は、ワークシートのセル内では何もしませんが、TextBox内では改行の印になります(ちなみにvbLfは、セル内でもTextBox内でも改行印になります)ので、CSVファイルへの読出・書込を繰り返すたびに改行が増えてしまう事になります。
ですので、347行目は「buf1 = mySplit(buf, vbCr)」としました。

しかし、やっぱり「気持ち悪い」という方のために、「複数文字列を区切り文字に指定できる関数」を作ってみました。それが図6-25の「mySplit2」です。
詳細説明は図6-25のところでしますが、これでしたらvbLfもvbCrも残らずにデータ区切りが可能になります。試行するのであれば、サンプルファイルの347行目の代わりに348行目を生かして下さい。

349行目は、データ行の行数を計算しています。図6-10の例であればタイトル行・データ行含めて4行ですが、配列はゼロから始めています(図6-24の493行目)ので、CSVrow=3となります。

352~355行目は、データ行を「日付」と「内容」に分割し、Dictionaryデータのセットにする部分です。
まず352行目で繰り返し数を決めます。csvファイルにタイトル行が存在する場合はHEADER=1ですから、1から開始させることでタイトル行を除外することが出来ます。

353行目は図6-11でも説明しましたが、「mySplit(buf1(i), ",")」とmySplit関数を使う事で、「,(カンマ)」で「日付」列と「内容」列を分割しています。
分解した「日付」と「内容」は図6-13の様にゼロ始まりの配列となっており、配列のインデックスは「日付=0」、「内容=1」になっています。
日付と内容を分割したbuf2配列
図6-13

354行目ではその日付と内容をDictionaryデータにしています。
まずKeyには、「CDate(buf2(0))」と日付の文字列をDate型に変換してセットしています。またItemには、内容であるbuf2(1)を入れています。

357行目は、336行目で生成したStreamオブジェクトを解除するものです。StreamオブジェクトはCSVファイルを読み込む為に使用したものですが、既に役目は完了しています。

6-2-4.CSVファイルにデータを書き込む(Module2)

図5-15の「ファイル書込・終了」ボタンをクリックする事で実行されるのが、図6-14のCSVwriteプロシージャです。
  1. '========== ⇩(27) CSVファイルにデータを書き込む ============
  2. Sub CSVwrite(YM As Date)
  3.  Dim FN As String               '←正規ファイル名
  4.  Dim RN As String                '←バックアップファイル名
  5.  Dim NewFileOK As Boolean           '←CSVファイルの書込みが成功したか否かのフラグ
  6.  FN = Format(YM, "yyyymm") & ".csv"
  7.  RN = "_" & Format(Now, "yyyymmddhhnnss") & "_" & FN
  8.  If Not Dir(CSV_PATH & FN) = "" Then       '←元ファイル有ったら
  9.   If Not Sched.Count = 0 Then           '←書込みデータ有り
  10.    Call FileReName(FN, RN)           '←元ファイルの名前変更
  11.    NewFileOK = NewFileMake(YM, FN)       '←新ファイル作成
  12.    If NewFileOK = False Then
  13.     MsgBox "新ファイルが作れませんでした。" _
  14.         & vbCrLf & "旧ファイルに戻します。" _
  15.         & vbCrLf & "管理者に連絡して下さい。"
  16.     Call FileReName(RN, FN)          '←元ファイルの名前変更
  17.    Else
  18.     Call FileDelete(RN)            '←予備ファイルの削除(予備ファイル不要の場合)
  19.    End If
  20.   Else                    '←書込みデータ無し
  21.     'Call FileReName(FN, RN)          '←元ファイルの名前変更(予備ファイルとして残す場合)
  22.    Call FileDelete(FN)            '←元ファイルの削除(予備ファイル不要の場合)
  23.   End If
  24.  Else                       '←元ファイル無かったら
  25.   If Not Sched.Count = 0 Then           '←書込みデータ有り
  26.    NewFileOK = NewFileMake(YM, FN)       '←新ファイル作成
  27.    If NewFileOK = False Then
  28.     MsgBox "新ファイルが作れませんでした。" _
  29.         & vbCrLf & "管理者に連絡して下さい。"
  30.    End If
  31.   End If
  32.  End If
  33. End Sub
図6-14

CSVwriteプロシージャは、引数として、表示されているカレンダーの年月をDate型として受取ります。
その年月を使って、365行目では「表示年月のCSVファイル名」を作ります。 366行目では、そのCSVファイル名の前に、「現在の日時」をアンダースコア( _ 印)と共に結合したファイル名を作ります。これはバックアップのファイル名で、正規ファイルが保存出来たあとすぐに削除してしまうのであれば「"BackUp" & FN 」でも良いのです。
しかし、もし「バックアップファイルもしばらく残したい」という時には、保存しようとするバックアップと同じファイル名が残っている可能性が出てきます。もし同じものがあると、実行時エラーが発生し「既に同名のファイルが存在しています。」とマクロが停止してしまいます。
そこでバックアップファイルのファイル名は「決して同名にならない現在の日時」をファイル名に追加しました。
(「ファイル書込」ボタンを連打されると、同一時刻のファイルが出来る可能性は残りますのでご注意を)

また、365~366行目ではFormat関数を使って日付・時刻の書式を変更していますが、例えば「365行目は、拡張子も含めて以下の様に書くこともできるのでは?」と思われる方もいるかもしれません。
  FN = Format(YM, "yyyymm.csv")   ←これは間違い
しかし、「.csv」の文字の中には、Format関数で定められている日時を表す記号が2つ( c と s )も入っており、残念ながら思い通りのファイル名にはなりません。もし書くのであれば、
  FN = Format(YM, "yyyymm.¥c¥sv")  
と、「文字ですよ」という意味になるように「¥」をそれぞれの文字の前につけてエスケープさせてください。

368~393行目はIf文が何層にもなっており複雑な分岐をしています。まず分岐の考えの元となる図5-12を再掲します。
  編集前の状況編集中編集完了時TextBoxのBeforeUpdateイベントデータ処理方法
1 文字列が存在した
(データがあった)
追記・修正した文字列として残った発生データ更新
2 全部削除した空文字になったデータ削除
3 何もしなかった元の文字列のままそのまま
4 空文字だった記入した文字列として残った発生データ新作
5 記入したが消した空文字になったそのまま
6 何もしなかった空文字のまま
図6-15(図5-12と同じ)

図6-15は、メモリー内の保持データでの処理でしたが、これをファイルの立場で書き直したものが図6-16です。
一番右列には、その処理方法に対応したコード番号を記載しました。
  ファイルの存否メモリー上の保持データファイル処理方法コード行
1 (3) ファイルが存在するDictionaryデータが1つ以上あるファイル更新370~380行目
2 Dictionaryデータが無いファイル削除382~383行目
4 (6) ファイルが存在しないDictionaryデータが1つ以上あるファイル新作387~391行目
5 Dictionaryデータが無い何もしない
図6-16

この図6-16の「ファイルの存否」列を判断しているのが368行目のIf文+385行目のElse文、「メモリー上の保持データ」列を判断しているのが369行目のIf文+381行目のElse文、及び386行目のIf文です。
各ブロックごとに説明していきます。

1(3)の「ファイルが存在する」+「Dictionaryデータがある」の場合です。
370行目で、まず「FileReName」プロシージャを呼び出し、存在しているファイルのファイル名を変更します。第一引数に現在のファイル名、第二引数に変更後のファイル名を指定します。ここでは「正規ファイル名(FN) → バックアップファイル名(RN)」としていますので、正規ファイルのファイル名をバックアップファイル名に変更しています。

次に「NewFileMake」関数を呼出し、表示カレンダーの年月のCSVファイルを新作しています。その時のファイル名は正規ファイル名(FN)です。「NewFileMake」関数は、「ファイルが正常に作成されたらTrueを返す」ことにしています。
その返り値をNewFileOK変数で受取り、作成に失敗した(=False)ら、374~377行目を実行します。
失敗時は「作成に失敗しました・・・」とのコメントを出し、377行目で「FileReName」プロシージャを再び呼び出します。
ここでの「FileReName」の引数は370行目の逆ですので、「バックアップファイル名(RN)→正規ファイル名(FN)」となり、バックアップしたファイルを元の正規ファイルに戻しています。
作成が成功した時は、379行目の「FileDelete」プロシージャで、不要になったバックアップファイル(RN)を削除します。

2の「ファイルが存在する」+「Dictionaryデータが無い」の場合です。
383行目で「FileDelete」プロシージャを呼び出し、不要になった正規ファイル(FN)を削除します。
その前の382行目に「FileReName」をコメントアウトしてありますが、383行目をコメントアウトにし382行目を生かすと「バックアップファイル(RN)を残してから正規ファイルを削除する」という意味になります。

4(6)の「ファイルが存在しない」+「Dictionaryデータが有る」の場合です。
バックアップする元ファイルがありませんから、387行目で正規ファイル(FN)を新作します。
「NewFileMake」関数の返り値がFalseであれば「ファイル作成失敗」という意味ですので、389~390行目でコメントを出し終了します。なお、バックアップファイルがありませんから、戻すことも不可能です。

5の「ファイルが存在しない」+「Dictionaryデータが無い」場合は、何もしませんので、分岐先はありません。

なお、図6-14はIf文を重ねて分岐させていますが、他の方法もあります。図6-17で紹介します。
  1. '========== ⇩(28) CSVファイルにデータを書き込む(Select Case 版) ================
  2. Sub CSVwrite2(YM As Date)
  3.  Dim FN As String    '←正規ファイル名
  4.  Dim RN As String    '←バックアップファイル名
  5.  Dim NewFileOK As Boolean
  6.  FN = Format(YM, "yyyymm") & ".csv"
  7.  RN = "_" & Format(Now, "yyyymmddhhnnss") & "_" & FN
  8.  Select Case (Not Dir(CSV_PATH & FN) = "") * 2 + (Not Sched.Count = 0)
  9.           '↑元ファイル有ったら            ↑書込みデータ有り
  10.   Case -3              '(元ファイル有り+書込みデータ有り)
  11.    Call FileReName(FN, RN)            '←元ファイルの名前変更
  12.    NewFileOK = NewFileMake(YM, FN)        '←新ファイル作成
  13.    If NewFileOK = False Then
  14.     MsgBox "新ファイルが作れませんでした。" _
  15.         & vbCrLf & "旧ファイルに戻します。" _
  16.         & vbCrLf & "管理者に連絡して下さい。"
  17.     Call FileReName(RN, FN)           '←元ファイルの名前変更
  18.    Else
  19.     Call FileDelete(RN)              '←予備ファイルの削除(予備ファイル不要の場合)
  20.    End If
  21.   Case -2               '(元ファイル有り+書込みデータ無し)
  22.     'Call FileReName(FN, RN)            '←元ファイルの名前変更(予備ファイルとして残す場合)
  23.    Call FileDelete(FN)               '←元ファイルの削除(予備ファイル不要の場合)
  24.   Case -1                '(元ファイル無し+書込みデータ有り)
  25.    NewFileOK = NewFileMake(YM, FN)         '←新ファイル作成
  26.    If NewFileOK = False Then
  27.     MsgBox "新ファイルが作れませんでした。" _
  28.         & vbCrLf & "管理者に連絡して下さい。"
  29.    End If
  30.  End Select
  31. End Sub
図6-17

図6-17の404行目のSelect文では「 (Not Dir(CSV_PATH & FN) = "") * 2 + (Not Sched.Count = 0)」を評価式にしています。
前半の「(Not Dir(CSV_PATH & FN) = "")」は、図6-14の368行目のIf文と同じで、「ファイルが存在すればTrue」となります。Excelの中でTrue、Falseを計算に使うと「True=-1」「False=0」です。
一方、後半の「 (Not Sched.Count = 0)」は「保持データが存在すればTrue」となります。
ですので「 (Not Dir(CSV_PATH & FN) = "") * 2 + (Not Sched.Count = 0)」の計算は、図6-18の様になります。
ファイルが存在保持データが存在 (Not Dir(CSV_PATH & FN) = "") * 2 + (Not Sched.Count = 0)の値
1(3)(-2)+(-1)= -3
2(-2)+( 0)= -2
4(6)( 0)+(-1)= -1
5( 0)+( 0)=  0
図6-18

この表の右列の値をSelect Case文で仕訳けて、図6-16の内容を実行していけば、図6-14と同じ結果が得られます。
If文を多重にすると行頭が揃わなくなりますが、Select Case だと揃えられるので少しコードが読み易くなるかもしれません。試してみるのも良いかと思います。

6-2-5.CSVファイルを新作する(Module2)

図6-14の371・387行目から呼び出されるのが、図6-19のNewFileMake関数です。引数として、「年月(YM)」及び「保存するファイル名(Fname)」を渡されます。
FnameはYMから作ります(例えば、図6-14の365行目)ので、引数としてはYMのみでも成立します。今回は同じ式を繰り返すのを嫌って引数を2つにしましたが、YMのみにした場合は、プロシージャ内に変換式(図6-14の365行目相当)を記述して下さい。
  1. '========== ⇩(29) DictionaryデータからCSVファイルを作成する ===============
  2. Function NewFileMake(YM As Date, Fname As String) As Boolean
  3.  Dim St As Object
  4.  Dim buf As String
  5.  Dim i As Date
  6.  Const adWriteLine = 1
  7.  Set St = CreateObject("ADODB.Stream")
  8.  St.Charset = CHARACTER
  9.   'st.Type = AdTypeText           '既定値(2) 
  10.  St.Open
  11.   If HEADER = 1 Then
  12.    St.WriteText """日付"",""内容""", adWriteLine
  13.   End If
  14.  For i = YM To DateSerial(Year(YM), Month(YM) + 1, 0)
  15.   If Sched.Exists(i) = True Then
  16.    buf = i & ",""" & Sched(i) & """"
  17.    St.WriteText buf, adWriteLine
  18.   End If
  19.  Next i
  20.  On Error Resume Next
  21.   St.SaveToFile CSV_PATH & Fname      ', adSaveCreateNotExist (既定=1)
  22.   If Err.Number = 0 Then NewFileMake = True
  23.  On Error GoTo 0
  24.  St.Close
  25.  Set St = Nothing
  26. End Function
図6-19

436行目は、CSVreadプロシージャ(図6-5の336行目)と同様にStreamオブジェクトを生成します。
437行目は文字コードを設定します。設定値のCHARACTER定数は、図6-2の311行目で設定してあるものです。

438行目は、StreamオブジェクトのTypeプロパティにAdTypeText(数値=2)を設定するものですが、コメントアウトしてあります。

Streamオブジェクトを「事前バインディング」(ツールの参照設定で「Microsoft ActiveX Data Objects x.x Library」をON)する場合には、定数「AdTypeText」には「2」という値が設定されるのですが、今回の「実行時バインディング」(436行目のCreateObjectを使用)では「AdTypeText」には値が入っていません。
ですので、実行時バインディングの場合は、プロパティ設定の前に、使用する定数の宣言と値代入が必要となります。
しかし今回Typeプロパティに設定したいAdTypeTextは既定値であるため、あえてプロパティ設定をしなければAdTypeText(値2)が入る為にコメントアウトにしています。

439行目でStreamオブジェクトを開きます。
441行目は、定数HEADERが1(=Header有)であれば、442行目を実行しタイトル行を作成します。
ここで使用されている「WriteText メソッド」はStreamオブジェクトにテキストを書き込むの意味で、第一引数に「書き込む文字列」、第二引数には「文字列の末尾に行区切り記号を書き込む」のadWriteLineを指定します。
なおadWriteLineは「CrLf」を書き込むとは限りません。「LineSeparator プロパティ」で指定した「adCR」・「adCRLF」・「adLF」のどれかで行区切りをするのです。但し「adCRLF」が既定値ですので、何も指定していない為に「CrLf」が区切り文字になっているのです。

さてタイトルにする文字列ですが、複数の「"(ダブルクォーテーション)」が連続しており分かり難くなっています。

CSVファイルのタイトル行文字列のダブルクォーテーションの意味
図6-20

442行目のWriteTextメソッドの第一引数には「文字列」を指定することになるので、両端のダブルクォーテーションは「全体を囲み、1つの文字列とするためのダブルクォーテーション」となります。残りは2つずつのダブルクォーテーションが4組あり、ダブルクォーテーション2つで「1つのダブルクォーテーションという文字」を表しています。

445行目はFor~Next文で、カウント変数iは「カレンダーの初日(引数YM)」から「最終日」までを順に調べながらCSVデータを作っていきます。尚、最終日の計算方法は「月の最終日の計算方法」を参照下さい。
446行目は、カウント変数i(日付)のKeyが Dictionaryデータに存在するかを確認し、存在すれば「i & ",""" & Sched(i) & """"」を変数bufに代入します。

ここでもダブルクォーテーションが連続して使用されているので、図6-21で説明しておきます。
CSVファイルのデータ行文字列のダブルクォーテーションの意味
図6-21

4連続のダブルクォーテーションの内、外側のダブルクォーテーションは文字列として囲ってるものであり、中の2個は、「2つで1つのダブルクォーテーションという文字」を表しています。

ここでカウント変数iはDate型ですので、当初447行目の先頭のiには「2020/07/01 00:00.00」のように「時刻も文字列として入ってしまうのでは」と考えていました。その為、Format関数を使ったり、Date型をClngでシリアル値にしたりしていました。
しかし、Date型でも日付のみ(Double型で言うと小数点以下の無い整数値)の場合は、時刻の無い日付として出力されるようですので、何も変換せずに直接iを使うようにしています。

448行目では、組み立てた1行のデータをStreamオブジェクトに書き込んでいます。第二引数のadWriteLineは442行目と同じです。

タイトル行、データ行のStreamオブジェクトへの書込みが完了すると、453行目でSaveToFileメソッドを使って「Streamオブジェクトに書き込んだ内容を、ファイルに出力」します。出力先は、第一引数である「CSV_PATH & Fname」ですので、カレンダー表示月のCSVファイルになります。

しかし、万一書込みが失敗した場合にはエラーが発生します。そのためエラーとラップとして451行目に「On Error Resume Next」を置き、エラーが出ても進ませます。書込み実行後453行目でエラー番号を調べ(エラーが出たら、Err.Numberはゼロ以外が出る)、ゼロだったら(=正常に書込みが完了した)Functionプロシージャの返り値としてTrueを代入します。
なお、ゼロ以外(エラーが出た)の場合は、既定値であるFalseがFunctionプロシージャの返り値になります。

456行目でStreamオブジェクトを閉じ、457行目でStreamオブジェクトを解放します。

6-2-6.CSVファイルを削除(Module2)

図6-14の379・383行目から呼び出されるのが、ファイルを削除するFileDeleteプロシージャです。(図6-22)
引数としては「削除するファイル名」を受取ります。
  1. '========== ⇩(30) CSVファイルを削除する ===============
  2. Sub FileDelete(Fname As String)
  3.  Dim Fso As Object
  4.  Set Fso = CreateObject("Scripting.FileSystemObject")
  5.  Fso.GetFile(CSV_PATH & Fname).Delete
  6.  Set Fso = Nothing
  7. End Sub
図6-22

462行目ではFileSystemObjectオブジェクトを生成します。 463行目では、引数のファイル名をGetFileの引数にすることで、そのファイル名をFileオブジェクトとして受取り、FileオブジェクトのDeleteメソッドでファイル削除をしています。
463行目の代わりに「Fso.Deletefile (Fname)」を使っても同じ結果になります。尚この場合の「Deletefile」はFileSystemObjectオブジェクトのDeletefileメソッドです。
どちらでも、分かり易い方を使用して下さい。

6-2-7.CSVファイルの名前の変更(Module2)

ファイル名を変更するプロシージャが図6-23です。図6-14からは、370・377・382行目から呼び出されます。
引数は2つあり、第一引数には変更前の既存のファイル名、第二引数には変更後のファイル名を指定します。
当然ながら第二引数のファイル名が既存のファイル名だとエラーが発生します。
  1. '========== ⇩(31) CSVファイルの名前を変更する =============
  2. Sub FileReName(Name1 As String, Name2 As String)
  3.  Dim Fso As Object
  4.  Set Fso = CreateObject("Scripting.FileSystemObject")
  5.  Fso.GetFile(CSV_PATH & Name1).Name = Name2
  6.  Set Fso = Nothing
  7. End Sub
図6-23

470行目は、FileSystemObjectオブジェクトを生成します。
472行目では、引数のファイル名をGetFileの引数にすることで、そのファイル名をFileオブジェクトとして受取り、FileオブジェクトのNameプロパティでファイル名を付け直しています。
他の方法として「Fso.MoveFile Name1, Name2 」を使うこともできます。MoveFileはFileSystemObjectオブジェクトのメソッドで「ファイルを移動」するのが主目的ですが、「同じフォルダで異なるファイル名」を使うことで、ファイル名変更することが出来ます。

6-2-8.文字列をDelimiter(単文字列)で区切り、配列で返す(Module2)

図6-5の347・353行目から呼び出されるもので、文字列を特定の文字列(Delimiter)で区切り、配列として返す関数です。これだとSplit関数と同じなのですが、このmySplit関数は「文字列の中のDelimiter文字列は無視」するようにしています。
この関数については「CSVファイルの読み込み」で詳細説明していますので、そちらを参考にして下さい。
  1. '========== ⇩(32) 文字列をDelimiter(単文字列のみ)で区切り、配列で返す ========
  2. Function mySplit(buf As String, Delimiter As String) As String() '出力をString型へ
  3.   '//buf=対象文字列 '//Delimiter=区切り文字
  4.  Const MOJI As String = """"    '←文字列を囲っている文字(ここでは「"」)を指定
  5.  Dim MOJI_count As Boolean     '←MOJIを数える変数。奇数ならFalse、偶数ならTrue
  6.  Dim SP() As String         '←String型へ。1行分のデータをカンマ印で分割した値を入れる配列
  7.  Dim buf1 As String         '←bufから切り出した1文字
  8.  Dim buf2 As String         '←切り出した文字をまとめた文字列
  9.  Dim i As Long, cnt As Long     '←カウンタ変数
  10.  buf = buf & Delimiter
  11.  For i = 1 To Len(buf)
  12.   buf1 = Mid(buf, i, 1)
  13.   If buf1 = MOJI Then
  14.    MOJI_count = Not MOJI_count
  15.   ElseIf (buf1 = Delimiter And MOJI_count = False) Then
  16.    ReDim Preserve SP(0 To cnt)
  17.    cnt = cnt + 1
  18.    SP(UBound(SP, 1)) = buf2
  19.    buf2 = ""
  20.   Else
  21.    buf2 = buf2 & buf1
  22.   End If
  23.  Next i
  24.  mySplit = SP
  25. End Function
図6-24

なお「CSVファイルの読み込み」内のmySplit関数と異なるところが1箇所だけあります。関数の戻り値である配列のデータ型をVariant型 → String型に変更しました。
戻り値側をVariant型にしたその当時の理由は、戻り値の配列の内容は「片方に日付の値、もう片方にString型の内容」と型が混ざった値が入るためVariant型の方がBetterだろう、と考えたからです。

しかし今回のシステムでは、347行目で「vbCr」で区切り、その区切ったデータを353行目で「,(カンマ)」で区切っています。簡単に言うと1回目の出力が2回目の入力になるのです。
もともとのコードは出力がVariant型で入力がString型でしたので、2回目でエラーが発生してしまいました。汎用性の高い関数ほど、入力・出力のデータ型には注意が必要だと反省しています。

6-2-9.文字列をDelimiter(複数文字列可)で区切り、配列で返す(Module2)

図6-24のmySplit関数では、第二引数のDelimiterは単文字列としてアルゴリズムを考えました。しかしvbCrLfのような2文字で文字列を区切る場合には使えない事が分かりました。
(今回のシステムでは、vbCr の1文字で区切ることで運よく成立することは出来ましたが)
そこでDelimiterに複数文字列を使える区切り方法を図6-25で紹介します。
  1. '========== ⇩(33) 文字列をDelimiter(複数文字列可)で区切り、配列で返す  ==========
  2. Function mySplit2(buf As String, Delimiter As String) As String()
  3.     '//buf=対象文字列 //Delimiter=区切り文字列
  4.  Const MOJI As String = """"   '←文字列を囲っている文字(ここでは「"」)を指定
  5.  Dim SP() As String        '←String型へ。1行分のデータをカンマ印で分割した値を入れる配列
  6.  Dim PStart As Long        '←Delimiterで分割される文字列の先頭位置
  7.  Dim PEnd As Long         '←Delimiterで分割される文字列の最終位置
  8.  Dim PMid As Long
  9.  Dim MOJIcount As Long      '←その位置までに出てきたMojiの数
  10.  Dim cnt As Long
  11.  Dim Pbuf As String
  12.  cnt = 0       '←データ行の要素数(ゼロ始まり)
  13.  PStart = 1     '←データの先頭位置
  14.  PMid = 1      '←検索の先頭位置
  15.  If Delimiter = "" Then
  16.   ReDim Preserve SP(0 To 0)
  17.   mySplit2 = SP    '←Delimiterが空だと関数内でエラーが出る為、空配列を戻して終了させる
  18.   Exit Function
  19.  End If
  20.  buf = buf & Delimiter   '←同じルーチンコードでデータ区切りを可能にする為、データ塊の最後に区切り文字を結合
  21.  Do
  22.   Do
  23.    PEnd = InStr(PMid, buf, Delimiter)
  24.    Pbuf = Mid(buf, PStart, PEnd - PStart)
  25.    MOJIcount = Len(Pbuf) - Len(Replace(Pbuf, MOJI, ""))
  26.    If MOJIcount Mod 2 = 0 Then
  27.     ReDim Preserve SP(0 To cnt)
  28.     SP(cnt) = Pbuf
  29.     cnt = cnt + 1
  30.     Exit Do
  31.    End If
  32.    PMid = PEnd + Len(Delimiter)
  33.    DoEvents: DoEvents
  34.   Loop While Not PEnd = Len(buf) - Len(Delimiter) + 1
  35.   PStart = PEnd + Len(Delimiter)
  36.   PMid = PStart
  37.   DoEvents: DoEvents
  38.  Loop While Not PEnd = Len(buf) - Len(Delimiter) + 1
  39.  mySplit2 = SP
  40. End Function
図6-25

アルゴリズムはmySplitとは全く異なります。図6-26で説明します。
複数文字列をDelimiterにした時のロジック
図6-26

まず文字列の先頭からDelimiterをInStr関数で捜します(③)。探し当てた位置までの文字列の中に「データを囲む記号(ここではダブルクォーテーション)」が何個あるか数えます(④)。
「データを囲む記号」は、先頭と最後尾を囲んでいますので必ず偶数です。先頭から数えていき、もし奇数ならば「データの中に居る」と考えられます。
ですので、偶数であればデータの区切りと判断し、そこまでのデータは戻り値用の配列に格納します。
奇数の場合は、先程見つけたデータ中のDelimiterのすぐ後ろを探索の先頭にして、それ以降でDelimiterを再び探します。
文字列を全て探せば、必ず「データを囲む記号」は偶数個になるはずですので、そこで最後のデータを配列に格納できます。

以上の考え方を踏まえて、図6-25のコードを説明します。
516~518行目は、データを格納する配列の要素数(cnt変数)と検索の開始位置を示すポインター(PSart、PMid)の初期化です。ポインターのPStartはデータの先頭位置を表し、もう一方のPMidはDelimiterを捜す際の先頭位置を現しています。

520~524行目は、関数を呼び出す時にDelimiterが空文字の場合にはエラーが発生してしまうため、空の配列を戻してエラーを回避しています。

526行目は引数で渡された文字列の最後にDelimiterを結合しています。
これは、文字列の最後のデータを取り出す時にも、途中のデータ取り出しと同じルーチン(Delimiterを検出しダブルクォーテーションの個数が偶数だったらデータと見なす)で進められるようにするためです。(図6-26の中の⑩の状態)

527~545行目の中にはDo~Loopが二重になっています。内側のDo~Loopは528~540行目になりますが、内側は1つのデータを確定して配列に格納するまでの処理で、外側は次のデータに移る処理の役目になっています。

529行目はDelimiterを検出しています。検索のスタート位置はPMidで、Delimiterの次の文字から(開始時は先頭文字から)です。
530行目は529行目で検出したDelimiterの手前までの文字列を取得しPbuf変数に代入しています。この時のスタート位置は529行目とは少し異なり、データの区切りと確定したDelimiterの次の文字からになります。

531行目は530行目で切り出した文字列Pbufの中に、何個の区切り文字MOJI(今回はダブルクォーテーション)が入っているか計算しています。手法としては図6-27の様になってます。
文字列を囲むダブルクォーテーションの数の調べ方
図6-27

ある文字列(Pbuf)の中に「データを囲む区切り文字MOJI(ここではダブルクォーテーション)」が何個あるかを数えるには、「データを囲む記号MOJI」をReplace関数で「長さゼロの文字列」に置換し、元の文字列の中さと比較をするのです。
置換されると、その文字としては「長さ1→長さゼロ」になりますので、「置換された個数=短くなった長さ」となるのです。

532行目では、偶数か奇数かを判断しています。「データを囲む記号MOJI」の個数に対して「Mod関数(割った余りを計算)」を使い、2で割って余りがゼロなら偶数となります。
「偶数=データの区切り」ですので、533~536行目を実行します

まず533行目ではデータを入れる配列SPのサイズを1つ大きくします(インデックスはゼロ始まり)。
534行目では、サイズアップした配列SPの最後に切り出した文字列を格納します。
535行目では次のデータのためにcnt変数(データの数量)を1つ増やしてから、Exit Doで内側のDo~Loopを抜けます。

一方、奇数だった場合は「まだデータの途中」の為 Do~Loop内の529行目に戻りますが、その前に538行目を実行します。
529行目で再度Delimiterを検索するのですが、このままでは「またデータ途中のDelimiterを検出してしまう」ため、検索のスタート位置を移動してあげる必要があります。そこで538行目では、検出したDelimiterの次の文字に検索スタート位置(PMid)をずらしています。
539行目の「DoEvents:DoEvents」は、万一内側Do~Loopが無限になってしまった時の為のおまじないです。
「:(コロン)」でつないでいるのは、2つのコードを「1行で記述している」という意味になります。

536行目で内側Do~Loopを抜けた(=データ区切りのDelimterを見つけた)ら、542行目に移ります。
542行目では「新たなデータの検索」に移行したため、検索スタート位置をDelimiterの次の文字位置に移動させます。543行目は、その同じ位置をPMidに代入していますが、これは517~518行目で先頭位置を揃えるのと同じ意味になります。
544行目の「DoEvents:DoEvents」は、外側Do~Loopが無限になってしまった時の対策です。

尚、540・545行目のDo~Loopを回している条件式を「Not PEnd = Len(buf) - Len(Delimiter) + 1」としています。右辺の「 Len(buf) - Len(Delimiter) + 1」は、図6-26で分かるように「最後のDelimiterを検出した位置」であり、PEndがその位置に来るとNotが付いているために条件式がFalseになりDo~Loopが終了します。
ピンポイントでFalseを出しているため、もし心配であるなら「もし通り過ぎても止まる」ように「Loop While PEnd < Len(buf) - Len(Delimiter) + 1」とする方法も良いと思います。

547行目では、分割したデータを格納した配列SPを、Function関数の戻り値とするためmySplit2に代入しています。

7.最後に

自分の中では「CSVファイルへの書き出し」をメインテーマにExcelを作ったつもりでしたが、説明を書き進めてみるとDictionaryデータやデータの分割、TextBoxのイベント等の方がメインになってしまった感があります。
またこの予定表はExcelとして一応完成したものの、インターネット・イントラネット上のサイトでデータを共有しようとすればHTML等でCSVファイルを取り込めるようにする事はほぼ必須とも思っています。
追々やっていかないと言う認識は持っていますので、まずは使えそうな環境でお役に立てばと思います。

一方、今回は「FileSystemObject」や「Stream」を使ってファイル削除・ファイル名変更・ファイル作成などの操作がありましたが、その様な操作を権限として許可されているのか否かを、もう少し考慮しなければ・・・と思っています。データをマクロを記述してあるExcel以外に保存しようとした時には必ず発生する問題とも思いますので、充分なチェックと試行が必要と思います。


CSVファイルでデータを読み書きする月間予定表(it-033.xlsm)
2020年7月CSVデータ(202007.csv)
・サンプルファイルで試行する場合には、CSVファイルを保管するフォルダを作り、そのフルパスをModule2の先頭の定数「CSV_PATH」に指定してから動かす様にして下さい。
・2番目の添付ファイル「202007.csv」はサンプルデータですので、そのCSV保管フォルダにコピーしなくても構いません。
・CSVのサンプルデータを使わない場合、サンプルファイルを起動させても初期状態では予定は表示されません。

セキュリティ向上を目的として「インターネット経由でダウンロードしたOfficeファイル(Excel等)のマクロは、既定でブロック」されるようにOfficeアプリケーションの既定動作が変更になりました。(2022年4月より切替開始)
解除の方法については「ダウンロードファイルのブロック解除方法」を参照下さい。