2022年11月29日火曜日

Google Formsでドロップダウン形式の日付を毎日変更する

背景

グーグルフォームのGASで自動で毎日ドロップダウン形式の日付が変わる仕組みの質問質問があり、リファレンスマニュアルをみてなんとなくできそうなことはわかっていたが、実際に作ってみた。

ソースコート

function updateForm() {
  // 毎日実行するトリガを設定
  setTrigger( arguments.callee.name );

  var forms = FormApp.getActiveForm();
  var items = forms.getItems();

  var table = [
    {'title':'発症日','start':-14 ,'end':0 }, // 二週間前から今日まで
    {'title':'診察希望日1','start':7 ,'end':1 }, // 7日後から明日まで(逆順)
    {'title':'診察希望日2','start':1 ,'end':7 } // 明日から7日後まで
  ];
  
  // 途中に日付が変わると面倒なのでここで取得
  var today = new Date();
  items.forEach( (item) =>{
    table.forEach( (range) =>{
      // リスト形式で質問がテーブルのtitleと一致したときに処理
      if( item.getType() == FormApp.ItemType.LIST && item.getTitle() == range.title )
      {
        item = item.asListItem();
        var choices = [];

        // テーブルの内容に従い選択肢を追加
        var dir = (range.start<range.end)?1:-1;
        for( var i = range.start; (range.start<=i&&i<=range.end)||(range.end<=i&&i<=range.start); i += dir )
        {
         var day = new Date( today );
          day.setDate(today.getDate() + i);
          choices.push( item.createChoice(formatDate( day, "yyyy/MM/dd" )));
        }
        item.setChoices(choices);
      }
    });

  });
}

function formatDate (date, format) {
  format = format.replace(/yyyy/g, date.getFullYear());
  format = format.replace(/MM/g, ('0' + (date.getMonth() + 1)).slice(-2));
  format = format.replace(/dd/g, ('0' + date.getDate()).slice(-2));
  format = format.replace(/HH/g, ('0' + date.getHours()).slice(-2));
  format = format.replace(/mm/g, ('0' + date.getMinutes()).slice(-2));
  format = format.replace(/ss/g, ('0' + date.getSeconds()).slice(-2));
  format = format.replace(/SSS/g, ('00' + date.getMilliseconds()).slice(-3));
  return format;
}

function setTrigger(name) {
  // 念のため既存のトリガはすべて削除してから次のトリガーを設定する
  var triggers = ScriptApp.getProjectTriggers();
  triggers.forEach( (trigger) =>{
    if( trigger.getHandlerFunction() == name )
    {
      ScriptApp.deleteTrigger(trigger);
    }
  });
  var setTime = new Date();
  setTime.setDate(setTime.getDate() + 1)
  setTime.setHours(12);
  setTime.setMinutes(00); 
  ScriptApp.newTrigger(name).timeBased().at(setTime).create();  
}

フォームに追加したプルダウン



2022年11月20日日曜日

GoogleTakeoutでダウンロードしたGoogleフォト画像のファイルタイムスタンプを修正するプログラム



