はじめに

Backlogでタスク管理をしていると、このように感じたことはありませんか?

「親課題に紐づく大量の子課題を、ショートカットキーでサクサク次の課題へ移動できたらいいのに……」

Backlogには便利な標準ショートカット(jで次、kで前など)がありますが、「親子課題のツリーを順番に巡回する」という機能はありません。
毎回一覧に戻ってクリックするのは非常に手間であり、親課題にぶら下がる子課題が多い場合は特に煩わしく感じますよね。

世の中には便利なGoogle Chrome拡張機能も存在しますが、「機密情報が含まれるBacklogに、サードパーティ製(または外部)の拡張機能を導入するのはセキュリティの観点で慎重になる」という方も多いはずです。

そこで今回は、外部ツールに依存しない自分専用の「Backlogナビゲーター」を自作する方法をご紹介します!
こちらを導入すれば、親子課題の前・次への移動をショートカットで素早く行えるようになります。

備考
本記事のソースコードは、生成AI(Gemini)を活用しつつ、実際の業務で使いやすいように独自のカスタマイズと動作確認を行って作成しました。初心者の方でも実装できるよう「メモ帳」を使ったやさしい手順で解説しています。

動作確認環境

  • OS: Windows 11
  • ブラウザ: Google Chromeバージョン 146 (執筆時点の最新版)
  • Backlog: クラウド版

⚠️注意事項
※本記事のソースコードは動作を保証するものではありません。内容をご確認のうえご利用ください。
※AIを利用して生成したコードを利用する際は、必ずご自身または社内のエンジニアと内容をレビューし、安全性を確認した上で自己責任においてご利用ください。
※Backlogの仕様変更(UIや言語設定など)により、動作しなくなる可能性があります。
※ご利用の際は、所属組織のセキュリティポリシーをご確認ください。

この拡張機能でできるようになること

この拡張機能を導入すると、Backlogの画面上で以下の操作が可能になります。

  • . (ドット / >)キーを押す ➔ 次の課題 へ移動
  • , (カンマ / <)キーを押す ➔ 前の課題 へ移動

「子課題」のページから.を押せば次の課題へ移動します。
Backlog標準のショートカット機能もそのまま使えるよう、既存のショートカットキーと競合しないキーを割り当てています。

作成手順(3ステップ)

ステップ1:専用のフォルダを作る

まずは、デスクトップなどの任意の場所に新しいフォルダを作成します。
フォルダ名は半角英数字で BacklogNavigator などにしておきましょう。

ステップ2:メモ帳で4つのファイルを作成する

テキストエディタ(Windowsの場合は「メモ帳」など)を開き、後述するコードをコピー&ペーストして、先ほど作成したフォルダ内に保存していきます。

後述するファイルを作成すると以下のようになります。

※保存するときの注意点
* メモ帳の「文字コード」を UTF-8 にして保存してください。
* ファイル名が manifest.json.txt のように拡張子が重複しないよう、保存時の「ファイルの種類」を すべてのファイル に変更してから保存してください。

1つ目のファイル: manifest.json (拡張機能の設計図)

{
  "manifest_version": 3,
  "name": "Backlog Navigator",
  "version": "1.0",
  "description": "ON/OFFスイッチ付き・Backlog親子課題ナビゲーター",
  "action": {
    "default_popup": "popup.html"
  },
  "content_scripts": [
    {
      "matches": [
        "https://*.backlog.jp/*",
        "https://*.backlog.com/*"
      ],
      "js": ["content.js"]
    }
  ],
  "permissions": ["storage"]
}

2つ目のファイル: popup.html (スイッチの見た目)

拡張機能のアイコンを押したときに出る、可愛くてわかりやすいスイッチ画面です

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Backlog Navigator</title>

  <style>
    :root {
      --bg: #fcfaf8;
      --text: #594d4c;
      --sub-text: #8e807f;
      --accent: #82cda0;
      --border: #dcd6d1;
      --shadow: rgba(0, 0, 0, 0.05);
    }

    body {
      width: 220px;
      margin: 0;
      padding: 20px;
      font-family: "Helvetica Neue", Arial, sans-serif;
      background: var(--bg);
      color: var(--text);
    }

    .header {
      text-align: center;
      font-size: 15px;
      font-weight: bold;
      margin-bottom: 16px;
      padding-bottom: 8px;
      border-bottom: 2px dashed #eedbc5;
      color: #796664;
    }

    .card {
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding: 10px 14px;
      background: #fff;
      border-radius: 8px;
      box-shadow: 0 2px 5px var(--shadow);
    }

    .status {
      font-size: 14px;
      font-weight: bold;
    }

    /* Toggle */
    .switch {
      position: relative;
      width: 44px;
      height: 24px;
    }

    .switch input {
      display: none;
    }

    .slider {
      position: absolute;
      inset: 0;
      background: var(--border);
      border-radius: 24px;
      cursor: pointer;
      transition: 0.3s;
    }

    .slider::before {
      content: "";
      position: absolute;
      width: 18px;
      height: 18px;
      left: 3px;
      bottom: 3px;
      background: #fff;
      border-radius: 50%;
      transition: 0.3s;
      box-shadow: 0 1px 3px rgba(0,0,0,0.2);
    }

    input:checked + .slider {
      background: var(--accent);
    }

    input:checked + .slider::before {
      transform: translateX(20px);
    }

    .footer {
      margin-top: 16px;
      font-size: 13px;
      text-align: center;
      color: var(--sub-text);
      line-height: 1.8;
    }

    .key {
      display: inline-flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      margin: 0 4px;
      padding: 4px 8px;
      min-width: 16px;

      font-family: monospace;
      font-weight: bold;
      color: var(--text);

      background: #fafafa;
      border: 1px solid var(--border);
      border-bottom: 3px solid #c4bcae;
      border-radius: 6px;
      box-shadow: 0 2px 3px var(--shadow);
    }

    .key small {
      font-size: 10px;
      color: #a69998;
    }

    .key strong {
      font-size: 13px;
    }
  </style>
