2019年12月14日土曜日

iPhoneであまり知られていない機能

iMessageの会話に絵が追加されると、わかりやすくなり非常に良いと思う。ラーメンか?握り寿司か?確認する会話もご覧の通りわかりやすくなる。結局、ご飯の上に魚が乗ってるやつ(寿司)に決まった。

Digital Touch機能は知っている人が多いが、手書きメッセージについては知らない人が多い。もしくは知っていても忘れてしまっている人が多い。今回はこの手書きメッセージの使い方を説明する。

使い方を説明
iMessageの入力画面でiPhoneを横にして手書きボタンをタップする。以上!!
後は好き勝手書いて送るだけ。
手書きメッセージの利点
  • Digital Touchと違い、横長なので文字が書きやすい。
  • 受信したときに保存とかを押さなくても消えない。(Digital Touchは保存しないと消えてします)
  • 背景色が白で見やすい。
  • 場合によってはフリック入力よりも早い。
  • フリック入力などのタイピングが苦手な人に教えてあげると喜ばれる。
  • 文字と絵を混在できるので絵心がなくても伝わる。
    ご飯に魚が乗っかている絵が「寿司」と伝わらない場合には文字も添えてあげることで握り寿司と伝わりやすくなる。

2019年11月16日土曜日

Googleフォトの写真をまとめてAndroid端末にダウンロードする方法(すべてをダウンロード)

Googleフォトにバックアップ(アップロード)した画像を一括で端末にダウンロードする方法がわかりにくいのでまとめてみる。機種変更後に新しい端末に写真をダウンロードしたい人が多いようだが、Androidの四色風車アイコンのフォトアプリでは一括でダウンロードできない。

ここではすべての画像をダウンロードする方法を書くので、選択した画像をまとめてダウンロードしたい場合は以下を参照してほしい
Googleフォトの写真をまとめてAndroid端末にダウンロードする方法(選択してダウンロード)

まずは全体の流れ:
1.ブラウザで「自分のデータをダウンロード」にアクセスしてアーカイブ作成行う。
2.データ(.zip)のダウンロード
3.ダウンロードされたzipファイルを開く
4.ダウンロードされたzipファイルから画像を取り出す

それでは詳細を説明する。

ただし、この方法でダウンロードすると表示順が狂うと言う報告もあります。

1.ブラウザで「自分のデータをダウンロード」にアクセスしてアーカイブ作成行う。

ブラウザで 「自分のデータをダウンロード」にアクセスする。
「新しいアーカイブの作成」を押してる「①追加するデータの選択」からGoogleフォトだけを選択する。(「サービス」の部分にある「選択をすべて解除」してから「Googleフォト」にチェックを入れるとよい)
画面を下までスクロールし「次のステップ」を押下する。
「②アーカイブ形式のカスタマイズ」画面の配信方法として「ダウンロード リンクをメールで送信」を指定、でファイル形式に「.zip」を選択し「エクスポートの作成」を行う。

2.データ(.zip)のダウンロード

アーカイブの作成が完了するとメールでダウンロードのリンクが届くので、そこからZIPファイルをダウンロードする。
Googleフォトの容量が大きいと分割されているので、端末の容量が少ない場合には分割されたZIPファイルをひとつづつ操作するとよい。その場合は手順の3,4を繰り返す。

3.ダウンロードされたzipファイルを開く

ここからはGoogleフォトの動作ではないので、Android端末により使用するアプリがことなる。Pixel3しかもっていないので、Pixel3の標準でインストールされている「ファイル」アプリを例に説明する。
「ファイル」アプリを開き、「ダウンロード」フォルダから先ほどダウンロードした「takeout-2019XXXXXXXX.zip」を開く。
 

4.ダウンロードされたzipファイルから画像を取り出す

」メニューより「すべて選択」を選び、再度「」メニューより「次の場所に解凍...」を選択する。
続いてどこに解凍したいか選ぶが、これは人それぞれなので自分の好きな場所に展開すればよいが、今回はDCIMフォルダを選択する。「≡」メニューから「Pixel3」を選択し、最後に画面右下の「解凍」ボタンを押す。


※ 手順の4番目以降は端末メーカ毎に異なるので、ZIPファイルの展開/解凍方法を別途調べてほしい。

Googleフォトの写真をまとめてAndroid端末にダウンロードする方法(選択してダウンロード)

Googleフォトにバックアップ(アップロード)した画像を一括で端末にダウンロードする方法がわかりにくいのでまとめてみる。機種変更後に新しい端末に写真をダウンロードしたい人が多いようだが、Androidの四色風車アイコンのフォトアプリでは一括でダウンロードできない。

ここでは選択した画像をダウンロードする方法を書くので、すべての画像をまとめてダウンロードしたい場合は以下を参照してほしい
Googleフォトの写真をまとめてAndroid端末にダウンロードする方法(すべてをダウンロード)

まずは全体の流れ:
1.ブラウザでGoogleフォトにアクセス
2.Googleフォトでダウンロードしたい画像を選択する
3.選択した画像をダウンロードする。
4.ダウンロードされたzipファイルを開く
5.ダウンロードされたzipファイルから画像を取り出す

それでは詳細を説明する。

ただし、この方法でダウンロードすると表示順が狂うと言う報告もあります。

1.ブラウザでGoogleフォトにアクセス

