#105 【Chrome拡張】ダウンロードフォルダの整理を便利にする拡張機能を作ってみる #2

## はじめに

本記事は、「ダウンロードフォルダを散らかしたくない...」という思いを解決するために、Chromeの拡張機能を作ってみようという試みの Part2 です。

## 前回までのあらすじ

一旦全体の要件を振り返ってみます。

1. コンテキストメニューでページ内情報 or 手動入力文字でフォルダを作成
2. ①で作成したフォルダ内にページへのショートカットを作成
3. ダウンロード時にページ内の情報 or 手動入力文字でフォルダを作成
4. ③で作成したフォルダにダウンロードファイルを格納
5. ②同様、フォルダ内にページへのショートカットを作成

[前回](https://qiita.com/nxted_sapporo/items/6638ed3bf0861539b0bc) は、①と②のコンテキストメニューをクリックしたサイトのショートカット情報をフォルダで区切りダウンロードするというところまで実装しました。

今回は、③～⑤のファイルダウンロード時にフォルダで区切り保存、ショートカット情報も同時に保存するという所まで実装していきます。

## ディレクトリ構成

今回の実装でのディレクトリ構成は [前回](https://qiita.com/nxted_sapporo/items/6638ed3bf0861539b0bc) と変わりません。
[前回](https://qiita.com/nxted_sapporo/items/6638ed3bf0861539b0bc) 作成したファイルに追記していく形で実装を進めていきます。
```
拡張機能名/
        ├ background.js
        ├ content.js
        └ manifest.json
```

## background.js

[前回](https://qiita.com/nxted_sapporo/items/6638ed3bf0861539b0bc) はコンテキストメニューの作成やメニュークリック時のイベントを記述しました。
今回は、ダウンロードイベント時にリネーム処理をしフォルダ区切りで保存します。
また、そのままだと [前回](https://qiita.com/nxted_sapporo/items/6638ed3bf0861539b0bc) 実装したコンテキストメニュー実行時にもイベントが走ってしまうので通常のダウンロードと区別する処理も必要です。
以下は全体コードで [前回](https://qiita.com/nxted_sapporo/items/6638ed3bf0861539b0bc) との差分となります。

```diff:background.js
// contextMenuを作成
chrome.runtime.onInstalled.addListener(() => {
        chrome.contextMenus.create({
            id: "downloadShortcut",
                title: "ショートカットをダウンロード",
        });
});

// contextMenuクリック時のイベント
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
        if (info.menuItemId === "downloadShortcut") {
            const shortcutInfo = await chromeSendMessage(tab.id, {
                    type: "getShortcutInfo",
+           tabId: tab.id,
            });
        downloadShortcut(shortcutInfo);

-       // 使用し終えたblobURLは解放する
-       chromeSendMessage(tab.id, {
-           type: "revokeBlobUrl",
-           url: shortcutInfo.url,
-       });
    }
});

+ // ダウンロードイベント時にリネームする
+ chrome.downloads.onDeterminingFilename.addListener((downloadItem, suggest) => {
+     const urlPrefix = downloadItem.url.substring(0, 4); // blobかどうか
+     getCurrentTabId()
+         .then((tabId) => {
+              return chromeSendMessage(tabId, { type: "getShortcutInfo", tabId });
+         })
+         .then((shortcutInfo) => {
+             // contextMenu以外で発生したダウンロードの場合、shortcutも同時にダウンロード
+             if (!(urlPrefix === "blob")) downloadShortcut(shortcutInfo);
+             return createFilename(shortcutInfo, downloadItem, urlPrefix);
+         })
+         .then((filename) => {
+             suggest({ filename });
+         });
+     return true; // 非同期的に呼び出すのでtrueを返す
+ });

+ // 現在のアクティブなtabIdを返す
+ const getCurrentTabId = async () => {
+     const [tab] = await chrome.tabs.query({
+         active: true,
+         lastFocusedWindow: true,
+     });
+     return tab.id;
+ };

+ // suggest用のオブジェクトを返す
+ const createFilename = (shortcutInfo, downloadItem, urlPrefix) => {
+     let filename = shortcutInfo.filename + "/";
+     if (urlPrefix === "blob") {
+         filename += shortcutInfo.filename + ".url";
+     } else {
+         filename += downloadItem.filename;
+     }
+     return filename;
+ };

// shortcut情報を取得
const chromeSendMessage = async (tabId, message) => {
    return await chrome.tabs.sendMessage(tabId, message);
};

// shortcutのダウンロード
const downloadShortcut = (shortcutInfo) => {
        chrome.downloads.download({
        url: shortcutInfo.url,
        filename: shortcutInfo.filename + "/" + shortcutInfo.filename + ".url",
    });

+   // 使用し終えたblobURLは解放する
+   chromeSendMessage(tab.id, {
+       type: "revokeBlobUrl",
+       url: shortcutInfo.url,
+   });
};
```  

それでは [前回](https://qiita.com/nxted_sapporo/items/6638ed3bf0861539b0bc) 同様に関数やコメント毎に何をしているのか説明していきます。

### downloads.onDeterminingFilename

今回、一番の肝となる部分です。
このAPIではダウンロードイベント時に `DownloadItem.filename` をオーバーライドできます。

```javascript:background.js
// ダウンロードイベント時にリネームする
chrome.downloads.onDeterminingFilename.addListener((downloadItem, suggest) => {
        const urlPrefix = downloadItem.url.substring(0, 4); // blobかどうか
        getCurrentTabId()
        .then((tabId) => {
                return chromeSendMessage(tabId, { type: "getShortcutInfo", tabId });
        })
        .then((shortcutInfo) => {
                // contextMenu以外で発生したダウンロードの場合、shortcutも同時にダウンロード
                if (!(urlPrefix === "blob")) downloadShortcut(shortcutInfo);
                // オーバーライドするファイル名を返す title or h1 / downloadItem.filename
                return createFilename(shortcutInfo, downloadItem, urlPrefix);
        })
        .then((filename) => {
                suggest({ filename });
        });

        return true; // 非同期的に呼び出すのでtrueを返す
});
```

上記コードの機能は、
1. ダウンロードがBlobによるもの(コンテキストメニュー)か判断する
2. content_scripts からショートカット情報(ページ情報)を取得 ※非同期
3. ①で判断した情報を元に、通常のダウンロードファイルの場合にはショートカットも追加でダウンロード
4. ショートカット情報(ページ情報)を使用してファイル名をオーバーライドする

となっています。
`onDeterminingFilename` の特徴として、 `suggest` を非同期的に呼び出す場合には `true` を返す必要があることに注意が必要です。
また、 `suggest` はイベント内で "1度だけ" 呼び出すという所にも注意してください。
参照:https://developer.chrome.com/docs/extensions/reference/api/downloads?hl=ja#event-onDeterminingFilename


### 補助的な関数
以下は先述したリネーム処理時に使用していた関数達です。
非同期で現在のアクティブな `tabId` を返す、 `getCurrentTabId` と `suggest` 用のfilename(フォルダ名/ファイル名) を返す `createFilename` 関数を定義しています。

```javascript:background.js
// 現在のアクティブなtabIdを返す
const getCurrentTabId = async () => {
        const [tab] = await chrome.tabs.query({
                active: true,
                lastFocusedWindow: true,
        });
        return tab.id;
};

// suggest用のfilenameを返す
const createFilename = (shortcutInfo, downloadItem, urlPrefix) => {
        let filename = shortcutInfo.filename + "/";
        if (urlPrefix === "blob") {
                filename += shortcutInfo.filename + ".url";
        } else {
                filename += downloadItem.filename;
        }
        return filename;
};
```

特別なことをしているわけではないですが、 `createFilename` では `urlPrefix` によってファイル名を変えています。
この記述を変更することで、例えばショートカットファイルを固定した名前でダウンロードするなどのカスタムをすることが可能です。

### 全体

改めて [前回](https://qiita.com/nxted_sapporo/items/6638ed3bf0861539b0bc) と今回の実装を合わせた `background.js` の全体的なコードを記載します。

```javascript:background.js
// contextMenuを作成
chrome.runtime.onInstalled.addListener(() => {
        chrome.contextMenus.create({
                id: "downloadShortcut",
                title: "ショートカットをダウンロード",
        });
});

// contextMenuクリック時のイベント
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
        if (info.menuItemId === "downloadShortcut") {
                const shortcutInfo = await chromeSendMessage(tab.id, {
                        type: "getShortcutInfo",
                        tabId: tab.id,
                });
                downloadShortcut(shortcutInfo);
        }
});

// ダウンロードイベント時にリネームする
chrome.downloads.onDeterminingFilename.addListener((downloadItem, suggest) => {
        const urlPrefix = downloadItem.url.substring(0, 4); // blobかどうか
        getCurrentTabId()
        .then((tabId) => {
                return chromeSendMessage(tabId, { type: "getShortcutInfo", tabId });
        })
        .then((shortcutInfo) => {
                // contextMenu以外で発生したダウンロードの場合、shortcutも同時にダウンロード
                if (!(urlPrefix === "blob")) downloadShortcut(shortcutInfo);
                return createFilename(shortcutInfo, downloadItem, urlPrefix);
        })
        .then((filename) => {
                suggest({ filename });
        });
        return true; // 非同期的に呼び出すのでtrueを返す
});

// 現在のアクティブなtabIdを返す
const getCurrentTabId = async () => {
        const [tab] = await chrome.tabs.query({
                active: true,
                lastFocusedWindow: true,
        });
        return tab.id;
};

// suggest用のfilenameを返す
const createFilename = (shortcutInfo, downloadItem, urlPrefix) => {
        let filename = shortcutInfo.filename + "/";
        if (urlPrefix === "blob") {
                filename += shortcutInfo.filename + ".url";
        } else {
                filename += downloadItem.filename;
        }
        return filename;
};

// shortcut情報を取得
const chromeSendMessage = async (tabId, message) => {
        return await chrome.tabs.sendMessage(tabId, message);
};

// shortcutのダウンロード
const downloadShortcut = (shortcutInfo) => {
        chrome.downloads.download({
                url: shortcutInfo.url,
                filename: shortcutInfo.filename + "/" + shortcutInfo.filename + ".url",
        });

        // 使用し終えたblobURLは解放する
        chromeSendMessage(shortcutInfo.tabId, {
                type: "revokeBlobUrl",
                url: shortcutInfo.url,
        });
};
```

## content.js

[前回](https://qiita.com/nxted_sapporo/items/6638ed3bf0861539b0bc) は `background.js` から送られた情報を `onMessage` で受け取り、適切な値を `sendResponse` で返却するというようなコードを実装しました...が、今回、 `content.js` への追記は１行です。

```diff:content.js
// messageを受け付ける
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
        if (request.type === "getShortcutInfo") {

        // ページ内の h1タグ or titleタグ からファイル名候補を取得する
        const pageTitle = (
                document.querySelector("h1") || document.querySelector("title")
        ).textContent;

        // shortcutのBlobURLと、fileanmeを返却する
        sendResponse({
+       tabId: request.tabId,
                url: URL.createObjectURL(createShortcutBlob(document.URL)),
                filename: charEscapeToFilename(pageTitle),
        });
}

        // 生成したshortcutBlobURLを解放する
        if (request.type === "revokeBlobUrl") {
                URL.revokeObjectURL(request.url);
        }
});

// shortcutのBlobを作成
const createShortcutBlob = (pageUrl) => {
        // [InternetShortcut]
        // URL=ページURL
        const text = "[InternetShortcut]" + "\n" + "URL=" + pageUrl;
        return new Blob([text], { type: "text/plan" });
};

// 入力文字をファイルやフォルダ名で扱える文字にエスケープ
const charEscapeToFilename = (char) => {
        const escapeChar = char
                .replace(/\\/g, "")
                .replace(/\//g, "")
                .replace(/:/g, "")
                .replace(/\*/g, "")
                .replace(/"/g, "")
                .replace(/</g, "")
                .replace(/>/g, "")
                .replace(/\|/g, "");

        return escapeChar.trim();
};
```

単純に `background.js` の `downloadShortcut` で `tabId` を使用したいという理由で `tabId` も持たせるように追記しました。

## 全体
`background.js` に比べると今回の追記は微量なものですが、念のため全体のコードを記載します。

```javascript:content.js
// messageを受け付ける
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
        if (request.type === "getShortcutInfo") {
                // ページ内の h1タグ or titleタグ からファイル名候補を取得する
                const pageTitle = (
                        document.querySelector("h1") || document.querySelector("title")
                ).textContent;

                // shortcutのBlobURLと、fileanmeを返却する
                sendResponse({
                        tabId: request.tabId,
                        url: URL.createObjectURL(createShortcutBlob(document.URL)),
                        filename: charEscapeToFilename(pageTitle),
                });
        } 

        // 生成したshortcutBlobURLを解放する
        if (request.type === "revokeBlobUrl") {
                URL.revokeObjectURL(request.url);
        }
});

// shortcutのBlobを作成
const createShortcutBlob = (pageUrl) => {
        // [InternetShortcut]
        // URL=ページURL
        const text = "[InternetShortcut]" + "\n" + "URL=" + pageUrl;
        return new Blob([text], { type: "text/plan" });
};

// 入力文字をファイルやフォルダ名で扱える文字にエスケープ
const charEscapeToFilename = (char) => {
        const escapeChar = char
                .replace(/\\/g, "")
                .replace(/\//g, "")
                .replace(/:/g, "")
                .replace(/\*/g, "")
                .replace(/"/g, "")
                .replace(/</g, "")
                .replace(/>/g, "")
                .replace(/\|/g, "");

        return escapeChar.trim();
};
```

## 動作確認
これで、[前回](https://qiita.com/nxted_sapporo/items/6638ed3bf0861539b0bc) のコンテキストメニュー実行時と併せてダウンロード時にリネームする実装が完成しました。
動作確認をしてみましょう。今回作成した拡張機能を改めてChromeに読み込ませてください。
読み込ませたら、適当なサイトでファイルをダウンロードしてみましょう。
すると以下のような構成でダウンロードできているはずです。

```
ダウンロード/
└ [ページ情報]/
        ├ ダウンロードファイル
        └ ダウンロード元へのショートカット
```

## さいごに
今回は [前回](https://qiita.com/nxted_sapporo/items/6638ed3bf0861539b0bc) に引き続き、ダウンロード時のリネーム処理というところを実装しました。
ただ現状の機能だとファイル名をページ情報から拾ってくる状態で不確定要素が多いです...(ページ情報で取得した情報が全て使用できないとエラーとなる)
次回はこの状態を解決するためにサイドパネルを用いてページ情報 or 手動の入力情報でファイル名を決定できるような機能を実装してみたいと思います。
ここまで読んでいただきありがとうございました。