5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

毎朝Googleカレンダーを開くのが面倒なので、起動時に予定とタスクを出すアプリを作った

Posted at

1. 今日のタスク何だっけ?

私は前日に次の日のタスクをGoogleカレンダーに書き出すことが多いのですが、朝になると何のタスクや打ち合わせがあったか忘れて、Googleカレンダーを確認します。

この毎朝Googleカレンダーを開くことが意外と面倒なので、PC起動時にタスクと予定一覧が表示されるアプリを作成してみました。

2. できたもの!

このような感じでその日のタスクと予定をGoogleカレンダーから引っ張ってきてウィンドウに表示するアプリです。

3. Google Cloude Consoleの設定

Google Cloud Consoleにアクセスします。

  1. 新しいプロジェクトの作成(または既存のプロジェクトを選択)
  2. メニューから「APIとサービス」→「ライブラリ」を選択
  3. 「Google Calendar API」を検索して有効化
  4. 「APIとサービス」→「認証情報」を選択
  5. 「認証情報を作成」→「OAuthクライアントID」を選択
  6. アプリケーションの種類:「デスクトップアプリ」を選択
  7. 名前を入力して「作成」
  8. 作成したクライアントID名をクリック
  9. 作成時に「JSONをダウンロード」ボタンをクリックし、redentials.json にリネーム
  10. google_calendar_today.py と同じフォルダに配置
  11. Google Cloud Console → APIとサービス → Tasks APIを検索して有効化

4. OAuth同意画面の設定(初回のみ)

  1. 「APIとサービス」→「OAuth同意画面」を選択
  2. 対象 → テストユーザーに自分のGmailアドレスを追加

5. 実装

5.1. 全体コード

全体コードはこちらです。
import os
import sys
import tkinter as tk
from tkinter import ttk, messagebox
from datetime import datetime, timedelta
import webbrowser

# Google API関連のインポート
try:
    from google.auth.transport.requests import Request
    from google.oauth2.credentials import Credentials
    from google_auth_oauthlib.flow import InstalledAppFlow
    from googleapiclient.discovery import build
    from googleapiclient.errors import HttpError
    GOOGLE_API_AVAILABLE = True
except ImportError:
    GOOGLE_API_AVAILABLE = False

# スコープ(カレンダーとタスクの読み取り)
SCOPES = [
    'https://www.googleapis.com/auth/calendar.readonly',
    'https://www.googleapis.com/auth/tasks.readonly'
]

# 設定
CREDENTIALS_FILE = 'credentials.json'
TOKEN_FILE = 'token.json'


def get_google_services():
    """Google Calendar & Tasks APIサービスを取得"""
    creds = None
    
    # 保存済みのトークンがあれば読み込む
    if os.path.exists(TOKEN_FILE):
        creds = Credentials.from_authorized_user_file(TOKEN_FILE, SCOPES)
    
    # 有効な認証情報がなければ、ユーザーにログインを求める
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            if not os.path.exists(CREDENTIALS_FILE):
                return None, None, "credentials.jsonが見つかりません"
            flow = InstalledAppFlow.from_client_secrets_file(CREDENTIALS_FILE, SCOPES)
            creds = flow.run_local_server(port=0)
        
        # トークンを保存
        with open(TOKEN_FILE, 'w') as token:
            token.write(creds.to_json())
    
    calendar_service = build('calendar', 'v3', credentials=creds)
    tasks_service = build('tasks', 'v1', credentials=creds)
    return calendar_service, tasks_service, None


def get_today_events(service):
    """今日の予定を取得"""
    import pytz
    
    # JST対応
    jst = pytz.timezone('Asia/Tokyo')
    today_jst = datetime.now(jst).replace(hour=0, minute=0, second=0, microsecond=0)
    tomorrow_jst = today_jst + timedelta(days=1)
    
    time_min = today_jst.isoformat()
    time_max = tomorrow_jst.isoformat()
    
    events_result = service.events().list(
        calendarId='primary',
        timeMin=time_min,
        timeMax=time_max,
        singleEvents=True,
        orderBy='startTime'
    ).execute()
    
    return events_result.get('items', [])