ブラウザで Googleフォト( https://photos.google.com/login )にログインし、画像を表示する。

2.Googleフォトでダウンロードしたい画像を選択する

「≡」メニューの並びにある「」メニューより「写真を選択してください」を選択する。写真選択モードになるので写真左上にチェックを入れる。

3.選択した画像をダウンロードする。

ゴミ箱アイコンの横にある「」メニューより「ダウンロード」を選択する。しばらくすると「Photos.zip」がダウンロードされる。

4.ダウンロードされたzipファイルを開く

ここからはGoogleフォトの動作ではないので、Android端末により使用するアプリがことなる。Pixel3しかもっていないので、Pixel3の標準でインストールされている「ファイル」アプリを例に説明する。
「ファイル」アプリを開き、「ダウンロード」フォルダから「Photos.zip」を開く。

5.ダウンロードされたzipファイルから画像を取り出す

」メニューより「すべて選択」を選び、再度「」メニューより「次の場所に解凍...」を選択する。
続いてどこに解凍したいか選ぶが、これは人それぞれなので自分の好きな場所に展開すればよいが、今回はDCIMフォルダを選択する。「≡」メニューから「Pixel3」を選択し、最後に画面右下の「解凍」ボタンを押す。


※ 手順の4番目以降は端末メーカ毎に異なるので、ZIPファイルの展開/解凍方法を別途調べてほしい。

2019年11月6日水曜日

Googleフォトで画像を消す方法

スマホ(iPhoneやAndroid)向けのGoogleフォトアプリから画像を消す方法を説明します。といってもGoogleフォトアプリが写真を保持しているわけではないので、削除する対象はGoogleフォト(https://photos.google.com)の写真および端末の写真です。
以下の3パターンを順番に説明します。

 1.Googleフォトに写真を残し、スマホ(iPhoneやAndroid)から写真を削除する
 2.スマホ(iPhoneやAndroid)に写真を残し、Googleフォトから写真を削除する
 3.Googleフォトからも、スマホ(iPhoneやAndroid)からも写真を削除する

なお、いきなり大切な写真で実施せずにテスト用の画像で動きを確認してから大切な写真で作業してください。

1. Googleフォトに写真を残し、スマホ(iPhoneやAndroid)から写真を削除する
1-1.選択した写真だけをスマホから削除する方法
絶対やってはいけない方法: Googleフォトアプリのゴミ箱アイコンで削除

おススメしない方法: Googleフォトアプリを使用せず、写真アプリなどで削除

おススメの方法: Googleフォトアプリで削除したい画像を選択して、「・・・」や「」メニューから「デバイスから削除」や「元のファイルn個をデバイスから削除」を行う。もしくは下から表示されたメニューを横スクロースして「元のファイルをデバイスから削除」を実施する。※デバイスやアプリのバージョンによって操作性が変わるようです。



おススメする理由はバックアップされていないファイルを端末から削除しようとすると以下のような警告メッセージが表示される。
【iPhone】
【Android】

これらのメッセージが出たときは操作をキャンセルしてバックアップを行う。 

1-2.バックアップが完了している写真すべてをスマホから削除する方法
Googleフォトアプリの画面右上「アカウントアイコン」メニューから「このデバイスから XX個のファイルを削除できます」を実施してください。

1-3.バックアップが完了している写真を削除する際に一部の画像を残す方法(iPhone)
スマホ本体から画像を削除するとFacebookやInstagramなど他のアプリで画像を使いたいときに不便になる。そのため最近撮影した写真やよく使用する写真は本体に残しておくとよい。そういったときは"Googleフォトの「空き容量を増やす」で特定の画像を端末に残す"を参考にしてください。

2. スマホ(iPhoneやAndroid)に写真を残し、Googleフォトから写真を削除する

2-1.iPhoneに写真を残してGoogleフォトから写真を削除したい場合
・SafariブラウザのプライベートタブでGoogleフォトWEB版( https://photos.google.com/login )にアクセスして「ログイン」してください。
左上に「≡」アイコンがあればGoogleフォトWEB版にアクセスできています、左上が「≡」アイコンではない場合はアプリに飛ばされている可能性があるので、ブラウザでアクセスできているか再確認してください。
・削除したい画像を表示してゴミ箱アイコンを使用して写真を削除してください。

・Googleフォトでブラウザアクセスした際に複数画像を一気に選択したい場合は「…」メニューの「写真を選択してください」選ぶと複数画像を選択できるようになります。

2-2.Androidに写真を残してGoogleフォトから写真を削除したい場合
・フォト アプリの右上の「アカウントアイコン」>「フォトの設定」>「バックアップ」で「バックアップ」をオフにします。※「バックアップと同期」が「バックアップ」に名変更されましたがま機能は変わってません。
・そのうえでChromeブラウザのシークレットモードでGoogleフォトWEB版( https://photos.google.com/login )にアクセスして「ログイン」してください。
左上に「≡」アイコンがあればGoogleフォトWEB版にアクセスできています、左上が「≡」アイコンではない場合はアプリに飛ばされている可能性があるので、ブラウザでアクセスできているか再確認してください。 
・削除したい画像を表示してゴミ箱アイコンを使用して写真を削除してください。
・ゴミ箱に入れた写真がAndroidの端末に残っていることを確認したらゴミ箱からも削除してください。(Googleフォト以外のアプリで確認してください。)
・作業が完了したら「バックアップと同期」の設定を元に戻してください。

・Googleフォトでブラウザアクセスした際に複数画像を一気に選択したい場合は「…」メニューの「写真を選択してください」選ぶと複数画像を選択できるようになる。


3. Googleフォトからも、スマホ(iPhoneやAndroid)からも写真を削除する
Googleフォトアプリで写真を選択してゴミ箱アイコンで削除してください。
Googleフォトからも端末からも削除されるので、本当に要らない写真だけこの方法で削除してください。


繰り返しになりますが、いきなり大切な写真で操作せず、テスト用画像で操作や動作を確認してください。

2019年9月8日日曜日

Googleのバックアップと同期の設定画面

Googleのバックアップと同期の設定画面は文章だけで説明するのが面倒なので設定画面のキャプチャ画像を張ってみた。

1.タスクトレイの通知領域に表示されるGoogleのバックアップと同期アイコン



1-1.待機状態のGoogleのバックアップと同期アイコン

1-2.同期実施状態のGoogleのバックアップと同期アイコン

2.Googleのバックアップと同期アイコンクリック時のポップアップメニュー


2-1.Googleドライブアイコンのポップアップメニュー

3.Googleのバックアップと同期設定画面

3-1.マイ パソコン


3-1-1.設定画面:マイ パソコン
バックアップしたいフォルダにチェックを入れます
バックアップしたいフォルダを①のリストに追加します
バックアップ対象のファイルを選択します。
「すべてのファイル」か「写真と動画だけ」か選びます。
写真と動画のアップロードサイズを選択します。
「高画質」か「元の画質」か選べます。
「高画質」は画質の変化を伴います。
ファイル削除時の動作を選択します。Googleフォトには影響を与えません。
常に両方のコピーを削除する: パソコンまたは drive.google.com からアイテムを削除すると、他の場所からも削除されます。
両方のコピーを削除しない: パソコンからアイテムを削除しても、drive.google.com からは削除されません。
両方のコピーを削除する前に確認メッセージを表示する: パソコンからアイテムを削除するときに、他の場所からも削除するかどうかを確認するメッセージが表示されます。
写真をGoogleフォトにアップロードするときにチェックを入れます
カメラなどのデータを直接アップロードしたいときに設定します。

3-2.Googleドライブ


3-2.設定画面:Googleドライブ

3-3.設定


3-3.設定画面:設定

























2019年8月15日木曜日

デジカメの画像をGoogleフォトにアップロードする方法(iPhone/iPad)

デジカメで撮影した写真をGoogleフォトにアップロードする方法としては一度パソコンに取り込んでから「バックアップと同期」を用いてバックアップする方法がある。
しかし、「バックアップと同期」を用いてGoogleフォトにアップロードするとドライブ側にもアップロードされてしまいます。また、写真をバックアップするためにパソコンを起動する必要もあります、めったにパソコンを使用しない私としては非常に煩わしい。

そこで、おススメの方法はLightning SDカードカメラリーダーを使用して、デジカメの写真をiPhoneやiPadのカメラロールに取り込み、「Googleフォト」アプリでGoogleフォトにバックアップする方法です。カメラロールへの取り込みは迷うこともなく、簡単に行えるので特に方法は説明しません。旅行中にデジカメの写真を手軽にバックアップできることも魅力の一つです。
名前の通り、Lightningコネクタに接続してSDカードに保存されているデジカメのデータをカメラロールに読み込むためのものだが、アップル純正という安心感と写真の管理が一気に楽になることを考えると¥2,800 (税別)は非常にお買い得だとおもう。(¥3,200で購入したのにいつの間にか値下げされている、、、が満足している。)
カメラデータ以外読み込めないし、リーダーというだけあって書き込むこともできない、サードパーティ製のSDカードアダプタはもう少し高機能かもしれないので、そういったものにも興味がある人は調べてから購入するとよい。

我が家の目的としてはSDカードリーダーが良かったのですが、Lightning USBカメラアダプタLightning USB 3カメラアダプタUSB-C SDカードリーダーなどもあるので用途に合わせて選んでください。

「互換性」の項目にご自身の端末が登録されていることも忘れずにチェックしてください。

2019年1月19日土曜日

Google Apps Script で指定フォルダ以下のファイルを処理する方法(3)

Google Driveで指定したフォルダ以下に配置されているすべてのファイルに対して処理を行うGoogle Apps Script(GAS)を考えてみた。普通に考えるとフォルダ内のファイルとフォルダを処理する関数を再帰的に呼び出すのだが、、、GASには6分以上経過すると強制的にスクリプトが終了してしまう制限がある。

この6分の壁対策として時間トリガを設定して中断した処理を再開する必要がある。しかしその再開のためにはどこまで処理したかを保存しなけれがならない。一般的には中断するときにFileIteratorクラスのgetContinuationToken()を使用してトークンを取得しそれをスクリプトプロパティなどに保存して、時間トリガで起動した際にはDriveAppクラスのcontinueFileIterator(continuationToken)でFileIteratorを復元する。

しかし、再帰的に関数を呼び出している処理を中断するのは少しコツがいる。

今回は単純な方法を紹介する。
  1. ドライブで管理しているすべてのFileIteratorを所得する
  2. FileIteratorですべてのファイルをスキャンする
  3. ファイルが指定したフォルダ配下かどうかをチェックする
  4. 5分経過した場合にはFileIteratorをプロパティに保存する
ただし、この方法には欠点がある。すべてのファイルで、親フォルダをスキャンするので処理が重く、FileIteratorは1週間という有効期限があるため、ドライブ内のすべての処理が1Week以内で終わるようにトリガタイミングと処理を調整しなければならない。

そのうち6分の壁対策版の再帰処理コードも載せると思う。

function start()
{
  // プロパティとトリガをすべて削除
  PropertiesService.getScriptProperties().deleteAllProperties();
  var triggers = ScriptApp.getProjectTriggers();
  for (var i = 0; i < triggers.length; i++) {
    ScriptApp.deleteTrigger(triggers[i]);
  }

  // トリガを設定する。(10分間隔)
  trigger = ScriptApp.newTrigger("triggerFunction")
  .timeBased()
  .everyMinutes(10)
  .create();
  triggerFunction();
}

function triggerFunction() {
  var StartTime   = new Date();
  var TargetFolder = "1zXo...処理対象のフォルダID....xEfEI";

  var ContinuationToken = PropertiesService.getScriptProperties().getProperty("ContinuationToken");
  if( ContinuationToken != null )
  {
    var files = DriveApp.continueFileIterator(ContinuationToken);
  }
  else
  {
    var files = DriveApp.getFiles();
  }

  while(files.hasNext()) {
    var CurrentTime = new Date();
    // トリガを設定する。(5分間実行していたら止める。)
    if( (CurrentTime - StartTime) > 5*60*1000 ){
      PropertiesService.getScriptProperties().setProperty("ContinuationToken",files.getContinuationToken());
      return;
    }
    var file = files.next();
    if( checkSubordinate( file, TargetFolder ) )
    {
      ////////////////////////////////////
      // ファイルごとの処理をここから記載する。

      // ★★ ここにfileに対する処理を記載する ★★

      // ファイルごとの処理をここまでに記載する。
      ////////////////////////////////////
    }
  }
  
  PropertiesService.getScriptProperties().deleteAllProperties();
  var triggers = ScriptApp.getProjectTriggers();
  for (var i = 0; i < triggers.length; i++) {
    ScriptApp.deleteTrigger(triggers[i]);
  }
}

// targetが指定フォルダ(folder ID)の配下のフォルダかチェックする。
function checkSubordinate(target,folderID)
{
  var parents = target.getParents();
  while( parents.hasNext() )
  {
    var parent = parents.next();
    var id = parent.getId();
    if( id === folderID ){
      return true;
    }
    if( checkSubordinate(parent,folderID) )
    {
      return true;
    }
  }
  return false;
}

2019年1月13日日曜日

Google Apps Script小ネタ集(1)


Google Apps Scriptでたまに欲しくなる小ネタをまとめた。

  • フォルダの作成・取得
  • 多階層フォルダの作成・取得
  • テキストファイル保存
  • ファイルへのデータ保存
  • 指定期間のメールを処理する


フォルダの作成・取得

始めにフォルダ作成のダメな例ですが、普通にcreateFolderを呼び出すと、既に同じ名前のフォルダが存在していても構わず同じ名前のフォルダを生成する。そのためトリガを設定して何度も実行されるスクリプトでこれを行うと同じ名前のフォルダがどんどん増えていき使い物にならない。
// ダメな例
// すでに存在していてもフォルダを作成する。
// 何度も実行すると、毎回同じ名前のフォルダが生成される。
DriveApp.getRootFolder().createFolder( 'HogeHoge' );
対策としては既にフォルダが存在していたらそのフォルダを使用し、存在していない場合には新規に作成することにする。ちなみにファイルやフォルダが存在しているかどうかはgetFoldersByName( name ) 関数を使用する。Foldersと複数形となっていることからもわかるように、同じ名前のフォルダが複数あった場合には複数のフォルダが見つかる。その場合どのフォルダを採用するか悩んでも仕方がないので、最初に見つかったものを採用する。
var folderName = 'HogeHoge';
var root = DriveApp.getRootFolder();    
var folders = folder.getFoldersByName( folderName );
if( folders.hasNext() ) {
  // フォルダが見つかった場合は最初のフォルダを採用
  folder = childs.next();
} else {
  // フォルダが見つからなかった場合はフォルダを作成
  folder = folder.createFolder( folderName );
}
これを関数化しておく。
/**
 *  指定されたフォルダ取得する、フォルダがないときは新規に作成する。
 *
 *  @param {Folder} parent 基準フォルダ、null指定時はルートフォルダ
 *  @param {string} name 取得・生成するフォルダ名
 *  @param {bool} noCreate 作成は行わず既存のフォルダを取得するときにtrue
 *      省略可能、省略時はfalseとして動作
 *  @return {Folder|undefined} 取得・生成したフォルダ
 */
function getFolder( parent, name, noCreate ) {
  if ( parent == null ){
    parent = DriveApp.getRootFolder();
  }
  if ( noCreate !== true ){
    noCreate = false;
  }
  var folders = parent.getFoldersByName(name);
  if( folders.hasNext() ) {
    // フォルダが見つかった場合は最初のフォルダを採用
    var folder = folders.next();
  } else {
    if( noCreate ){
      // noCreate指定の場合はundefinedを返す。
      var folder = undefined;
    }else{
      // フォルダが見つからなかった場合はフォルダを作成
      var folder = parent.createFolder(name);
    }
  }
  return folder;
}


多階層フォルダの作成・取得

一階層のフォルダを生成するだけであれば先ほどの処理でよいが、写真を整理する場合などは年フォルダの下に月フォルダを生成したい場合がある。単純に処理を繰り返すだけだが、毎回コードを書くのが面倒なので関数化しておく。
/**
 *  指定されたフォルダ取得する、フォルダがないときは新規に作成する。
 *
 *  @param {Folder} parent 基準フォルダ、null指定時はルートフォルダ
 *  @param {string|Array of strings} name 取得・生成するフォルダ名
 *      string 指定時はnameを取得。生成する
 *      Array of strings 指定時は多階層フォルダを一括作成
 *  @param {bool} noCreate 作成は行わず既存のフォルダを取得するときにtrue
 *      省略可能、省略時はfalseとして動作
 *  @return {Folder|undefined} 取得・生成したフォルダ
*/
function getFolder( parent, name, noCreate ) {
  // 親フォルダが指定されなかった場合はルートフォルダを取得する。
  if ( parent == null ){
    var folder = DriveApp.getRootFolder();
  }else{
    var folder = parent;
  }
  // フォルダ名に配列が指定されていない場合は配列にする。
  if( !Array.isArray( name ) ){
    name = [name];
  }
  // noCreateが指定されていない場合はfalseにする。
  if ( noCreate !== true ){
    noCreate = false;
  }

  while( name.length > 0 ){
    var newFolder = name.shift();
    var childs = folder.getFoldersByName( newFolder );
    if( childs.hasNext() ){
      // フォルダが見つかった場合は最初のフォルダを採用
      folder = childs.next();
    } else {
      if( noCreate ){
        // noCreateの場合はnullを返す
        return undefined;
      }else{
        // フォルダが見つからなかった場合はフォルダを作成
        folder = folder.createFolder( newFolder );
      }
    }
  }
  return folder;
}
使い方は以下。
function myFunction() {
  // ルートフォルダにHoge1フォルダを生成
  var f1 = getFolder( null,'Hoge1' );  

  // ルートフォルダにあるHoge2フォルダを取得
  var f2 = getFolder( null,'Hoge2', true );
  if( f2 == undefined ){
    Logger.log('Did Not create folder.' )
  }

  // Hoge1フォルダの下に多階層フォルダを生成
  var v3 = getFolder( f1,['Hoge3','Hoge3-1','Hoge3-2'] );
  // 以下のフォルダ構成の'Hoge3-2'フォルダを取得
  // \\Hoge1\Hoge3\Hoge3-1\Hoge3-2
}


テキストファイルを保存

テキストファイルへの保存と読み込みはDrive Serviceのリファレンスに従いうだけではあるが、毎回悩むので記録しておく。
// 新規ファイルの生成と追記
var folder = DriveApp.getRootFolder();
var file = folder.createFile( 'filename.txt', 'Hello, world!!' );
var text = file.getBlob().getDataAsString();
file.setContent(text + '\nNew line!!' );
// 既存ファイルの読み込みと追記
var files = folder.getFilesByName( 'filename.txt' );
if( files.hasNext() ){
  var file = next();
  var text = file.getBlob().getDataAsString();
  file.setContent(text + '\nNew line!!' );
}


ファイルへのデータ保存

テキストの読み書きができているので、クラスを保存したい場合などはJSON化すればよい。
// 保存するオブジェクトの配列を定義
var Users = [new User(123,'Taro'),new User(124,'Jiro')];

// オブジェクトをJSONに変換してファイルに保存
var json = JSON.stringify(Users);
var file = folder.createFile( 'filename.txt', json );

// 保存したJSONを読み込んでオブジェクトを復元
var Users2 = JSON.parse(file.getBlob().getDataAsString());
保存されたファイル(filename.txt)は以下。
[{"id":123,"name":"Taro"},{"id":124,"name":"Jiro"}]


指定期間のメールを処理する

GMAILの検索はGmailApp.search(query,start,max)を使用する。しかしこの関数の戻り値はGmailThread[]となっており非常に厄介。なぜかというと、期間を指定検索をすると期間内のメールも含むスレッドが検索されるのだが、そのスレッドには期間外のメールも含まれていることがある。
今回はその期間外のメールを除外したメールのID一覧を取得する関数の紹介。といっても、期間指定して検索した結果をループで回して期間内のメールだけを抽出している。特に工夫していないが、いざ欲しいときにコーディングしようと思うと時間を取られるので残しておく。
/**
 *  メールの検索結果から指定した期間のメールのIDリストを取得する。
 *
 *  @param {string} searchString 期間指定以外の検索条件
 *  @param {Date} start 検索期間の開始
 *  @param {Date} end 検索期間の終了
 *  @return {string[]} メールIDリスト
 */
function getMailIdList( searchString, start, end )
{
  var after = 'after:' + Math.floor( start.getTime()/1000 ).toString();
  var before = 'before:' + Math.floor( end.getTime()/1000 ).toString();
  const search = after + ' ' + before + ' ' + searchString;

  var mailIdList = [];
  var indexStart = 0;
  while( true )
  {
    var myThreads = GmailApp.search(search, indexStart, 500);
    if( myThreads.length==0)
    {
      return mailIdList;
    }
    indexStart+=myThreads.length;
    
    var myMsgs = GmailApp.getMessagesForThreads(myThreads);
    for(var thread = 0;thread < myMsgs.length;thread++){
      for(var msg = 0;msg < myMsgs[thread].length;msg++){
        var reciveDate = myMsgs[thread][msg].getDate();
        if( start < reciveDate && end > reciveDate )
        {
          mailIdList.unshift(myMsgs[thread][msg].getId());
        }
      }
    } 
  }
}
ちなみに使い方は以下を想定している。
MailList = getMailIdList('SearchString',new Date('2018/10/01 0:00:00'),new Date('2018/10/01 13:00:00'));
while( MailList.length > 0 )
{
  var mail = GmailApp.getMessageById(JobList.shift());
  mail.xxxXxxxx(); // メールに対する処理を実施
}

2019年1月2日水曜日

Google Apps Script でプロパティをファイルに保存する

GoogleAppsScriptは便利だがいろいろと困ることがある、その代表格は6分制限。スクリプトの実行時間が6分を超えると途中で処理が終了してしまいます。 その対応として実行経過を保存し時間駆動型のトリガーを設定して、細切れに処理をする必要がある。これについてはGoogleで検索すれば山のように情報が出てくるので割愛する。
今回その途中経過を保存する方法について書いてみる。 一般的にはProperties ServicesetProperty(key, value)などを用いると非常に楽である。しかし、9217文字以上の文字列を扱おうと思うとエラーが発生してしまう。

var scriptProperties = PropertiesService.getScriptProperties();
scriptProperties.setProperty(
  'key_string',  // keyにもサイズ制限があるかもしれないが未検証
  'ここに文字数が9217以上の文字列を指定、XXXXXXXXXX');
}

上の例では以下のようにエラーではじかれる。
引数が大きすぎます:value(行 nn,ファイル「コード」)
そこまで大きい値を扱わない場合には問題ないが、DriveAPIで複雑な検索式でsearchFilesを行った場合のContinuationTokenがこの上限を大幅に超えることがある。9216文字以下になるように分割して複数のプロパティに保存することで対処可能ではあるが、今回はプロパティをファイルに保存する方法を考えてみたので忘れないようにメモ。使い方はそのうち別記事で記載します。

初回起動時にプロパティファイルを生成し、そのファイルIDをProperties Serviceで保存しておく。ファイルIDが変わらない限りはプロパティファイルの名前が変更されても参照が継続する。使い勝手が良いのか、悪いのかはよくわからない。
// 使い方はfunction examplePersistentProperty()を参照

// PersistentProperty()
// 概要:クラスのコンストラクタ
// 引数:filename  プロパティ保存する初期ファイル名
// 戻り値:インスタンス
// 使い方:
//   var pp = new PersistentProperty('PersistentPropertyTest');
// 備考:一度生成したファイルを参照し続ける。ファイル名を変えても参照し続ける。
PersistentProperty = function(filename){
  this.file = null;
  var id =  PropertiesService.getUserProperties().getProperty("PropertiesFileID");
  try{
    if( id!=null )
    {
      // ファイル取得を試みる
      this.file = DriveApp.getFileById(id);
      if( this.file.isTrashed() )
      {
      // ユーザープロパティから取得したファイルがゴミ箱にある場合には廃棄する
        this.file=null;;
      }
    }
  }
  catch(e)
  {
    // ファイル取得に失敗した場合は
    this.file=null;
  }
  // プロパティファイルがない場合には新規に作成する。
  if( this.file==null )
  {
    var name = filename;
    this.file =  DriveApp.createFile(name,"{}",MimeType.PLAIN_TEXT);
    var fileId = this.file.getId();
    PropertiesService.getUserProperties().setProperty("PropertiesFileID",fileId);
  }

  // ファイルからテキスト読み込み
  var text =  this.file.getBlob().getDataAsString();
};


// deleteAllProperties()
// 概要:保存されているプロパティをすべて消去
// 引数:なし
// 戻り値:なし
// 使い方:
//   var pp = new PersistentProperty('PersistentPropertyTest');
//   pp.deleteAllProperties();
// 備考:
PersistentProperty.prototype.deleteAllProperties = function(){
  this.file.setContent(JSON.stringify({}));
};

// deleteProperty(key)
// 概要:指定したプロパティを消去
// 引数:key  削除するプロパティを指定するキー
// 戻り値:なし
// 使い方:
//   var pp = new PersistentProperty('PersistentPropertyTest');
//   pp.deleteProperty('PropaertyKeyString');
// 備考:
PersistentProperty.prototype.deleteProperty = function(key){
  var text =  this.file.getBlob().getDataAsString();
  var Properties =  JSON.parse(text);

  try{
    // 指定されたプロパティ削除
    delete Properties[key];
  }
  finally
  {
    // 最終的にオブジェクトをファイルに保存
    this.file.setContent(JSON.stringify(Properties));
  }  
};

// getKeys()
// 概要:保存しているプロパティのキーの一覧を取得
// 引数:なし
// 戻り値:キーの一覧(string[])
// 使い方:
//   var pp = new PersistentProperty('PersistentPropertyTest');
//   var keys = pp.getKeys()
// 備考:
PersistentProperty.prototype.getKeys = function(){
  var text =  this.file.getBlob().getDataAsString();
  var Properties =  JSON.parse(text);

  var keys = [];
  for (key in Properties) {
    keys.push(key);
  }
  return keys;
};

// getProperties()
// 概要:保存しているプロパティリストを取得
// 引数:なし
// 戻り値:保存しているプロパティリスト(連想配列)
// 使い方:
//   var pp = new PersistentProperty('PersistentPropertyTest');
//   var properties = pp.getProperties();
//   var value = properties['PropaertyKeyString'];
// 備考:
PersistentProperty.prototype.getProperties = function(){
  var text =  this.file.getBlob().getDataAsString();
  var Properties =  JSON.parse(text);
  return Properties;
};

// getProperty(key)
// 概要:指定したプロパティを取得
// 引数:key  取得するプロパティを指定するキー
// 戻り値:プロパティの値
// 使い方:
//   var pp = new PersistentProperty('PersistentPropertyTest');
//   var value = pp.getProperty('PropaertyKeyString');
// 備考:
PersistentProperty.prototype.getProperty = function(key){
  var text =  this.file.getBlob().getDataAsString();
  var Properties =  JSON.parse(text);

  if( key in Properties ){
    return Properties[key];
  }else{
    return null;
  }
};

// setProperties(properties,deleteAllOthers)
// 概要:プロパティリストを設定
// 引数:properties  設定するプロパティリスト(連想配列)
//   :deleteAllOthers  trueを指定すると他のプロパティをクリア、書略時はfalseとして動作する
// 戻り値:なし
// 使い方:
//   var pp = new PersistentProperty('PersistentPropertyTest');
//   pp.setProperties({ "key1":"value1","key2":"value2"},true); // 他のプロパティは削除
//   pp.setProperties({ "key3":"value3","key4":"value4"}); // プロパティを追加
// 備考:deleteAllOthersがfalseの場合はプロパティリストの追加
PersistentProperty.prototype.setProperties = function(properties,deleteAllOthers){
  var text =  this.file.getBlob().getDataAsString();
  var Properties =  JSON.parse(text);

  if( deleteAllOthers ){
    Properties = properties;
  }else{
    for( var key in properties ){
      Properties[key] = properties[key];
    }
  }
  var json = JSON.stringify(Properties);
  this.file.setContent(json);
};

// setProperty(key,value)
// 概要:プロパティを設定
// 引数:key  設定するプロパティのキー
//   :value  設定するプロパティの値
// 戻り値:なし
// 使い方:
//   var pp = new PersistentProperty('PersistentPropertyTest');
//   pp.setProperty( "key1", "value1" );
// 備考:
PersistentProperty.prototype.setProperty = function(key,value){
  var text =  this.file.getBlob().getDataAsString();
  var Properties =  JSON.parse(text);

  Properties[key] = value;
  this.file.setContent(JSON.stringify(Properties));
};

//////////////////////////////////////////////////////////////////////////////
// これ以降は使い方サンプル及びDEBUG用
//////////////////////////////////////////////////////////////////////////////
function examplePersistentProperty()
{
  // ファイル名を決める
  var pp = new PersistentProperty('PersistentPropertyTest');
  
  // プロパティのセット
  pp.setProperty( "test1","test1 value" );

  // プロパティを複数追加
  pp.setProperties(
    { "test2":"test2 value","test3":"test3 value"});

  // プロパティを複数追加
  pp.setProperties(
    { "test4":"test4 value","test5":"test5 value"},false );

  // 一つずつ取得してもよい
  var test1 = pp.getProperty("test1");
  
  // 一気に取得してもよい
  var properties = pp.getProperties();
  var test2 = properties["test2"];
 
  // プロパティを一部削除
  pp.deleteProperty("test4");
  
  // プロパティを一部削除や追加し一気に更新
  delete properties["test5"];
  properties["test6"] = "New Property";
  pp.setProperties(properties,true);
  
  // 全プロパティを列挙
  var keys = pp.getKeys();
  for( var i=0; i<keys.length; i++ ){
    var key = keys[i]
    var value = pp.getProperty(key);
  }

  // 全プロパティを削除
  pp.deleteAllProperties();
}

GMAILに受信したメールをGoogle Apps Scriptで処理する

GMailに受信したメールを処理するスクリプトを作成した。
検索を行いその結果に対して処理を行うが、一度処理したメールは再度処理しないようにしなければならない。処理をしたメールにたいし、ラベルをつけたり、既読化したり、スターをつけたりすることで対処しようとしているページが多い。ラベルや既読化はスレッド単位で検索されてしまうので問題が発生するし、既読化やスターをつける方法は複数のスクリプトで処理したいメールがある場合に対応できない。

今回はメール受信日時で処理済みかどうかを判断する方法を採用した。時間駆動トリガで定期的に実行することを想定しているが、トリガを仕掛ける処理は組み込んでいないので、手動でトリガ指定する必要がある。処理量が多いと一回のスクリプト実行時間(6分)では処理しきれないこともあるため中断することも考慮に入れている。
目的に合わせて青字の部分をカスタマイズして使用する。


/// トリガ設定は行っていないので、手動で指定する必要があります。

function Main() {
  var ScriptStartTime = new Date();
//////////////////////////////////////////////////////////////////////////////////////
// カスタマイズはここから
////////////////////////////////////////
  // メールを検索する条件(beforとafterは指定しない)
  var SearchString = 'in:all -in:spam -in:trash has:attachment';
    
  // 処理対象期間
  var SearchStartDate = new Date('2018/10/00 0:00:00');
  var SearchEndDate = new Date(ScriptStartTime);

  // 一回の検索で検索する期間:5分で処理できる量を指定するのが一番良い。
  var IncrementHours = 24;

  // メールに対する処理(例:メール受信日時フォルダを生成し、その中に添付ファイルを保存する)
  var Action = function(mail)
  {
    var DriveFolderName = "GmailAttachments";  
    var reciveDate = mail.getDate();
    var folderID = getDriveFolder([DriveFolderName,reciveDate.getFullYear(),reciveDate.getMonth()+1,reciveDate.getDate()]);
    var attachments = mail.getAttachments();
    for (var n = 0; n < attachments.length; n++)
    {
      folderID.createFile(attachments[n]);
    }
  }  
////////////////////////////////////////
// カスタマイズはここまで
//////////////////////////////////////////////////////////////////////////////////////

  var properties = PropertiesService.getScriptProperties().getProperties();
  
  // 検索開始日時をプロパティから取得する
  if( 'SearchStartDate' in properties ){
    SearchStartDate = new Date(properties['SearchStartDate']);
  }

  // 実施すべきジョブ(MailIDの配列)をプロパティから取得する。
  var JobList = [];
  if( 'JobList' in properties ){
    JobList = JSON.parse(properties['JobList']);
  }

  for( var start = SearchStartDate; start < SearchEndDate; )
  {
    // メール検索期間の設定
    var end = new Date(start);
    end.setHours(end.getHours() + IncrementHours);
    if( end > SearchEndDate ){
      end = new Date(SearchEndDate);
    }
    
    // JobListの生成(処理すべきメールのIDリスト)
    if( Object.keys(JobList).length == 0 )
    {
      JobList = getMailIdList(SearchString,start,end);
    }
    
    // JobListに従い処理実行
    while( JobList.length > 0 )
    {
      // スクリプト開始から5分経過している場合は実行経過をプロパティに保存して処理を中断する。
      var CurrentTime = new Date();
      if( (CurrentTime - ScriptStartTime) > 5*60*1000 )
      {
        properties['SearchStartDate'] = start;
        properties['JobList'] = JSON.stringify(JobList);
        PropertiesService.getScriptProperties().setProperties( properties );
        return;
      }
      
      Action(GmailApp.getMessageById(JobList.shift()));   // 処理実行関数呼び出し
    }
    
    // 検索期間を更新
    start = end;
  }
  properties['SearchStartDate'] = start;
  properties['JobList'] = JSON.stringify(JobList);
  PropertiesService.getScriptProperties().setProperties( properties );
}

// 指定期間で検索を行い、指定期間に受信したメールのIDを配列で取得する
function getMailIdList( searchString, start, end )
{
  var after = 'after:' + Math.floor( start.getTime()/1000 ).toString();
  var before = 'before:' + Math.floor( end.getTime()/1000 ).toString();
  const search = after + ' ' + before + ' ' + searchString;

  var mailIdList = [];
  var indexStart = 0;
  while( true )
  {
    var myThreads = GmailApp.search(search, indexStart, 500);
    if( myThreads.length==0)
    {
      return mailIdList;
    }
    indexStart+=myThreads.length;
    
    var myMsgs = GmailApp.getMessagesForThreads(myThreads);
    for(var thread = 0;thread < myMsgs.length;thread++){
      for(var msg = 0;msg < myMsgs[thread].length;msg++){
        var reciveDate = myMsgs[thread][msg].getDate();
        if( start < reciveDate && end > reciveDate )
        {
          mailIdList.unshift(myMsgs[thread][msg].getId());
        }
      }
    } 
  }
}

//////////////////////////////////////////////////////////////////////////////////////
// カスタマイズ部分から呼び出している関数はここから
////////////////////////////////////////
// 名前を指定したフォルダ生成する、すでに存在する場合はそのフォルダを取得する
// 階層構造を一気に作成できます。
// 作成したいフォルダ:(RootFolder)/folderA/folderB/folderC
// 呼び出し方:getDriveFolder(["folerA","folderB","folderC"]);
function getDriveFolder( folderHierarchy, folder ) {
  if ( folder === undefined ) folder = DriveApp.getRootFolder();    
  for(var h = 0;h < folderHierarchy.length; h++ ){
    var childs = folder.getFoldersByName(folderHierarchy[h]);
    if( childs.hasNext() ) {
      // 指定された名前のフォルダがあるときはそのフォルダを使用
      folder = childs.next();
    } else {
      // 指定された名前のフォルダが見つからなかったときはフォルダを生成
      folder = folder.createFolder( folderHierarchy[h] );
    }
  }
  return folder;
}
////////////////////////////////////////
// カスタマイズ部分から呼び出している関数はここまで
//////////////////////////////////////////////////////////////////////////////////////

メールから必要な部分を抜き出してスプレッドシートに張り付ける場合のAction関数は以下のような感じで実現した。

  // メールに対する処理
  var Action = function(mail)
  {
    // 処理サンプル:以下のメールから必要な部分をシートに張り付ける。
      //    氏 名 様
      //    
      //    いつも ANAプリペシルバーマイルコース をご利用いただき、
      //    ありがとうございます。
      //    氏 名 様のカードのご利用がありましたのでご連絡します。
      //    
      //     【ご利用明細の承認番号】
      //      1234567
      //    
      //     【ご利用カード番号】
      //      ****-****-****-0000
      //      ※セキュリティ保護のため、下4桁のみ表示しています。
      //    
      //     【ご利用日時(日本時間)】
      //      2018/12/29 17:20
      //    
      //     【ご利用先】
      //      セブン-イレブン
      //    
      //     【ご利用金額】
      //      1,234 円
      //    
      //     【バリュー残高】
      //      123,456 円
      //    
      //    ご利用先は、加盟店からの売上情報到着前の情報となり、実際のご利用先と名称が異なる場合があります。
      //    (売上情報到着後の名称については、マイページにログインのうえ「ご利用明細の確認」メニューよりご確認ください。)
      //    なお、携帯電話料金などの月額料金のお支払いに登録され、
      //    9:00PM以降にバリュー残高からの加算・減算が発生した場合は、翌8:00AM以降のメール送信となります。
      //    
      //    ご不明点などありましたら、JCBプリペイドカードデスクにお問い合わせください。    
    
    
    var sheet = SpreadsheetApp.getActiveSheet();
    var valTitle = [["【ご利用日時(日本時間)】","【ご利用先】","【ご利用金額】","【バリュー残高】","【ご利用明細の承認番号】","【ご利用カード番号】"]];
    var i = sheet.getLastRow();
  
    if(i==0){
      sheet.getRange(1, 1, 1, valTitle[0].length).setValues(valTitle); //シートに貼り付け
      i=1;
    }
    var bodys = mail.getPlainBody().replace(/[\t \r]/g,'').split("\n");
    var valMsgs=[];
    valMsgs[0]=[];
    for(var l = 0;l < valTitle[0].length; l++ ){
      valMsgs[0][l] = bodys[bodys.indexOf(valTitle[0][l])+1];
    }
    sheet.getRange(i+1, 1, 1,valTitle[0].length).setValues(valMsgs); //シートに貼り付け
  }