Google Calendar を定期的にバックアップする PowerShell スクリプト

# Google Calendar をマスターカレンダーとして使う

生活スタイルを見直す機会が出来たので、公私で利用しているカレンダーを整理することにした。

カレンダーを利用するデバイスは PC（自宅・会社共に Windows）と iPhone。
アプリケーションは PC では Thunderbird（Lightningアドオン）、
iPhone のカレンダーApp と LifeBear を使う。

今後、マスターカレンダーを Google Calendar として、それぞれのアプリからカレンダーを同期するように設定させる。まずは前準備として予定データをマージすることにした。

## 予定データを Google Calendar に集約する

マージ対象は、Google Carlendar に登録していた過去の予定と、iCloud に登録していた予定である。
どちらのアプリも iCal 形式で予定をエクスポートできるため、次の方法でマージした。重複予定の削除には Outlook Duplicate Items Remover (ODIR) を利用した。詳しい方法は参照情報のリンク先に譲る。

1. PC に iCloud.com をインストール後、カレンダーを一時公開して iCal 形式でダウンロードする。（参考情報：[iCloud に保存している情報をアーカイブまたはコピーする#カレンダー](https://support.apple.com/ja-jp/HT204055#calendars)）
1. PC で Google Calendar から iCal ダウンロード URL を調べてダウンロードする。（参考情報：[カレンダーをパソコン上のプログラムと同期する](https://support.google.com/calendar/answer/37648?hl=ja)）
1. PC に Outlook をインストールし、予定データを*予定表*にインポートする。
1. PC に ODIR をインストールし、*予定表*の重複予定を削除する。（参考情報：[Googleカレンダーに発生した重複スケジュールを削除する方法（Outolook 2010）](http://it-danshari.nextrange.net/2015/08/26/google%E3%82%AB%E3%83%AC%E3%83%B3%E3%83%80%E3%83%BC%E3%81%AB%E7%99%BA%E7%94%9F%E3%81%97%E3%81%9F%E9%87%8D%E8%A4%87%E3%82%B9%E3%82%B1%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB%E3%82%92%E5%89%8A%E9%99%A4/)）
1. PC で Outlook の*予定表*を iCal 形式で保存する。（メニューから「名前を付けて保存」）

## Google Calendar を定期的にバックアップする PowerShell スクリプト

マスターカレンダーを Google Calendar にすると、各アプリで予定を追加・削除しても、他のアプリでカレンダーが同期されるため便利だが、消失が怖い。
そこで、定期的に Google Calendar をバックアップすることにした。

Google Calendar の設定画面にはちゃんとカレンダーを書き出す方法がある。しかし、手動でやるのは面倒だしスクリプトを作成することにした。探せばスクリプトを作らずともバックアップする方法がきっとあるのでしょうが、最近プログラミングしていないのでリハビリを兼ねている。

やりたいことは URL からファイルをダウンロードするだけ。ゴテゴテした環境は用意したくなかったので、PowerShell を使うことにした。

今回は未実装だが、タスクマネージャから実行できるよう PowerShell はスクリプトとして用意。バックアップ期間はタスクマネージャの設定で調整することとする。
ダウンロードするカレンダーは外部ファイルに非公開URLをリストアップしておき、スクリプト実行時に読み込むことにする。
ダウンロードしたファイルはフォルダにまとめて ZIP で圧縮してみる。（完全に趣味）

```ps1:GoogleCalendarDownloader.ps1
#
# スクリプト名：GoogleCalendarDownloader.ps1
#
# 動作概要    ：
#   外部ファイル callist.txt の非公開 URL からカレンダーデータ（ics形式 / UTF8）を
#   ダウンロードして、実行日のフォルダ配下に「カレンダー名.ics」として保存して zip 圧縮する。
#
#   例えば、2017/01/02 にカレンダー Private, FoodCompany, カレンダーを指定して実行した場合、
#      [2017/01/02.zip]
#        + 2017/01/02            フォルダ
#           - Private.ics        カレンダー Private の予定データ    （iCal 形式）
#           - FoodCompany.ics    カレンダー FoodCompany の予定データ（iCal 形式）
#   といったファイルを、スクリプトと同フォルダに保存する。
#
# 動作要件    ：
#   PowerShell 5.0 以降（Compress-Archive を使っているから)
#
# 修正履歴    ：
#   2017/10/09  tatsurou  ver1.0  初版(書いたもののProxyは使わなかったので動作テスト未実施)
# 

# コマンドレットのパラメータとして、ファイルを保存するパス、ダウンロードファイル・リストのファイル名を宣言
Param(
  [String]$strSaveFolderPath       = (Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) (Get-Date).ToString("yyyyMMdd")),
  [String]$strFilePathCalendarList = (Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) 'callist.txt'),
  [String]$strProxyURL             = "",
  [String]$strProxyUser            = "",
  [String]$strProxyPassword        = ""
)

# ファイル保存先が無ければ作成する
if(! (Test-Path -PathType Container $strSaveFolderPath) ) {
  New-Item -ItemType Directory $strSaveFolderPath
  Write-Verbose "Create Directory. Path: ${strSaveFolderPath}"
}

# ファイルをダウンロードするためのWebClientオブジェクトを生成
$wc = New-Object System.Net.WebClient
if ($wc -eq $null) {
  Write-Warning "New-Object() failed. cannot create object of System.Net.WebClient."
  exit 1
}

# 外部ファイルのカレンダーリストを読み込む（各行をタブ区切りデータとする）
# ※ Encoding を使うには PowerShell 3.0 以上である必要がある。
#    （参考情報：https://docs.microsoft.com/ja-jp/powershell/module/Microsoft.PowerShell.Utility/Import-Csv?view=powershell-5.1）
$arrObjTargetCalendarList = Import-Csv $strFilePathCalendarList -Delimiter "`t" -Encoding UTF8

# ファイルリストから順にカレンダーをダウンロードして保存
foreach($objCalendar in $arrObjTargetCalendarList){
  $uriCalendar = New-Object System.Uri($objCalendar.ダウンロードURL)
  $strCalendarFileName = [System.IO.Path]::ChangeExtension($objCalendar.カレンダー名, ".ics")
  $strSaveCalendarPath = (Join-Path $strSaveFolderPath $strCalendarFileName)
  
  # 指定された URL からカレンダーをダウンロードし、カレンダー名で保存
  if ($strProxyURL -ne "") {
    $proxy = New-Object System.Net.WebProxy($strProxyURL, $true)
    $proxy.Credentials = New-Object System.Net.NetworkCredential($strProxyUser, $strProxyPassword)
    $wc.Proxy = $proxy
  }
  $wc.DownloadFile($uriCalendar, $strSaveCalendarPath)

  Write-Verbose "Saved Calendar. Path: ${strSaveCalendarPath}"
}

# ダウンロードしたファイルをフォルダごと圧縮。圧縮が成功したら元データは削除。
$strSaveCompFilePath = [System.IO.Path]::ChangeExtension($strSaveFolderPath, ".zip")
Compress-Archive -Path $strSaveFolderPath -DestinationPath $strSaveCompFilePath -Update
if ( (Test-Path -PathType Leaf $strSaveCompFilePath) -and `
     (Test-Path -PathType Container $strSaveFolderPath) ) {
  Remove-Item $strSaveFolderPath -Force -Recurse
}
```

初めて PowerShell で 20 行以上書いた。
初歩的な PowerShell の構文について等、調べた内容を参考までに下記に記載しておく。

----

## 参考情報
### タブ区切りのテキストを読み込む方法

Import-CSV はオプションで区切り文字を指定できるため、
タブを指定すればタブ区切りのテキストが読み込める。
（Import-CVS の参考情報：[PowerShell 3.0 - Import-Csv](https://docs.microsoft.com/ja-jp/powershell/module/Microsoft.PowerShell.Utility/Import-Csv?view=powershell-3.0)）

```ps1:例）タブ区切りのテキストファイルfile.txtを読み込む場合
PS> Import-CSV file.txt -Delimiter "`t"
```

#### Import-CSV における CSV ファイルのデータフォーマットについて

Import-CSV は読み取り対象のファイルの 1 行目に項目が指定されていることを前提としている。
もし 1 行目に項目名が書かれていない、データのみの CSV ファイルを読み取りたい場合は
オプション -Header を使って項目名を String[] で指定する。

項目名が指定されない場合は 1 行目が項目名として読み込まれる。
空行が指定された場合は、"警告" を出力して既定の項目名が利用されるが 1 番目の要素しか読み込まれない。

```text:
警告: 指定されていないヘッダーが 1 つ以上ありました。指定されていないヘッダーには、"H" で始まる既定の名前を代わりに使用しました。
```
既定の項目名は **H1** となる。

 
```text:memberlist.txt
 no,name
 1,Alice
 2,Bob
 3,Charlie
```

```ps1:例）項目名を指定してCSVファイルmemberlist.txtを読み込む場合
 PS> $header = "no","name"
 PS> $P = Import-CSV memberlist.txt -Encoding UTF8 -Header $header
 PS> $P | Format-Table
 
 no         name                                                                                                                                               
 --         ---- 
 1	Alice
 2	Bob
 3	Charlie
```

#### Import-CSV で読み込むファイルのエンコードを指定する方法（PowerShell 3.0 以降）

オプション -Encoding を使って "Unicode, UTF7, UTF8, ASCII, UTF32, BigEndianUnicode, Default, OEM" から選択できる。
デフォルトは ASCII である。

```ps1:例）UTF8でエンコードされたCSVファイルfile.csvを読み込む場合
PS> Import-CSV file.csv -Encoding UTF8
```

#### Import-CSV で読み取った CSV 要素の確認方法

Import-CSV で読み取った結果は Object 型の配列である。
要素は System.Management.Automation.PSCustomObject 型のデータである。

```text:memberlist.txt
 no,name
 1,Alice
 2,Bob
 3,Charlie
```

```ps1:
 PS > $csv = Import-CSV memberlist.txt -Encoding UTF8
 PS > $csv.GetType().fullname
 System.Object[]
 PS > $csv[0].GetType().fullname
 System.Management.Automation.PSCustomObject
```

PSCustomObject は PowerShell の処理時に動的に作成されるオブジェクトである。（参考情報：[morituriのブログ - PowerShellのカスタムオブジェクト（PSCustomObject）の使い方](http://blog.livedoor.jp/morituri/archives/54579410.html)）
CSV 解析した結果の（又は指定された）項目名がプロパティ名として追加されている。
値は文字列で指定されているとして処理すればよい。

```ps1:
PS > $csv | Get-Member

   TypeName: System.Management.Automation.PSCustomObject

Name        MemberType   Definition                    
----        ----------   ----------                    
Equals      Method       bool Equals(System.Object obj)
GetHashCode Method       int GetHashCode()             
GetType     Method       type GetType()                
ToString    Method       string ToString()             
name        NoteProperty string name=Alice             
no          NoteProperty string no=1   

PS > $csv[0].no
1
PS > $csv[0].name
Alice
PS > $csv[0].no.GetType().fullname
System.String

PS > foreach ($cal in $csv) { Write-Output $cal.name }
Alice
Bob
Charlie
```

----

### PowerShell の基本情報

- PowerShell の関数リファレンス
  - https://docs.microsoft.com/ja-jp/powershell/scripting/powershell-scripting?view=powershell-3.0

- 色々コマンドレットの動作を調べたい場合は、スクリプトファイルを作ると編集と実行が必要となって手間なので、
PowerShell ISE から実行するとよい。（インタプリタの利点）

- スクリプトファイルを作る場合は拡張子は "ps1" なので注意。
また、拡張子が ps1 のファイルはエクスプローラからダブルクリックしても実行されない。
PowerShell 上でスクリプトファイルを指定して実行する必要がある。

- コマンドレットの動作を調べるためには Get-Help コマンドレットを使うとよい。
Linux の man コマンドのようなものである。

#### ソースコードの式を途中で折り返す方法

参考情報：http://bayashita.com/p/entry/show/87

ソースコード上で、 1 つの式の途中で改行するには折り返す行の行末にバッククォート（`）を記述します。
使い方は VB や VBA で式の改行に使用するアンダースコア ( _ ) と同じです。

#### if 構文について

```text:書式）
if (条件式) ＜文 or ブロック文＞
```

```ps1:例）
$a = 10
if ($a -lt 30) {
  "${a}は30より小さい。"
}
```

#### 比較演算子について

参考情報：http://www.sakutyuu.com/technology/?p=117

-eq, -ne, -gt, -lt, -ge, -le は 
Equal, NotEqual, GreaterThan, LessThan, GreaterThanEqual, LessThanEqual の単語どおりの意味。
但し、大文字・小文字は区別しない。

大文字・小文字を区別するようためには -ceq, -cne, -cgt, -clt, -cge, -cle を使う。
逆に大文字・小文字を区別しない場合は -ieq, -ine, -igt, -ilt, -ige, -ile を使う。（デフォルト）

|比較演算子|説明|
|:--------|:--|
|-like|正規表現による比較で一致したら True。大文字・小文字は区別しない。|
|-notlike|正規表現による比較で一致しなかったら True。大文字・小文字は区別しない。|
|-match|正規表現による比較でマッチしたら True。大文字・小文字は区別しない。|
|-nomatch|正規表現による比較でマッチしなかったら True。大文字・小文字は区別しない。|
|-contains|★未記載|
|-notcontains|★未記載|
|-replace|★未記載|
|-split|★未記載|

正規表現について

|識別子|説明|
|:----|:--|
|*|[量指定子] 直前の文字が 0 個以上連続する。-[no]like の時はワイルドカードとみなされる。|
|{x,y}|[量指定子] 直前の文字が x 個以上、y 個以下連続する。-[no]like の時は利用不可。|
|?|[量指定子] 直前の文字が 0 個または 1 つ連続する。-[no]like の時は任意の 1 文字。|
|.|任意の 1 文字。|
|[a-z]|a-z の中の任意の 1 文字。|
 ※ マルチバイトも 1 文字として扱う。※多分エンコードが正しい場合に限る

#### 論理演算子について

参考情報：http://www.vwnet.jp/Windows/PowerShell/Ope/OpeListg.htm

|論理演算子|説明|
|:--------|:--|
|-and|かつ|
|-or|又は|
|-not / !|否定|

#### 配列の初期化

配列の初期化結果は変数に代入してもよいし、
そのままパイプやアクセサを使うこともできる。

```text:書式）配列の初期化
# 配列を初期化して変数に代入する
$a = @(＜要素1＞, ＜要素2＞, ＜要素3＞)                                 

# 配列を初期化してアクセサ Length を実行する。
@(＜要素1＞, ＜要素2＞, ＜要素3＞).Length                               

# 配列を初期化して ForEach-Object コマンドレットへパイプで渡す。
@(＜要素1＞, ＜要素2＞, ＜要素3＞) | ForEach-Object { Write-Output $_ }
```

```ps1:例）
PS > $a = @("Alice", "Bob", "Charlie")
PS > @("Alice", "Bob", "Charlie").Length
3
PS > @("Alice", "Bob", "Charlie") | ForEach-Object { echo $_ }
Alice
Bob
Charlie
```

配列の中のデータ型は同じでなくても構わない。例えば文字列と数値を1つの配列に混在できる。

```ps1:
PS > @("Alice", 10, "Bob", 20, "Charlie", 30) | ForEach-Object { echo $_ }
Alice
10
Bob
20
Charlie
30
```

#### 配列要素の参照方法や、配列個数などの参照方法

配列は配列型のオブジェクトのため、プロパティやメソッドに対するアクセサ(.)が使える。
配列の個数は .Length プロパティで調べられる。

```ps1:
PS > $array = @("Alice", "Bob", "Charlie")
PS > $array.Length
3
PS > $array[0]  # 括弧内に要素番号(0始まり)を指定すると、任意の要素が参照できる。
Alice
PS > $array[10] # 要素を超える要素番号を指定してもエラーにはならない。
PS > 
```

#### for 構文について

参考情報：http://www.atmarkit.co.jp/ait/articles/0709/20/news125_3.html

for 構文では後続に続くブロック文の処理を、終了条件が満たされるまで繰り返し実行する。
次のような動作で終了条件式が満たされるかを評価する。

1. 初回ブロック文実行前：初期化式を実行した後、終了条件を評価。終了条件を満たせばループ終了。
1. 次回以降ブロック文実行前：増分式を実行した後、終了条件を比較。終了条件を満たせばループ終了。

```text:書式）
for (初期化式; 終了条件; 増分式) ＜ブロック文＞
```

```ps1:例）
for ($i = 0; $i -lt 3; $i++) {
  Write-Output "iは${i}です。"
}
```
```text:例の実行結果)
iは0です。
iは1です。
iは2です。
```


#### foreach 構文について

参考情報：http://www.atmarkit.co.jp/ait/articles/0709/20/news125_3.html

foreach 構文では基本的に for 構文と同じく、終了条件が満たされるまで後続に続くブロック文の処理を繰り返し実行する。
但し、終了条件は指定した配列の要素を 1 つずつ参照して、全て参照し終わった場合に満たされる。
※ 内部処理を想像するに、イテレーション処理を行い、次要素が空の場合にループを終了するものと思われる。

```text:書式）
foreach (要素参照変数 in 配列orコレクション) ＜ブロック文＞
```
 
```ps1:例)
$array = @("Alice", "Bob", "Charlie")
foreach ($name in $array) {
  Write-Output "名前は${name}です。"
}
```

```text:例の実行結果)
名前はAliceです。
名前はBobです。
名前はCharlieです。
```

----

### コマンドレットのパラメータを指定する

参考情報：https://technet.microsoft.com/ja-jp/library/jj554301.aspx

Param ブロックでデータ型と仮引数、初期値を指定する方法がある。
変数名はコマンドライン引数の名前になる。

```ps1:例）コマンドレットの引数computerNameとfilePathを指定する場合
 Param)
   [string]$computerName="HelloWorldMachine",
   [string]$filePath,
 )
 
 PS > .\mycmdlet -computerName "MyMachine" "test.txt"
```

オプションは ValidateSet で定義すると補完することができる。

----

### スクリプト実行途中で終了させる

exit ステートメントでスクリプトを終了できる。

```ps1:例）$aがnullであればスクリプトを終了する
 if ($a -eq $null) {
   exit 1
 }
```

----

### データの型を調べる方法

GetType メソッドでデータの型を調べることができる。

```ps1:
PS > $a = "string"
PS > $b = 3
PS > $c = 3.3
PS > $d = @("Alice", "Bob", "Charlie")
PS > $a.GetType().fullname
System.String
PS > $b.GetType().fullname
System.Int32
PS > $c.GetType().fullname
System.Double
PS > $d.GetType().fullname
System.Object[]
```

また、$null のデータ型は調べようとしても null なので GetType() が実行できず失敗する。
なので、GetType() を実行する場合は NULL ではないか気を付けないといけない。

```ps1:
PS > $null.GetType().fullname
null 値の式ではメソッドを呼び出せません。
発生場所 行:1 文字:1
+ $null.GetType().fullname
+ ~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (:) []、RuntimeException
    + FullyQualifiedErrorId : InvokeMethodOnNull
```
  
----

### 実行している PowerShell のバージョンを調べる方法

```ps1:
PS > $PSVersionTable.PSVersion

Major  Minor  Build  Revision
-----  -----  -----  --------
5      1      15063  608     
```

上記は PowerShell のバージョンが 5.1 の場合。Major と Minor の値がバージョンを指す。

----

#### 日付関連のプロパティについて

Get-Date コマンドレットで現在の日付が取得できる。
取得した結果は System.DateTime 型のデータであり、次のプロパティがある。

|プロパティ|説明|
|:--------|:--|
|Year|年|
|Month|月|
|Day|日|
|DayOfWeek|曜日(Sunday, Monday, Wednesday, ...)|
|Hour|時|
|Minute|分|
|Second|秒|
|Millisecond|ミリ秒|
|Date|日付（時間は含まず 00:00:00 を指す）|
|DateTime|日付と時刻|
|DayOfYear|当年での経過日数（★多分）|
|Kind|★未調査（Localとか出てきた）|
|Ticks|★未調査（クロック数？）|
|TimeOfDay |Days,Hours,Minutes,Seconds,Milliseconds,Ticks,TotalDays,TotalHours,TotalMinutes,TotalSeconds,TotalMilliseconds|


整形した文字列が欲しい場合は ToString() メソッドを使う。

```ps1:例）実行日をyyyymmdd形式の文字列を出力したい場合
PS > (Get-Date).ToString("yyyyMMdd")
20171009
```

----

#### ファイル・ディレクトリの存在チェックをする方法

参考情報：http://win.just4fun.biz/?PowerShell/%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%83%BB%E3%83%95%E3%82%A9%E3%83%AB%E3%83%80%E3%81%AE%E5%AD%98%E5%9C%A8%E3%83%81%E3%82%A7%E3%83%83%E3%82%AF%E3%83%BBTest-Path）

Test-Path コマンドレットを使ってファイルやディレクトリの存在を確認できる。
Test-Path を実行した返答結果が True であれば存在することを示し、False であれば存在しないことを示す。

Test-Path参考情報：https://docs.microsoft.com/ja-jp/powershell/module/Microsoft.PowerShell.Management/Test-Path?view=powershell-5.1

```ps1:例）ファイルが存在するか
PS > Test-Path -PathType Leaf "memberlist.txt"
True
```
```ps1:例）ディレクトリ（又はレジストリ）が存在するか
PS > Test-Path -PathType Container "memberlist.txt"
True
```
```ps1:例）レジストリが存在するか
PS > Test-Path -Path "HKLM:\Software\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell"
True
```

----

#### ファイル・ディレクトリを作成・削除する方法

New-Item コマンドレットでファイルを作成できる。

```ps1:例）ファイルを作成する
PS > New-Item "temp.txt"
```

```ps1:例）ディレクトリを作成する
PS > New-Item -ItemType Directory "temp"
```

```ps1:例）ファイル・ディレクトリを削除する
PS > Remove-Item "temp"
```

----

#### ファイルの拡張子を変換する

```ps1:例）拡張子を.icsに変換する。拡張子が無ければ付与することになる
PS > $path = ".\test.txt"
PS > [System.IO.Path]::ChangeExtension($path, ".ics");
```