def get_today_tasks(service):
    """今日が期限のタスクを取得"""
    import pytz
    
    jst = pytz.timezone('Asia/Tokyo')
    today_jst = datetime.now(jst).date()
    
    all_tasks = []
    
    try:
        # すべてのタスクリストを取得
        tasklists_result = service.tasklists().list().execute()
        tasklists = tasklists_result.get('items', [])
        
        for tasklist in tasklists:
            tasklist_id = tasklist['id']
            tasklist_title = tasklist.get('title', 'タスク')
            
            # 各タスクリストからタスクを取得(完了していないもの)
            tasks_result = service.tasks().list(
                tasklist=tasklist_id,
                showCompleted=False,
                showHidden=False
            ).execute()
            
            tasks = tasks_result.get('items', [])
            
            for task in tasks:
                due = task.get('due')
                if due:
                    # due は 'YYYY-MM-DDT00:00:00.000Z' 形式
                    due_date = datetime.fromisoformat(due.replace('Z', '+00:00')).date()
                    if due_date == today_jst:
                        task['tasklist_title'] = tasklist_title
                        all_tasks.append(task)
    
    except HttpError as e:
        print(f"Tasks API error: {e}")
    
    return all_tasks


def format_time(event):
    """イベントの時刻をフォーマット"""
    start = event['start'].get('dateTime', event['start'].get('date'))
    
    # 終日イベントの場合
    if 'date' in event['start']:
        return '終日', None
    
    # 時刻付きイベントの場合
    start_dt = datetime.fromisoformat(start.replace('Z', '+00:00'))
    start_str = start_dt.strftime('%H:%M')
    
    # 終了時刻から所要時間を計算
    end = event['end'].get('dateTime', event['end'].get('date'))
    if end and 'dateTime' in event['end']:
        end_dt = datetime.fromisoformat(end.replace('Z', '+00:00'))
        duration = end_dt - start_dt
        duration_minutes = int(duration.total_seconds() / 60)
        
        if duration_minutes >= 60:
            hours = duration_minutes // 60
            minutes = duration_minutes % 60
            if minutes > 0:
                duration_str = f"{hours}時間{minutes}"
            else:
                duration_str = f"{hours}時間"
        else:
            duration_str = f"{duration_minutes}"
        
        return start_str, duration_str
    
    return start_str, None


def get_event_color(event):
    """イベントの種類に応じて色を返す"""
    summary = event.get('summary', '').lower()
    
    # タスク系キーワード
    if any(word in summary for word in ['タスク', 'task', 'todo', '作業']):
        return '#FF6B6B'  # 赤系
    # ミーティング系
    elif any(word in summary for word in ['会議', 'ミーティング', 'mtg', 'meeting', '打ち合わせ']):
        return '#4ECDC4'  # 緑系
    # リマインダー系
    elif any(word in summary for word in ['リマインダー', 'reminder', '確認']):
        return '#FFE66D'  # 黄色系
    else:
        return '#95E1D3'  # デフォルト緑