</head>

<body>
  <header class="header">Backlog ナビゲーター</header>

  <main class="card">
    <span id="status-text" class="status">有効 (ON)</span>

    <label class="switch">
      <input type="checkbox" id="toggle-switch">
      <span class="slider"></span>
    </label>
  </main>

  <footer class="footer">
    <div>
      <span class="key"><small><</small><strong>,</strong></span>
      で <b>前の課題</b>
    </div>
    <div>
      <span class="key"><small>></small><strong>.</strong></span>
      で <b>次の課題</b>
    </div>
  </footer>

  <script src="popup.js"></script>
</body>
</html>

3つ目のファイル: popup.js (スイッチを動かす裏側)

document.addEventListener('DOMContentLoaded', () => {
  const toggleSwitch = document.getElementById('toggle-switch');
  const statusText = document.getElementById('status-text');

  chrome.storage.local.get(['isEnabled'], (result) => {
    const isEnabled = result.isEnabled !== false;
    toggleSwitch.checked = isEnabled;
    statusText.innerText = isEnabled ? '有効 (ON)' : '無効 (OFF)';
  });

  toggleSwitch.addEventListener('change', (e) => {
    const isEnabled = e.target.checked;
    statusText.innerText = isEnabled ? '有効 (ON)' : '無効 (OFF)';

    if (isEnabled) {
      chrome.storage.local.set({ isEnabled: true });
    } else {
      // OFFにした瞬間、古い課題リストのキャッシュをリセットする
      chrome.storage.local.set({ isEnabled: false, backlogChildIssues: [] });
    }
  });
});

4つ目のファイル: content.js (Backlogを動かす頭脳)

これがメインのプログラムです。

