iPhoneのショートカット機能を使って、GitHubのリポジトリにクリップボードからメモを保存するTips📝

# はじめに
[iPhoneのショートカット機能](https://support.apple.com/ja-jp/guide/shortcuts/welcome/ios)は便利であることを知っていましたが、使いこなせてはいませんでした。
調べると`HTTPリクエストの送信`や、`データの変換`など何かと便利ですね。

https://support.apple.com/ja-jp/guide/shortcuts/welcome/ios

今回はこの[ショートカット機能](https://support.apple.com/ja-jp/guide/shortcuts/welcome/ios)を使って、`GitHub`のリポジトリに`iPhone`のクリップボードの内容を**マークダウンとしてファイルを手軽に保存する方法**を紹介します。

## きっかけ
最近は`ChatGPT`や`Gemini`、`Claude`による調べ物が圧倒的に増えています。
また自分のプログラミング学習においても、「あ！これそういえばわかっていなかったな」という情報も気軽に聞いてしまえるほどです。

しかし調べ物を検証するためには、`PC`もといまとまった時間が必要になります。
一度チャットを投げて結果を見るも、あとでまた確認するというシーンが私は多いです。

そのため一時的にメモ📝を限りなく少ないアクションで残したいという気持ちがありました。

### クリップボードからペーストできるから必要ないのでは？
クリップボードから値を取得して、`Notion`やらお気に入りのメモアプリに値を貼り付ければ話が早いのですが、貼り付け先のアプリを立ち上げるのも私は億劫に感じます。
クリップボード📋のマークをタップしたら、限りなく少ない導線で保存できることが個人的な理想です。

::: note info
ここは私が非常に面倒くさがりな性格のため、このような結論に至っています。
:::

# 事前準備
今回試す自動化には下記が必要になります。
 - ショートカット機能が使用できるiOS端末 `iPhone`を想定
 - `GitHub`のリポジトリ `(プライベート)を想定`
 - `GitHub`の[個人用アクセス トークン](https://docs.github.com/ja/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)

## 個人用アクセス トークンの取得方法

https://docs.github.com/ja/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens

GitHubのWebページ、右側サイドパネルから[Setting]を選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/982b1eaa-c683-48ea-acf1-a6b46e4e9477.png)

[Setting]の中から[Developer settings]をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/628bcaa4-58e3-4b4a-8848-9f5c0757c39a.png)

[Personal access tokens]から[Fine-grained tokens]をクリックすると設定画面に遷移します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/1f41813f-9c68-40c6-abbd-00d4ffa654e9.png)

ここから[Generate new token]をクリックし、`必要な権限の範囲`と`有効期限`を設定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/551b7a60-1d9c-407d-97b6-d179d0d98c56.png)


必要な権限は`Contents Read and write`が該当します。

::: note warn
アクセス トークンは、パスワードと同じように扱ってください。
:::

# 試してみたショートカット
`iPhone`のショートカット機能の内容は下記のようなものです。
かなり簡易的なもとになっています。

1. リッチテキストからマークダウンを作成
   - クリップボードが変換元

2. Base64でエンコード
   - `(1)リッチテキストからマークダウンを作成`の値を参照
   - 行区切りを**指定しない**

3. 日付を書式設定
   - 現在の日付が変換元
   - 日付の書式 yyyyMMdd-HHmmss
   - 地域: 日本

4. 「Webの内容を取得」アクション
   - URL: https://api.github.com/repos/{username}/{repository}/contents/{filename}
   - 方法: PUT
   - ヘッダー:
     - Authorization: Bearer {your_token}
     - Accept: application/vnd.github.v3+json

```json
{
   "message": "コメント",
   "content": "(2)のアクションで取得したbase64文字列",
   "branch": "mainかmaster"
}
```

驚いたこととして`リッチテキストからマークダウンを作成`のような変換アクションが、標準でショートカットの機能に含まれています。
[markitdown](https://github.com/microsoft/markitdown)のようなライブラリを使用せずとも使えることはうれしい限りですね。

また`Base64エンコード`といった機能も備わっており、いろいろな自動化が試せそうだと今更知りました。

設定のポイントを見ていきます。

## 1. リッチテキストからマークダウンを作成
`変数`の選択画面で`クリップボード`を選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/bdc6331b-1a60-422e-8cf1-36996c51a920.png)

::: note info
ショートカットの機能の中には`クリップボードの内容の取得`といったアクションが存在しますが、今回のケースでは不要です。
:::