class CalendarApp:
    def __init__(self, root):
        self.root = root
        self.root.title("今日の予定 & タスク")
        self.root.geometry("520x700")
        self.root.configure(bg='#2C3E50')
        
        # ウィンドウを画面右上に配置
        self.root.update_idletasks()
        screen_width = self.root.winfo_screenwidth()
        x = screen_width - 540
        self.root.geometry(f"+{x}+20")
        
        self.setup_ui()
        self.load_events()
    
    def setup_ui(self):
        """UIを構築"""
        # ヘッダー
        header_frame = tk.Frame(self.root, bg='#34495E', pady=15)
        header_frame.pack(fill=tk.X)
        
        today_str = datetime.now().strftime('%Y年%m月%d日 (%a)')
        header_label = tk.Label(
            header_frame, 
            text=today_str,
            font=('Helvetica', 18, 'bold'),
            fg='white',
            bg='#34495E'
        )
        header_label.pack()
        
        # サブヘッダー
        sub_label = tk.Label(
            header_frame,
            text="今日の予定 & タスク一覧",
            font=('Helvetica', 12),
            fg='#BDC3C7',
            bg='#34495E'
        )
        sub_label.pack()
        
        # メインコンテンツ(スクロール可能)
        self.canvas = tk.Canvas(self.root, bg='#2C3E50', highlightthickness=0)
        scrollbar = ttk.Scrollbar(self.root, orient="vertical", command=self.canvas.yview)
        self.scrollable_frame = tk.Frame(self.canvas, bg='#2C3E50')
        
        self.scrollable_frame.bind(
            "<Configure>",
            lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))
        )
        
        self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw")
        self.canvas.configure(yscrollcommand=scrollbar.set)
        
        self.canvas.pack(side="left", fill="both", expand=True, padx=10, pady=10)
        scrollbar.pack(side="right", fill="y")
        
        # マウスホイールでスクロール
        self.canvas.bind_all("<MouseWheel>", self._on_mousewheel)
    
    def _on_mousewheel(self, event):
        self.canvas.yview_scroll(int(-1*(event.delta/120)), "units")
    
    def load_events(self):
        """予定とタスクを読み込んで表示"""
        # 既存のウィジェットをクリア
        for widget in self.scrollable_frame.winfo_children():
            widget.destroy()
        
        if not GOOGLE_API_AVAILABLE:
            self.show_setup_message()
            return
        
        try:
            calendar_service, tasks_service, error = get_google_services()
            if error:
                self.show_error(error)
                return
            
            # カレンダーイベントを取得
            events = get_today_events(calendar_service)
            
            # タスクを取得
            tasks = get_today_tasks(tasks_service)
            
            has_content = False
            
            # タスクセクション
            if tasks:
                has_content = True
                self.create_section_header("今日が期限のタスク", '#E74C3C')
                for task in tasks:
                    self.create_task_card(task)
            
            # カレンダーセクション
            if events:
                has_content = True
                self.create_section_header("今日の予定", '#3498DB')
                for event in events:
                    self.create_event_card(event)
            
            if not has_content:
                self.show_no_events()
            
            # 更新ボタンを下部に追加
            self.add_refresh_button()
                
        except HttpError as e:
            self.show_error(f"APIエラー: {e}")
        except Exception as e:
            self.show_error(f"エラー: {str(e)}")
    
    def create_section_header(self, title, color):
        """セクションヘッダーを作成"""
        header = tk.Frame(self.scrollable_frame, bg='#2C3E50', pady=10)
        header.pack(fill=tk.X, padx=5)
        
        label = tk.Label(
            header,
            text=title,
            font=('Helvetica', 14, 'bold'),
            fg=color,
            bg='#2C3E50',
            anchor='w'
        )
        label.pack(fill=tk.X)
    
    def create_task_card(self, task):
        """タスクカードを作成"""
        card = tk.Frame(
            self.scrollable_frame,
            bg='#4A3728',
            pady=10,
            padx=10
        )
        card.pack(fill=tk.X, pady=3, padx=5)
        
        # 左側の色バー(タスクは赤系)
        color_bar = tk.Frame(card, bg='#E74C3C', width=5)
        color_bar.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10))
        
        # コンテンツフレーム
        content = tk.Frame(card, bg='#4A3728')
        content.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        
        # タスクリスト名
        tasklist_title = task.get('tasklist_title', '')
        if tasklist_title:
            list_label = tk.Label(
                content,
                text=tasklist_title,
                font=('Helvetica', 9),
                fg='#E74C3C',
                bg='#4A3728',
                anchor='w'
            )
            list_label.pack(fill=tk.X)
        
        # タスクタイトル
        title = task.get('title', '(タイトルなし)')
        title_label = tk.Label(
            content,
            text=title,
            font=('Helvetica', 12),
            fg='white',
            bg='#4A3728',
            anchor='w',
            wraplength=380
        )
        title_label.pack(fill=tk.X)
        
        # メモ(あれば)
        notes = task.get('notes', '')
        if notes:
            notes_short = notes[:80] + '...' if len(notes) > 80 else notes
            notes_label = tk.Label(
                content,
                text=notes_short,
                font=('Helvetica', 9),
                fg='#95A5A6',
                bg='#4A3728',
                anchor='w'
            )
            notes_label.pack(fill=tk.X)
    
    def create_event_card(self, event):
        """イベントカードを作成"""
        color = get_event_color(event)
        
        card = tk.Frame(
            self.scrollable_frame,
            bg='#3D5A73',
            pady=10,
            padx=10
        )
        card.pack(fill=tk.X, pady=3, padx=5)
        
        # 左側の色バー
        color_bar = tk.Frame(card, bg=color, width=5)
        color_bar.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10))
        
        # コンテンツフレーム
        content = tk.Frame(card, bg='#3D5A73')
        content.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        
        # 時刻と所要時間
        time_str, duration_str = format_time(event)
        
        time_frame = tk.Frame(content, bg='#3D5A73')
        time_frame.pack(fill=tk.X)
        
        time_label = tk.Label(
            time_frame,
            text=time_str,
            font=('Helvetica', 14, 'bold'),
            fg=color,
            bg='#3D5A73',
            anchor='w'
        )
        time_label.pack(side=tk.LEFT)
        
        # 所要時間バッジ
        if duration_str:
            duration_label = tk.Label(
                time_frame,
                text=f"  ({duration_str})",
                font=('Helvetica', 10),
                fg='#BDC3C7',
                bg='#3D5A73',
                anchor='w'
            )
            duration_label.pack(side=tk.LEFT)
        
        # タイトル
        title = event.get('summary', '(タイトルなし)')
        title_label = tk.Label(
            content,
            text=title,
            font=('Helvetica', 12),
            fg='white',
            bg='#3D5A73',
            anchor='w',
            wraplength=380
        )
        title_label.pack(fill=tk.X)
        
        # 場所(あれば)
        location = event.get('location', '')
        if location:
            loc_label = tk.Label(
                content,
                text=location,
                font=('Helvetica', 10),
                fg='#BDC3C7',
                bg='#3D5A73',
                anchor='w'
            )
            loc_label.pack(fill=tk.X)
        
        # 説明(あれば、短縮表示)
        description = event.get('description', '')
        if description:
            desc_short = description[:50] + '...' if len(description) > 50 else description
            desc_label = tk.Label(
                content,
                text=desc_short,
                font=('Helvetica', 9),
                fg='#95A5A6',
                bg='#3D5A73',
                anchor='w'
            )
            desc_label.pack(fill=tk.X)
    
    def show_no_events(self):
        """予定がない場合の表示"""
        frame = tk.Frame(self.scrollable_frame, bg='#2C3E50', pady=50)
        frame.pack(fill=tk.BOTH, expand=True)
        
        msg_label = tk.Label(
            frame,
            text="今日の予定・タスクはありません",
            font=('Helvetica', 14),
            fg='#BDC3C7',
            bg='#2C3E50'
        )
        msg_label.pack(pady=10)
    
    def show_error(self, message):
        """エラー表示"""
        frame = tk.Frame(self.scrollable_frame, bg='#2C3E50', pady=30)
        frame.pack(fill=tk.BOTH, expand=True)
        
        error_label = tk.Label(
            frame,
            text="エラー",
            font=('Helvetica', 16, 'bold'),
            fg='#E74C3C',
            bg='#2C3E50'
        )
        error_label.pack()
        
        msg_label = tk.Label(
            frame,
            text=message,
            font=('Helvetica', 11),
            fg='#BDC3C7',
            bg='#2C3E50',
            wraplength=400
        )
        msg_label.pack(pady=10)
    
    def show_setup_message(self):
        """セットアップエラー表示"""
        frame = tk.Frame(self.scrollable_frame, bg='#2C3E50', pady=50)
        frame.pack(fill=tk.BOTH, expand=True)
        
        msg_label = tk.Label(
            frame,
            text="Google APIライブラリが見つかりません",
            font=('Helvetica', 14),
            fg='#E74C3C',
            bg='#2C3E50'
        )
        msg_label.pack(pady=10)
    
    def refresh_events(self):
        """予定を再読み込み"""
        self.load_events()
    
    def add_refresh_button(self):
        """更新ボタンをスクロール領域の下部に追加"""
        button_frame = tk.Frame(self.scrollable_frame, bg='#2C3E50', pady=15)
        button_frame.pack(fill=tk.X)
        
        refresh_btn = ttk.Button(
            button_frame,
            text="更新",
            command=self.refresh_events
        )
        refresh_btn.pack()