(function () {
    const STORAGE_KEY = 'backlogChildIssues';
    const EXPAND_BTN_REGEX = /他\d+件\s*すべて表示/;

    // 連打防止用のロックフラグ
    let isProcessing = false;

    // --- Utility ---
    const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

    const isTyping = () => {
        const el = document.activeElement;
        return (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable);
    };

    const normalizeUrl = (url) => {
        try {
            const urlObj = new URL(url);
            const issueKeyMatch = urlObj.pathname.split('/').pop().match(/^([a-zA-Z0-9_]+-\d+)$/);
            if (issueKeyMatch) {
               return `${urlObj.origin}/view/${issueKeyMatch[1]}`;
            }
            return location.origin + location.pathname;
        } catch {
            return location.origin + location.pathname;
        }
    };

    function getStorage() {
        return new Promise(resolve => {
            chrome.storage.local.get(['isEnabled', STORAGE_KEY], resolve);
        });
    }

    // --- DOM操作 ---
    function findExpandButton() {
        return Array.from(document.querySelectorAll('button, a, span'))
            .find(el => EXPAND_BTN_REGEX.test(el.textContent) && el.offsetParent !== null);
    }

    async function expandAllIfNeeded() {
        const btn = findExpandButton();
        if (!btn) return;

        btn.click();

        // ボタンが消えるまで待機
        for (let i = 0; i < 20; i++) {
            await sleep(300);
            if (!findExpandButton()) break;
        }
        // 課題が画面に表示されきるまでしっかり待つ
        await sleep(1000);
    }

    // --- データ取得 ---
    function collectIssueLinks() {
        const links = Array.from(document.querySelectorAll('a[href*="/view/"]'));

        const validLinks = links
            .filter(link => !link.closest('.global-header, .project-nav, .global-nav, .user-nav, .title-group__breadcrumb, #breadcrumb'))
            .filter(link => {
                try {
                    const urlObj = new URL(link.href);
                    return /^[a-zA-Z0-9_]+-\d+$/.test(urlObj.pathname.split('/').pop());
                } catch {
                    return false;
                }
            })
            .map(link => normalizeUrl(link.href));

        // 重複排除
        let uniqueLinks = [...new Set(validLinks)];

        // 念のため、自分がリストになければ先頭に追加
        const currentUrl = normalizeUrl(location.href);
        if (!uniqueLinks.includes(currentUrl)) {
            uniqueLinks.unshift(currentUrl);
        }

        return uniqueLinks;
    }

    // --- ナビゲーション処理 ---
    async function handleNavigation(key) {
        const { isEnabled, [STORAGE_KEY]: stored } = await getStorage();
        if (isEnabled === false) return;

        const currentUrl = normalizeUrl(location.href);
        let issues = stored || [];

        // リストがない、または今のURLがリストにない場合は取得
        if (!issues.length || !issues.includes(currentUrl)) {
            await expandAllIfNeeded();
            issues = collectIssueLinks();

            if (issues.length <= 1) {
                alert("⚠️ ここからだとリストが取得できません!\n『子課題』のページを一つ開いてから操作してください。");
                return;
            }

            await new Promise(r => chrome.storage.local.set({ [STORAGE_KEY]: issues }, r));
            console.log(`✅ ${issues.length} 件の課題リストを保存しました!`, issues);
        }

        // 移動の計算
        const index = issues.indexOf(currentUrl);
        if (index === -1) {
            location.href = issues[0];
            return;
        }

        const nextIndex = key === '.' 
            ? (index + 1) % issues.length 
            : (index - 1 + issues.length) % issues.length;

        location.href = issues[nextIndex];
    }

    // --- イベントリスナー ---
    document.addEventListener('keydown', (e) => {
        if (isTyping()) return;

        if (e.key === '.' || e.key === ',') {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();

            // 連打防止ロック機能
            if (isProcessing) {
                console.log("⏳ 現在リストを取得中です。連打をキャンセルしました。");
                return; 
            }

            isProcessing = true;

            handleNavigation(e.key).finally(() => {
                isProcessing = false;
            });
        }
    }, true);

})();

ステップ3:Google Chrome に読み込ませる

作ったフォルダをGoogle Chromeにインストールします。
1. Google Chromeの右上の 三点リーダー( をクリックし、「拡張機能」「拡張機能を管理」 を選びます。
(※URL入力欄に直接 chrome://extensions/ と入力して開いてもOKです!)
2. 画面右上の 「デベロッパーモード」 をONにします。
3. 左上に表示される 「パッケージ化されていない拡張機能を読み込む」 をクリックします。
4. ステップ1で作った BacklogNavigator フォルダを選択します。

これでインストール完了です!
全ての拡張機能に追加したものが表示されていればOKです!

使い方とオン・オフの切り替え

使い方は以下の2つのルールを覚えるだけでとっても簡単です。

1. まずは「子課題」のページを開いてスタート!

Backlogで「子課題」のページをどれかひとつ開いて、キーボードの .(ドット) を押すだけ!
自動で親子課題のリストが読み込まれ、サクサク次の課題へ進んでいきます。

2. 別の親課題のツリーに移るときは「ON/OFF」でリセット!

今の親子課題の確認が終わり、「まったく別の親課題」のツリーを確認したくなった時は、一度キャッシュのクリアが必要です。
Google Chrome右上のパズルピースのアイコンからこの「Backlog Navigator」をクリックし、スイッチを一度「OFF」にしてから、再度「ON」にしてください。
これで裏側の記憶がリセットされ、新しいツリーを巡回できるようになります。


拡張機能をアップデート・お引っ越しするには?

自分で作った拡張機能だからこそ、あとからコードを少し書き換えたり、パソコン内のフォルダを整理したりすることもありますよね。そんな時の対処法も超簡単です!

💻 コードを書き換えて更新したいとき

content.js などのファイルをメモ帳で書き換えて上書き保存したら、先ほどの「拡張機能を管理」画面(chrome://extensions/)を開きます。
この拡張機能のパネル右下にある 「↻(更新)」ボタンをポチッと押すだけ で、すぐに新しいプログラムが反映されます!

📁 フォルダの場所を移動させたとき

もし「デスクトップに作ったフォルダを、ドキュメントフォルダに移動させたい」となった場合、元の場所からファイルが消えるためGoogle Chrome側でエラーになってしまいます。
その時は、以下の手順で指定し直してください。
1. 拡張機能の管理画面で、一度この拡張機能の 「削除」 ボタンを押す。
2. 再度、左上の 「パッケージ化されていない拡張機能を読み込む」 から、新しい場所にあるフォルダを指定し直す。

さいごに

AIのサポートがあれば、プログラミング初心者でもこんなに実用的なツールが作れてしまいます。
本記事では最小限の機能を紹介していますが、さらにカスタマイズすることでより便利にできるかもしれないのでぜひ挑戦してみてください!