以前の記事( https://blog.hikozaru.com/2022/11/googletakeoutgoogle.html)で紹介した作業をもう少し簡単に実行できるようにプログラムを作成したのでそのソースコードを公開する。いろいろ手を抜くために邪道なこと(変数名に日本語を使う)を行っているが、気になる人は好きに修正するとよい。プロジェクト全体が欲しい方はここから、実行できるものが欲しい方はここからダウンロードしてください。

takeout機能で複数のZIPファイルに分割された場合にはすべての同一のフォルダに統合してください。****.jpegと****.jpeg.jsonが同一フォルダに存在する場合に処理可能なのですが、****.jpegと****.jpeg.jsonが同一のZIPファイルに格納されていると限りません。

該当のファイルを実行アイコンにドラッグ&ドロップするか、実行して表示されたウインドウにドラッグ&ドロップしてください。


プログラムの大まかな処理の流れは以下です。

  1. 指定されたファイルもしくは指定されたフォルダ配下のファイルすべてを処理
    ただし、JSONファイルは処理をスキップ
  2. ファイル名の後ろに".json"を付加したファイルが存在していたらデシリアライズして撮影日時を取得
  3. 撮影日時はunixtimeなのでutcを経由してローカル時間に変換
  4. ファイルの作成日時と更新日時を変更、その処理が成功したらJSONファイルも同様に作成日時と更新日時を変更
  5. 結果をDataGridに出力
JSONファイルから撮影日時を取り出してファイルのタイムスタンプを修正する処理に関連するコードはほんの一部(コードの背景色を変えた行)です。その他は操作性やエラー発生時の処理などを考慮したコードです。

まだ、大量データでのテストは実施していませんが、DataGridへの出力がボトルネックとなる可能性があります。もしかしたら処理成功したファイルの表示は辞めるとか、そもそもDataGridへの出力は辞めて結果をログファイルに出力の方が良いかもしれません。


まずはCSファイル

using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Runtime.Serialization;
using System.Threading;
using System.Windows;
namespace PhotosTimestamp
{
    [DataContract]
    public class ImageInfo
    {
        [DataMember(Name = "photoTakenTime")]
        public PhotoTakenTime PhotoTakenTime { get; set; }
    }
    [DataContract]
    public class PhotoTakenTime
    {
        [DataMember(Name = "timestamp")]
        public string timstamp { get; set; }
    }

    public partial class MainWindow : Window
    {
        enum E_RESULT
        {
            更新,
            失敗,
            SKIP
        };

        class Result
        {
            public int 番号 { get; set; }
            public E_RESULT 結果 { get; set; }
            public string ファイル名 { get; set; }
            public string エラーメッセージ { get; set; }
            public string 例外メッセージ { get; set; }
        }

        const string HowToUse
            = "Google TakeoutでダウンロードしたZIPファイルを展開し、展開されたファイル/フォルダをドラッグ&ドロップして下さい。\n"
            + "コマンドライン引数やプログラムアイコンへのドラッグ&ドロップでもファイル/フォルダ指定可能です。\n"
            + "画像ファイルにJSONファイル(例:IMG_1234.PNGに対しIMG_1234.PNG.json)がある場合に処理を行います。\n"
            + "TakeoutでZIPファイルが分割された場合には一つのフォルダに統合してください、画像ファイルとJSONファイルが同一のZIPに格納されるとは限りません。\n"
            + "JSONファイルに記録されているphotoTakenTime.timestampをLocal時間に変換し、画像ファイルとJSONファイルの作成日時と更新日時に設定します。";

        public MainWindow()
        {
            InitializeComponent();
            var resultList = new ObservableCollection();
            resultDataGrid.ItemsSource = resultList;
        }

        // 引数で指定されたときは即実行
        private void Window_ContentRendered(object sender, EventArgs e)
        {
            string[] args = Environment.GetCommandLineArgs();
            if (args.Length > 1)
            {
                Thread thread = new Thread(new ParameterizedThreadStart(WorkerThread));
                thread.Start(args.Skip(1).ToArray());
            }
            else
            {
                ddPanel.AllowDrop = true;
                status.Content = HowToUse;
            }
        }

        // Drag&Drop関連処理
        private void ddPanel_PreviewDragOver(object sender, DragEventArgs e)
        {
            e.Effects = DragDropEffects.None;
            if (e.Data.GetDataPresent(System.Windows.DataFormats.FileDrop, true))
            {
                e.Effects = DragDropEffects.Copy;
                e.Handled = e.Data.GetDataPresent(DataFormats.FileDrop);
            }
        }
        private void ddPanel_Drop(object sender, DragEventArgs e)
        {
            if (e.Data.GetDataPresent(DataFormats.FileDrop))
            {
                Thread thread = new Thread(new ParameterizedThreadStart(WorkerThread));
                thread.Start(e.Data.GetData(DataFormats.FileDrop));
            }
        }


        // UIが固まらないようにThreadで処理
        void WorkerThread(object arg)
        {
            Dispatcher.Invoke((Action)(() =>
            {
                ddPanel.AllowDrop = false;
                status.Content = "処理開始";
                var dataList = resultDataGrid.ItemsSource as ObservableCollection;
                dataList.Clear();
            }));
            DoEntries((string[])arg);
            Dispatcher.Invoke((Action)(() =>
            {
                ddPanel.AllowDrop = true;
                status.Content = "処理が終了しました。\n" + HowToUse;
            }));
        }
        void DoEntries(string[] args)
        {
            foreach (string arg in args)
            {
                if (File.Exists(arg) == true)
                {
                    DoFile(arg);
                }
                else
                {
                    try
                    {
                        foreach (var path in Directory.GetFiles(arg, "*", System.IO.SearchOption.AllDirectories))
                        {
                            DoFile(path);
                        }
                    }
                    catch (Exception e)
                    {
                        Dispatcher.Invoke((Action)(() =>
                        {
                            var dataList = resultDataGrid.ItemsSource as ObservableCollection;
                            dataList.Add(new Result() { 番号=dataList.Count+1, 結果=E_RESULT.失敗, ファイル名 = arg, エラーメッセージ="ファイルまたはフォルダが見つかりませんでした。",例外メッセージ = e.ToString() });
                        }));
                    }
                }
            }
        }

        void DoFile(string targetFilename)
        {
            var jsonFilename = targetFilename + ".json";
            if (string.Compare(".json", System.IO.Path.GetExtension(targetFilename), true) == 0)
            {
                Dispatcher.Invoke((Action)(() =>
                {
                    status.Content = targetFilename;
                }));
            }
            else if (System.IO.File.Exists(jsonFilename))
            {
                DateTime photoTakenTime;
                try
                {
                    using (var stream = new FileStream(jsonFilename, FileMode.Open, FileAccess.Read))
                    {
                        var serializer = new System.Runtime.Serialization.Json.DataContractJsonSerializer(typeof(ImageInfo));
                        var imageInfo = (ImageInfo)serializer.ReadObject(stream);
                        var unixtime = imageInfo.PhotoTakenTime.timstamp;
                        photoTakenTime = DateTimeOffset.FromUnixTimeSeconds(long.Parse(unixtime)).LocalDateTime;
                    }
                    var imageFile = new System.IO.FileInfo(targetFilename);
                    var jsonFile = new System.IO.FileInfo(jsonFilename);

                    var results = new Result[] {
                        new Result(){ 結果=E_RESULT.SKIP,ファイル名=targetFilename,エラーメッセージ="",例外メッセージ="" },
                        new Result(){ 結果=E_RESULT.SKIP,ファイル名=jsonFilename,エラーメッセージ="画像ファイルの更新に失敗したときはJSONファイルも更新しません。",例外メッセージ="" }
                    };

                    foreach (var result in results)
                    {
                        try
                        {
                            var file = new System.IO.FileInfo(result.ファイル名);
                            file.CreationTime = photoTakenTime;
                            file.LastWriteTime = photoTakenTime;
                            result.結果 = E_RESULT.更新;
                            result.エラーメッセージ = "";
                            result.例外メッセージ = "";
                        }
                        catch (Exception e)
                        {
                            result.結果 = E_RESULT.失敗;
                            result.エラーメッセージ = "タイムスタンプの更新に失敗しました。";
                            result.例外メッセージ = e.ToString();
                            break;
                        }
                    }
                    Dispatcher.Invoke((Action)(() =>
                    {
                        status.Content = targetFilename;
                        var dataList = resultDataGrid.ItemsSource as ObservableCollection;
                        foreach (var result in results)
                        {
                            result.番号 = dataList.Count + 1;
                            dataList.Add(result);
                        }
                    }));
                }
                catch(Exception e)
                {
                    Dispatcher.Invoke((Action)(() =>
                    {
                        status.Content = targetFilename;
                        var dataList = resultDataGrid.ItemsSource as ObservableCollection;
                        dataList.Add(new Result() { 番号 = dataList.Count + 1, 結果 = E_RESULT.失敗, ファイル名 = jsonFilename, エラーメッセージ = "タイムスタンプの取得に失敗しました。", 例外メッセージ = e.ToString() });
                    }));
                }
            }
            else
            {
                Dispatcher.Invoke((Action)(() =>
                {
                    status.Content = targetFilename;
                    var dataList = resultDataGrid.ItemsSource as ObservableCollection;
                    dataList.Add(new Result() { 番号 = dataList.Count + 1, 結果 = E_RESULT.SKIP, ファイル名 = targetFilename, エラーメッセージ = "*.JSONファイルがありません。", 例外メッセージ = "" });
                }));
            }
        }
    }
}
次はXAMLファイル
<window contentrendered="Window_ContentRendered" height="450" mc:ignorable="d" title="Google Takeoutでダウンロードした画像のタイムスタンプをJSONファイルを参考に修正します。" width="1200" x:class="PhotosTimestamp.MainWindow" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="clr-namespace:PhotosTimestamp" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
    <dockpanel allowdrop="false" drop="ddPanel_Drop" name="ddPanel" previewdragover="ddPanel_PreviewDragOver">
        <label dockpanel.dock="Bottom" name="status">
        <datagrid dockpanel.dock="Top" isreadonly="True" name="resultDataGrid">
    </datagrid></label></dockpanel>
</window>

2022年11月14日月曜日

GoogleTakeoutでダウンロードしたGoogleフォト画像のファイルタイムスタンプを修正する

2022/11/19追記:以前のスクリプトはUTC時間をそのまま設定していたため9時間ずれていることに気が付きました。スクリプトを修正したのでJSONファイルが残っていれば新スクリプトを再実行してください、正しい時間に修正されます。
22022/11/20追記:PowerShellは難しいという方は次の記事で専用のプログラムをダウンロードできるようにしています。

概要

Googleフォトのデータをtakeout(https://takeout.google.com/?hl=ja)機能でダウンロードするとファイルのタイムスタンプがダウンロード実施した日になってしまう。Takeoutで出力したデータに含まれているJSONデータに撮影日時が記録されているので、その情報をもとに修正する方法を説明します。Windowsパソコンが必要です。
なお、takeout機能で複数のZIPファイルに分割された場合にはすべての同一のフォルダに統合してください。****.jpegと****.jpeg.jsonが同一フォルダに存在する場合に処理可能なのですが、****.jpegと****.jpeg.jsonが同一のZIPファイルに格納されていると限りません。

①PowerShellのスクリプトを実行できるようにする

PowerShell 管理者権限として実行します。ウインドウズメニューを開いた状態で「PowerSell」と入力すると見つかるので、「管理者として実行する」を選択してください。

以下のコマンドを実行し現在の権限を確認しメモしておいてください。

Get-ExecutionPolicy

以下のコマンドを実行して権限を変更してください。

Set-ExecutionPolicy RemoteSigned

変更してよいか確認されるので Y キーを押してください。

なお、権限(RestrictedやRemoteSignedなどの)の詳細は以下を参照してください。

https://learn.microsoft.com/ja-jp/powershell/module/microsoft.powershell.core/about/about_execution_policies?view=powershell-7.3#powershell-execution-policies


②スクリプトを作成

拡張子を表示した状態で「CorrectTimestamp.ps1」というファイルを作成しメモ帳で以下の様に編集してください。

$files = Get-ChildItem $PSScriptRoot -File -Recurse -Exclude *.json,*.html,*.ps1,*.zip
foreach($i in $files){
  Write-Host $i.FullName
  $jsondata = Get-Content -Path $i".json" | ConvertFrom-JSON
  $j = $i.FullName+".json"
  
  $UnixTime = $jsondata.photoTakenTime.timestamp.Trim('"')
  $UtcTime = ([DateTime]::Parse("1970/01/01 00:00:00")).addSeconds($UnixTime)
  $LocalTime= [TimeZoneInfo]::ConvertTimeFromUtc($UtcTime,[TimezoneInfo]::local)

  Set-ItemProperty $i -name CreationTime -value $LocalTime
  Set-ItemProperty $j -name CreationTime -value $LocalTime
  Set-ItemProperty $i -name LastWriteTime -value $LocalTime
  Set-ItemProperty $j -name LastWriteTime -value $LocalTime
}
Write-Host "Processing has finished. Please press any key. . ." -NoNewLine
[Console]::ReadKey($true) > $null

JSON内にはいくつかの日時が記録されております、上記のスクリプトではphotoLastModifiedTimeを更新日時に設定していますが、photoTakenTimeを採用するなど好みに合わせて変更してください。更新日時もphotoTakenTimeにしています。


③Zipファイルの展開とスクリプトの配置

Takeout機能でダウンロードしたZIPファイルを全て展開し、展開されたフォルダに「CorrectTimestamp.ps1」をコピーしてください。


④スクリプトの実行

「CorrectTimestamp.ps1」ファイルを右クリックして「PowerShellで実行」を行うとタイムスタンプが修正されます。


その他

JSONファイルに記録されているデータを使ってファイル名を変えたりすることもできるが、PowerShellでなくWSH(Windows Script Host)などでも処理できると思います。