::: note
クリップボードの内容はテキストとして解釈しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/f56717e0-b2b3-4b1a-b13a-8d1c66a7c530.png)

私のような`ChatGPT`、`Claude`、`Gemini`の出力結果を保存したいという方をいったん想定し、`リッチテキストからマークダウンを作成`を採用していますが、ほかの書式やデータ型に対応させたい場合は条件分岐やスクリプトが必要になるかもしれません。
:::

## 2. Base64でエンコード
こちらもアクションで備わっています。デフォルトで`行区切り`が設定されてしまっているため、`指定しない`に変更してください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/f386b5b7-defa-422f-9741-d4dc24e7ab40.png)

## 3. 日付を書式設定 - ファイル名の設定
ファイル名を設定するために現在日時を取得します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/bf94b088-5f9e-44e3-a634-3582e40a9ef7.png)

`日付を書式設定`というアクションをもとに、`yyyyMMdd-HHmmss`でテキストを生成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/b9ebc0b6-6be1-45c5-ac00-b19b20e00f50.png)

## 4. Webの内容を取得
こちらがいわゆる[HTTP要求の送信](https://support.apple.com/ja-jp/guide/shortcuts/apd2d448b2de/8.0/ios/18.0)に該当します。

https://support.apple.com/ja-jp/guide/shortcuts/apd2d448b2de/8.0/ios/18.0

メソッドは`PUT`です。

GitHub REST APIについては下記が公式のドキュメントに該当します。
[GitHub REST API に関するドキュメント](https://docs.github.com/ja/rest)

URLは`https://api.github.com/repos/{ユーザーの名前}/{リポジトリの名前}/contents/{ファイル名称}`

ヘッダーは下記の通りです。

|キー|バリュー|
|---|---|
|Authorization|Bearer {事前準備で取得した個人用アクセス トークン}|
|Accept|application/vnd.github.v3+json|

実際の設定は上記をコピペしていくと簡単に済みます。
本文はJSONを直接打つのではなく、キーとバリューをガイドに沿って入力していきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/ceb135b5-1a32-46b3-a9b7-e31603984bbf.png)

```json
{
   "message": "コメント",
   "content": "(2)のアクションで取得したbase64文字列",
   "branch": "mainかmaster"
}
```

これでほぼ完成です。

# ChatGPTの出力で検証する
`Gitの基本的な考え方、コマンドを初心者向けにわかりやすく説明してください。出力にはコマンドを含め、どのようなシーンで利用するのか教えてください。`
上記のプロンプトで検証してみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/c3e95077-87fb-4105-984d-b03935738d71.png)

**クリップボードにコピーします**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/09e20f01-1d1c-4cc9-8546-c26f0b098713.png)

**この状態でショートカットを実行すると**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/4328e767-ece9-460e-a039-ffc3f7bf21f7.png)

**リポジトリにマークダウンファイルが、上手く保存されました🙌**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/abe1abee-8c9b-48b8-8ce3-8d6498633aa0.png)

# おまけ: READMEに目次を作ってもらう
`Claude`の力を利用して、`GitHub Actions`を使った目次の自動生成も仕込みます。
リポジトリの`.github/workflows`に下記のymlファイルを設定してください。