def main():
    root = tk.Tk()
    
    # スタイル設定
    style = ttk.Style()
    style.theme_use('clam')
    
    app = CalendarApp(root)
    root.mainloop()


if __name__ == '__main__':
    main()

5.2. Google CalendarとTasks APIを同時に扱う認証

スコープをまとめて定義してtoken.json を使った再認証回避しています。また2つのserviceを同時に返しています

SCOPES = [
    'https://www.googleapis.com/auth/calendar.readonly',
    'https://www.googleapis.com/auth/tasks.readonly'
]
def get_google_services():
    creds = None

    if os.path.exists(TOKEN_FILE):
        creds = Credentials.from_authorized_user_file(TOKEN_FILE, SCOPES)

    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            flow = InstalledAppFlow.from_client_secrets_file(
                CREDENTIALS_FILE, SCOPES
            )
            creds = flow.run_local_server(port=0)

        with open(TOKEN_FILE, 'w') as token:
            token.write(creds.to_json())

    calendar_service = build('calendar', 'v3', credentials=creds)
    tasks_service = build('tasks', 'v1', credentials=creds)
    return calendar_service, tasks_service, None

5.3. JSTを意識して「今日の予定・今日が期限のタスク」を抽出

GoogleカレンダーはJSTで今日0:00〜24:00を指定しています。

jst = pytz.timezone('Asia/Tokyo')
today_jst = datetime.now(jst).replace(hour=0, minute=0, second=0, microsecond=0)
tomorrow_jst = today_jst + timedelta(days=1)

time_min = today_jst.isoformat()
time_max = tomorrow_jst.isoformat()

タスクはUTC表記のdueをdateに変換して比較しています。

due = task.get('due')
if due:
    due_date = datetime.fromisoformat(
        due.replace('Z', '+00:00')
    ).date()
    if due_date == today_jst:
        all_tasks.append(task)

6. 実行

手動で実行するならこれで実行できます。

python google_calendar_today.py

初回実行時はブラウザが開き、Googleアカウントでの認証が求められます。

Windowsで毎日(起動時)実行するなら

  1. タスクスケジューラを開く
  2. 「基本タスクの作成」をクリック
  3. トリガーを「ログオン時」または「毎日」の特定時刻
  4. 操作 → プログラムの開始
    1. プログラム: pythonw
    2. 引数: C:\path\to\google_calendar_today.py
    3. 開始: スクリプトのあるフォルダ

7. まとめ

プログラムの実行タイミングが起動時や特定の時刻になっており、微妙な気もしているので、もっとよいタイミングがあれば変更してみようかなと思います。

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?