```yaml:generate-toc.yml
name: Generate Markdown Table of Contents

on:
  push:
    branches: [ main, master ]
    paths: ['**/*.md']
  pull_request:
    branches: [ main, master ]
    paths: ['**/*.md']
  workflow_dispatch:

jobs:
  generate-toc:
    runs-on: ubuntu-latest
    
    permissions:
      contents: write
      
    steps:
    - name: Checkout repository
      uses: actions/checkout@v4
      with:
        fetch-depth: 0
        
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: '18'
        
    - name: Generate Table of Contents
      run: |
        cat > generate-toc.js << 'EOF'
        const fs = require('fs');
        const path = require('path');
        
        function findMarkdownFiles(dir, fileList = []) {
          const files = fs.readdirSync(dir);
          
          files.forEach(file => {
            const filePath = path.join(dir, file);
            const stat = fs.statSync(filePath);
            
            if (stat.isDirectory()) {
              if (!file.startsWith('.') && file !== 'node_modules') {
                findMarkdownFiles(filePath, fileList);
              }
            } else if (file.endsWith('.md') && file.toLowerCase() !== 'readme.md') {
              fileList.push(filePath);
            }
          });
          
          return fileList;
        }
        
        // Markdownファイルからタイトルを抽出
        function extractTitle(filePath) {
          try {
            const content = fs.readFileSync(filePath, 'utf8');
            const h1Match = content.match(/^#\s+(.+)$/m);
            if (h1Match) {
              return h1Match[1].trim();
            }
            
            return path.basename(filePath, '.md');
          } catch (error) {
            return path.basename(filePath, '.md');
          }
        }
        
        function generateTOC() {
          const markdownFiles = findMarkdownFiles('.');
          
          if (markdownFiles.length === 0) {
            console.log('No markdown files found.');
            return;
          }
          
          const filesByDir = {};
          
          markdownFiles.forEach(filePath => {
            const dir = path.dirname(filePath);
            if (!filesByDir[dir]) {
              filesByDir[dir] = [];
            }
            
            const title = extractTitle(filePath);
            const relativePath = filePath.replace(/\\/g, '/'); // Windows対応
            
            filesByDir[dir].push({
              title,
              path: relativePath,
              filename: path.basename(filePath)
            });
          });
          
          let toc = '# 📚 Documentation Table of Contents\n\n';
          toc += `> 自動生成された目次 - 最終更新: ${new Date().toLocaleDateString('ja-JP')}\n\n`;
          
          if (filesByDir['.']) {
            toc += '## 📄 Root Directory\n\n';
            filesByDir['.'].forEach(file => {
              toc += `- [${file.title}](./${file.path})\n`;
            });
            toc += '\n';
          }
          
          Object.keys(filesByDir)
            .filter(dir => dir !== '.')
            .sort()
            .forEach(dir => {
              const dirName = dir.replace(/^\.\//, '');
              toc += `## 📁 ${dirName}\n\n`;
              
              filesByDir[dir].forEach(file => {
                toc += `- [${file.title}](./${file.path})\n`;
              });
              toc += '\n';
            });
          
          const totalFiles = markdownFiles.length;
          const totalDirs = Object.keys(filesByDir).length;
          
          toc += '---\n\n';
          toc += '## 📊 Statistics\n\n';
          toc += `- **Total Markdown files**: ${totalFiles}\n`;
          toc += `- **Directories**: ${totalDirs}\n`;
          toc += `- **Last updated**: ${new Date().toLocaleString('ja-JP')}\n\n`;
          
          let existingContent = '';
          if (fs.existsSync('README.md')) {
            existingContent = fs.readFileSync('README.md', 'utf8');
            
            const startMarker = '<!-- TOC_START -->';
            const endMarker = '<!-- TOC_END -->';
            
            if (existingContent.includes(startMarker) && existingContent.includes(endMarker)) {
              const beforeToc = existingContent.substring(0, existingContent.indexOf(startMarker));
              const afterToc = existingContent.substring(existingContent.indexOf(endMarker) + endMarker.length);
              existingContent = beforeToc + afterToc;
            }
          }
          
          const newReadme = existingContent.trim() ? 
            `${existingContent.trim()}\n\n<!-- TOC_START -->\n${toc}<!-- TOC_END -->\n` :
            `<!-- TOC_START -->\n${toc}<!-- TOC_END -->\n`;
          
          fs.writeFileSync('README.md', newReadme);
          console.log(`✅ Table of Contents generated successfully!`);
          console.log(`📝 Found ${totalFiles} markdown files in ${totalDirs} directories`);
        }
        
        generateTOC();
        EOF
        
        node generate-toc.js
        
    - name: Check for changes
      id: verify-changed-files
      run: |
        if [ -n "$(git status --porcelain)" ]; then
          echo "changed=true" >> $GITHUB_OUTPUT
        else
          echo "changed=false" >> $GITHUB_OUTPUT
        fi
        
    - name: Commit and push changes
      if: steps.verify-changed-files.outputs.changed == 'true'
      run: |
        git config --local user.email "action@github.com"
        git config --local user.name "GitHub Action"
        git add README.md
        git commit -m "📚 Auto-update Table of Contents [skip ci]"
        git push
```

上記を実行するために`リポジトリ`の`Setting`、`Actions`の`General`を開きます。
`Workflow permissions`に`Read and write permissions`の権限を付与してください。

こちらでファイルが追加される都度、下記のような目次が自動生成されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/9be81cbf-cac8-416a-b631-26a35f58880f.png)

# おわりに
メモ保存ツールとしてプライベートリポジトリの使用をしてみましたが、GitHubでなくてもほかのツールで応用できそうですね。
**身近な端末からこのような自動化ができることはとてもうれしいです。**

**AIはアイディアアクセラレータですので、どんどんこのような自動化を試していきたいと思います！**