From df60de1de5b7fa8b3d422687f3fdfb277ea3dee6 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 22 Apr 2026 11:32:19 +0900 Subject: [PATCH 1/8] Add: New share to hub. --- docs/composed-swimming-island.md | 343 ++++++++++++++++++ packages/extension/.env.development | 1 + .../public/_locales/de/messages.json | 3 + .../public/_locales/en/messages.json | 4 + .../public/_locales/es/messages.json | 3 + .../public/_locales/fr/messages.json | 3 + .../public/_locales/hi/messages.json | 3 + .../public/_locales/id/messages.json | 3 + .../public/_locales/it/messages.json | 3 + .../public/_locales/ja/messages.json | 3 + .../public/_locales/ko/messages.json | 3 + .../public/_locales/ms/messages.json | 3 + .../public/_locales/pt_BR/messages.json | 3 + .../public/_locales/pt_PT/messages.json | 3 + .../public/_locales/ru/messages.json | 3 + .../public/_locales/zh_CN/messages.json | 3 + .../src/components/option/ShareButton.tsx | 41 +++ .../option/editor/CommandTreeRenderer.tsx | 8 +- packages/extension/src/const.ts | 31 ++ packages/extension/src/services/hubShare.ts | 137 +++++++ 20 files changed, 603 insertions(+), 1 deletion(-) create mode 100644 docs/composed-swimming-island.md create mode 100644 packages/extension/.env.development create mode 100644 packages/extension/src/components/option/ShareButton.tsx create mode 100644 packages/extension/src/services/hubShare.ts diff --git a/docs/composed-swimming-island.md b/docs/composed-swimming-island.md new file mode 100644 index 00000000..2037c9a7 --- /dev/null +++ b/docs/composed-swimming-island.md @@ -0,0 +1,343 @@ +# Plan: New Hub (selection-command-hub) へのコマンド投稿機能 + +## Context + +Selection Command Hub の新バージョン(`https://selection-command-hub.pages.dev`)が公開された。 +旧 Hub(GitHub Pages の静的サイト)では Google Apps Script エンドポイントへ POST していたが、 +新 Hub は Supabase + Server Action をバックエンドに持ち、 +Chrome 拡張から `window.postMessage` でコマンドデータを送信する新しいプロトコルを採用している。 + +**やること**: 拡張機能のオプションページのコマンド一覧に「Hub へ共有」ボタンを追加し、 +新 Hub の postMessage プロトコルでコマンドを投稿できるようにする。 + +旧 Hub との連携(ダウンロード機能 / MyCommands UI の注入)はそのまま維持する。 + +--- + +## 新しい共有フロー + +``` +[オプションページ] ShareButton クリック + → window.open(NEW_HUB_URL/lang, "_blank") ← オプションページのJSコンテキストから直接実行 + → setInterval でリトライしながら hubWindow.postMessage({ type: "share-command", command }, hubOrigin) + → Hub ページが ConfirmForm ダイアログを表示 + → ユーザーが「投稿」クリック → Hub の Server Action が Supabase に保存 +``` + +> **Note**: Chrome 拡張のオプションページ(`chrome-extension://…`)は通常の Web ページとして動作するため、 +> `window.open` でタブを開き、返された window 参照に対して `postMessage` を送信できる。 +> バックグラウンドスクリプトや追加のコンテントスクリプトは不要。 + +--- + +## 変更ファイル一覧 + +| 操作 | ファイル | 内容 | +| ---- | ------------------------------------------------------------------------- | ------------------------ | +| 修正 | `packages/extension/src/const.ts` | `NEW_HUB_URL` 定数を追加 | +| 新規 | `packages/extension/src/services/hubShare.ts` | 共有ロジック一式 | +| 新規 | `packages/extension/src/components/option/ShareButton.tsx` | 共有ボタン UI | +| 修正 | `packages/extension/src/components/option/editor/CommandTreeRenderer.tsx` | ShareButton を追加 | +| 修正 | `packages/extension/public/_locales/*/messages.json` | i18n キー追加(15言語) | + +--- + +## 詳細設計 + +### 0. `.env` への追加 + +`VITE_` プレフィックスを使い、Vite が自動的に `import.meta.env` に注入する形式を採用(既存の `VITE_MEASUREMENT_ID` 等と同じパターン)。 + +**`packages/extension/.env`**: + +``` +VITE_NEW_HUB_URL=https://selection-command-hub.pages.dev +``` + +開発環境向けに `.env.development` を作成: + +``` +VITE_NEW_HUB_URL=http://localhost:3001 +``` + +--- + +### 1. `const.ts` への追加 + +```typescript +// packages/extension/src/const.ts(HUB_URL の直後に追加) + +export const NEW_HUB_URL = + import.meta.env.VITE_NEW_HUB_URL ?? "https://selection-command-hub.pages.dev" + +// Hub がサポートする言語コード +export const NEW_HUB_SUPPORTED_LOCALES = [ + "ja", + "en", + "ko", + "zh-CN", + "de", + "es", + "fr", + "hi", + "id", + "it", + "ms", + "pt-BR", + "pt-PT", + "ru", +] as const + +export type NewHubLocale = (typeof NEW_HUB_SUPPORTED_LOCALES)[number] + +// 新 Hub に投稿できる openMode(それ以外は「共有」ボタン非表示) +export const NEW_HUB_SHAREABLE_OPEN_MODES: ReadonlySet = new Set([ + OPEN_MODE.POPUP, + OPEN_MODE.TAB, + OPEN_MODE.WINDOW, + OPEN_MODE.BACKGROUND_TAB, + OPEN_MODE.SIDE_PANEL, + OPEN_MODE.PAGE_ACTION, +]) +``` + +--- + +### 2. `services/hubShare.ts`(新規) + +```typescript +// packages/extension/src/services/hubShare.ts + +import { + NEW_HUB_URL, + NEW_HUB_SUPPORTED_LOCALES, + OPEN_MODE, + type NewHubLocale, +} from "@/const" +import type { + SelectionCommand, + SearchCommand, + PageActionCommand, +} from "@/types" + +// ---- 型定義 --------------------------------------------------------------- + +export interface SubmitCommandInput { + title: string + description?: string + iconUrl?: string + openMode: string + commandData: Record + locale?: string + tags?: string[] +} + +// ---- ロケール解決 ---------------------------------------------------------- + +export function getHubLocale(): NewHubLocale { + const lang = ( + chrome.i18n.getUILanguage() ?? + navigator.language ?? + "en" + ).toLowerCase() + + // 完全一致 + for (const locale of NEW_HUB_SUPPORTED_LOCALES) { + if (lang === locale.toLowerCase()) return locale + } + // 前方一致(例: "zh-tw" → "zh-CN"、"pt-br" → "pt-BR") + for (const locale of NEW_HUB_SUPPORTED_LOCALES) { + if (lang.startsWith(locale.split("-")[0].toLowerCase())) return locale + } + return "en" +} + +// ---- コマンドデータ変換 ---------------------------------------------------- + +export function toSubmitCommandInput( + cmd: SelectionCommand, +): SubmitCommandInput | null { + const { title, iconUrl, openMode } = cmd + + if (openMode === OPEN_MODE.PAGE_ACTION) { + const pa = cmd as PageActionCommand + return { + title, + iconUrl: iconUrl || undefined, + openMode, + commandData: { + steps: pa.pageActionOption.steps, + startUrl: pa.pageActionOption.startUrl, + openMode: pa.pageActionOption.openMode, + }, + locale: getHubLocale(), + } + } + + // Search 系(popup / tab / window / backgroundTab / sidePanel) + const sc = cmd as SearchCommand + if (!sc.searchUrl) return null + + const commandData: Record = { searchUrl: sc.searchUrl } + if (sc.openModeSecondary) commandData.openModeSecondary = sc.openModeSecondary + if (sc.spaceEncoding) commandData.spaceEncoding = sc.spaceEncoding + + return { + title, + iconUrl: iconUrl || undefined, + openMode, + commandData, + locale: getHubLocale(), + } +} + +// ---- 共有メイン処理 -------------------------------------------------------- + +const RETRY_INTERVAL_MS = 500 +const MAX_RETRIES = 20 // 10秒 + +export function shareCommandToHub(command: SelectionCommand): boolean { + const input = toSubmitCommandInput(command) + if (!input) return false + + const locale = getHubLocale() + const hubUrl = `${NEW_HUB_URL}/${locale}` + + const hubWindow = window.open(hubUrl, "_blank") + if (!hubWindow) { + console.error("[HubShare] Failed to open Hub page.") + return false + } + + let retries = 0 + const timer = setInterval(() => { + retries++ + if (retries > MAX_RETRIES) { + clearInterval(timer) + console.error("[HubShare] Hub page did not respond in time.") + return + } + try { + hubWindow.postMessage( + { type: "share-command", command: input }, + NEW_HUB_URL, + ) + clearInterval(timer) + } catch { + // hub ウィンドウがまだロード中 → 次のリトライへ + } + }, RETRY_INTERVAL_MS) + + return true +} +``` + +> **注意**: `postMessage` の `targetOrigin` は `NEW_HUB_URL`(`https://selection-command-hub.pages.dev`)を指定し、 +> `"*"` は使用しない(API 仕様のセキュリティ要件)。 + +--- + +### 3. `ShareButton.tsx`(新規) + +`EditButton` / `RemoveButton` と同じパターンで実装する。 + +```typescript +// packages/extension/src/components/option/ShareButton.tsx + +import { useState } from "react" +import { Share2 } from "lucide-react" +import { cn } from "@/lib/utils" +import { t } from "@/services/i18n" +import { shareCommandToHub } from "@/services/hubShare" +import type { SelectionCommand } from "@/types" + +type Props = { + command: SelectionCommand +} + +export const ShareButton = ({ command }: Props) => { + const [status, setStatus] = useState<"idle" | "sent" | "error">("idle") + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation() + const ok = shareCommandToHub(command) + setStatus(ok ? "sent" : "error") + setTimeout(() => setStatus("idle"), 2000) + } + + return ( + + ) +} +``` + +--- + +### 4. `CommandTreeRenderer.tsx` の修正 + +差分: + +```diff ++ import { ShareButton } from "@/components/option/ShareButton" ++ import { NEW_HUB_SHAREABLE_OPEN_MODES } from "@/const" + +
++ {isCommand(field.content) && ++ NEW_HUB_SHAREABLE_OPEN_MODES.has(field.content.openMode) && ( ++ ++ )} + {isPageActionCommand(field.content) && ( +``` + +変更点はこれだけ。Props の追加や CommandList.tsx への変更は不要(ShareButton が自己完結)。 + +--- + +### 5. i18n キーの追加 + +**15言語** の `messages.json` に以下を追加: + +```json +"Option_shareButton_tooltip": { + "message": "Share to Hub" +} +``` + +対象ファイル: `packages/extension/public/_locales/{en,ja,ko,zh_CN,de,es,fr,hi,id,it,ms,pt_BR,pt_PT,ru,…}/messages.json` + +日本語(`ja`): `"Hubに共有"` + +--- + +## ダウンロード記録 API について + +`POST /api/commands/{commandId}/download` は新 Hub の**フロントエンド側**が内部的に呼び出すもので、 +拡張機能から呼び出す必要はない(今回のスコープ外)。 + +--- + +## 検証方法 + +1. `pnpm dev` で拡張機能を開発ビルド(`isDebug=true` → `NEW_HUB_URL=http://localhost:3001`) +2. Chrome で拡張機能のオプションページを開く +3. 検索コマンドの行に Share2 アイコンが表示されることを確認 +4. ボタンをクリック → 新 Hub のタブが開くことを確認 +5. Hub の ConfirmForm ダイアログが表示され、コマンドデータが入力済みであることを確認 +6. PageAction コマンドでも同様に確認 +7. API / aiPrompt / copy などの unsupported コマンドには Share ボタンが表示されないことを確認 diff --git a/packages/extension/.env.development b/packages/extension/.env.development new file mode 100644 index 00000000..a1f065dd --- /dev/null +++ b/packages/extension/.env.development @@ -0,0 +1 @@ +VITE_NEW_HUB_URL=http://localhost:3000 diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json index 44c2d58b..3e003a33 100644 --- a/packages/extension/public/_locales/de/messages.json +++ b/packages/extension/public/_locales/de/messages.json @@ -725,6 +725,9 @@ "Option_copy_tooltip": { "message": "Duplizieren" }, + "Option_shareButton_tooltip": { + "message": "Mit Hub teilen" + }, "Option_remove_title": { "message": "Dies löschen?" }, diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json index f7c8df39..f9f6790b 100644 --- a/packages/extension/public/_locales/en/messages.json +++ b/packages/extension/public/_locales/en/messages.json @@ -726,6 +726,10 @@ "Option_copy_tooltip": { "message": "Duplicate" }, + "Option_shareButton_tooltip": { + "message": "Share to Hub", + "description": "Tooltip for the share button in the command list. Opens the new Selection Command Hub in a new tab with the command pre-filled." + }, "Option_remove_title": { "message": "Delete this?" }, diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json index 83a83a61..f4f0a5d2 100644 --- a/packages/extension/public/_locales/es/messages.json +++ b/packages/extension/public/_locales/es/messages.json @@ -725,6 +725,9 @@ "Option_copy_tooltip": { "message": "Duplicar" }, + "Option_shareButton_tooltip": { + "message": "Compartir en Hub" + }, "Option_remove_title": { "message": "¿Eliminar esto?" }, diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json index db6fd96a..dc3bca6a 100644 --- a/packages/extension/public/_locales/fr/messages.json +++ b/packages/extension/public/_locales/fr/messages.json @@ -725,6 +725,9 @@ "Option_copy_tooltip": { "message": "Dupliquer" }, + "Option_shareButton_tooltip": { + "message": "Partager sur Hub" + }, "Option_remove_title": { "message": "Supprimer ceci ?" }, diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json index 79792ace..436353a6 100644 --- a/packages/extension/public/_locales/hi/messages.json +++ b/packages/extension/public/_locales/hi/messages.json @@ -725,6 +725,9 @@ "Option_copy_tooltip": { "message": "डुप्लिकेट" }, + "Option_shareButton_tooltip": { + "message": "Hub पर साझा करें" + }, "Option_remove_title": { "message": "क्या आप वाकई इसे हटाना चाहते हैं?" }, diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json index 08ffabf8..e59c1aa2 100644 --- a/packages/extension/public/_locales/id/messages.json +++ b/packages/extension/public/_locales/id/messages.json @@ -725,6 +725,9 @@ "Option_copy_tooltip": { "message": "Duplikat" }, + "Option_shareButton_tooltip": { + "message": "Bagikan ke Hub" + }, "Option_remove_title": { "message": "Hapus ini?" }, diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json index cd73f33f..963aa72d 100644 --- a/packages/extension/public/_locales/it/messages.json +++ b/packages/extension/public/_locales/it/messages.json @@ -725,6 +725,9 @@ "Option_copy_tooltip": { "message": "Duplica" }, + "Option_shareButton_tooltip": { + "message": "Condividi su Hub" + }, "Option_remove_title": { "message": "Sei sicuro di voler eliminare questo?" }, diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json index 3685234a..0461432e 100644 --- a/packages/extension/public/_locales/ja/messages.json +++ b/packages/extension/public/_locales/ja/messages.json @@ -725,6 +725,9 @@ "Option_copy_tooltip": { "message": "コピー" }, + "Option_shareButton_tooltip": { + "message": "Hubに共有" + }, "Option_remove_title": { "message": "削除しますか?" }, diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json index 32269d0e..75f9c97e 100644 --- a/packages/extension/public/_locales/ko/messages.json +++ b/packages/extension/public/_locales/ko/messages.json @@ -725,6 +725,9 @@ "Option_copy_tooltip": { "message": "복제" }, + "Option_shareButton_tooltip": { + "message": "Hub에 공유" + }, "Option_remove_title": { "message": "이것을 삭제하시겠습니까?" }, diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json index 925012c0..3468cc6d 100644 --- a/packages/extension/public/_locales/ms/messages.json +++ b/packages/extension/public/_locales/ms/messages.json @@ -725,6 +725,9 @@ "Option_copy_tooltip": { "message": "Duplikat" }, + "Option_shareButton_tooltip": { + "message": "Kongsi ke Hub" + }, "Option_remove_title": { "message": "Padam ini?" }, diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json index 17317026..f97afe6d 100644 --- a/packages/extension/public/_locales/pt_BR/messages.json +++ b/packages/extension/public/_locales/pt_BR/messages.json @@ -725,6 +725,9 @@ "Option_copy_tooltip": { "message": "Duplicar" }, + "Option_shareButton_tooltip": { + "message": "Compartilhar no Hub" + }, "Option_remove_title": { "message": "Excluir isto?" }, diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json index 4ebd3c3c..5f4abf39 100644 --- a/packages/extension/public/_locales/pt_PT/messages.json +++ b/packages/extension/public/_locales/pt_PT/messages.json @@ -725,6 +725,9 @@ "Option_copy_tooltip": { "message": "Duplicar" }, + "Option_shareButton_tooltip": { + "message": "Partilhar no Hub" + }, "Option_remove_title": { "message": "Eliminar isto?" }, diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json index 3d494058..7215f2b8 100644 --- a/packages/extension/public/_locales/ru/messages.json +++ b/packages/extension/public/_locales/ru/messages.json @@ -725,6 +725,9 @@ "Option_copy_tooltip": { "message": "Дублировать" }, + "Option_shareButton_tooltip": { + "message": "Поделиться в Hub" + }, "Option_remove_title": { "message": "Вы уверены, что хотите удалить это?" }, diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json index 02e9e1e2..86bdb1bb 100644 --- a/packages/extension/public/_locales/zh_CN/messages.json +++ b/packages/extension/public/_locales/zh_CN/messages.json @@ -725,6 +725,9 @@ "Option_copy_tooltip": { "message": "复制" }, + "Option_shareButton_tooltip": { + "message": "分享到 Hub" + }, "Option_remove_title": { "message": "您确定要删除吗?" }, diff --git a/packages/extension/src/components/option/ShareButton.tsx b/packages/extension/src/components/option/ShareButton.tsx new file mode 100644 index 00000000..ef975c28 --- /dev/null +++ b/packages/extension/src/components/option/ShareButton.tsx @@ -0,0 +1,41 @@ +import { useState } from "react" +import { Share2 } from "lucide-react" +import { cn } from "@/lib/utils" +import { t } from "@/services/i18n" +import { shareCommandToHub } from "@/services/hubShare" +import type { SelectionCommand } from "@/types" + +type Props = { + command: SelectionCommand +} + +export const ShareButton = ({ command }: Props) => { + const [status, setStatus] = useState<"idle" | "sent" | "error">("idle") + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation() + const ok = shareCommandToHub(command) + setStatus(ok ? "sent" : "error") + setTimeout(() => setStatus("idle"), 2000) + } + + return ( + + ) +} diff --git a/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx b/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx index a15931ac..bd9210d0 100644 --- a/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx +++ b/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx @@ -3,15 +3,17 @@ import { SortableItem } from "@/components/option/SortableItem" import { EditButton } from "@/components/option/EditButton" import { CopyButton } from "@/components/option/CopyButton" import { RemoveButton } from "@/components/option/RemoveButton" +import { ShareButton } from "@/components/option/ShareButton" import { MenuImage } from "@/components/menu/MenuImage" import type { FlattenNode } from "@/services/option/commandTree" -import type { CommandFolder } from "@/types" +import type { CommandFolder, SelectionCommand } from "@/types" import { isCommand, isFolder, isPageActionCommand, } from "@/services/option/commandUtils" import { calcLevel } from "@/services/option/commandTree" +import { NEW_HUB_SHAREABLE_OPEN_MODES } from "@/const" interface Props { nodes: FlattenNode[] @@ -68,6 +70,10 @@ export const CommandTreeRenderer: React.FC = ({
+ {isCommand(field.content) && + NEW_HUB_SHAREABLE_OPEN_MODES.has(field.content.openMode) && ( + + )} {isPageActionCommand(field.content) && ( = new Set([ + OPEN_MODE.POPUP, + OPEN_MODE.TAB, + OPEN_MODE.WINDOW, + OPEN_MODE.BACKGROUND_TAB, + OPEN_MODE.SIDE_PANEL, + OPEN_MODE.PAGE_ACTION, +]) + export const PAGE_ACTION_MAX = 12 // 10 actions + 1 start + 1 end export const PAGE_ACTION_TIMEOUT = 5000 diff --git a/packages/extension/src/services/hubShare.ts b/packages/extension/src/services/hubShare.ts new file mode 100644 index 00000000..0e9e2f4e --- /dev/null +++ b/packages/extension/src/services/hubShare.ts @@ -0,0 +1,137 @@ +import { + NEW_HUB_URL, + NEW_HUB_SUPPORTED_LOCALES, + OPEN_MODE, + type NewHubLocale, +} from "@/const" +import type { + SelectionCommand, + SearchCommand, + PageActionCommand, +} from "@/types" + +// ---- 型定義 --------------------------------------------------------------- + +export interface SubmitCommandInput { + title: string + description?: string + iconUrl?: string + openMode: string + commandData: Record + locale?: string + tags?: string[] +} + +// ---- ロケール解決 ---------------------------------------------------------- + +export function getHubLocale(): NewHubLocale { + const lang = ( + chrome.i18n.getUILanguage() ?? + navigator.language ?? + "en" + ).toLowerCase() + + // 完全一致 + for (const locale of NEW_HUB_SUPPORTED_LOCALES) { + if (lang === locale.toLowerCase()) return locale + } + // 前方一致(例: "zh-tw" → "zh-CN"、"pt-br" → "pt-BR") + for (const locale of NEW_HUB_SUPPORTED_LOCALES) { + if (lang.startsWith(locale.split("-")[0].toLowerCase())) return locale + } + return "en" +} + +// ---- コマンドデータ変換 ---------------------------------------------------- + +export function toSubmitCommandInput( + cmd: SelectionCommand, +): SubmitCommandInput | null { + const { title, iconUrl, openMode } = cmd + + if (openMode === OPEN_MODE.PAGE_ACTION) { + const pa = cmd as PageActionCommand + return { + title, + iconUrl: iconUrl || undefined, + openMode, + commandData: { + steps: pa.pageActionOption.steps, + startUrl: pa.pageActionOption.startUrl, + openMode: pa.pageActionOption.openMode, + }, + locale: getHubLocale(), + } + } + + // Search 系(popup / tab / window / backgroundTab / sidePanel) + const sc = cmd as SearchCommand + if (!sc.searchUrl) return null + + const commandData: Record = { searchUrl: sc.searchUrl } + if (sc.openModeSecondary) commandData.openModeSecondary = sc.openModeSecondary + if (sc.spaceEncoding) commandData.spaceEncoding = sc.spaceEncoding + + return { + title, + iconUrl: iconUrl || undefined, + openMode, + commandData, + locale: getHubLocale(), + } +} + +// ---- 共有メイン処理 -------------------------------------------------------- + +const RETRY_INTERVAL_MS = 500 +const MAX_RETRIES = 20 // 10秒 + +export function shareCommandToHub(command: SelectionCommand): boolean { + const input = toSubmitCommandInput(command) + if (!input) return false + + const locale = getHubLocale() + const hubUrl = `${NEW_HUB_URL}/${locale}` + + const hubWindow = window.open(hubUrl, "_blank") + if (!hubWindow) { + console.error("[HubShare] Failed to open Hub page.") + return false + } + + let retries = 0 + + const cleanup = () => { + clearInterval(timer) + window.removeEventListener("message", onAck) + } + + // Hub からの ack を受け取ったらリトライを停止する + const onAck = (event: MessageEvent) => { + if (event.origin !== NEW_HUB_URL) return + if ((event.data as { type?: string })?.type === "share-command-ack") { + cleanup() + } + } + window.addEventListener("message", onAck) + + const timer = setInterval(() => { + retries++ + if (retries > MAX_RETRIES) { + cleanup() + console.error("[HubShare] Hub page did not respond in time.") + return + } + try { + hubWindow.postMessage( + { type: "share-command", command: input }, + NEW_HUB_URL, + ) + // ack が来るまで clearInterval しない + } catch { + // hub ウィンドウがまだロード中 → 次のリトライへ + } + }, RETRY_INTERVAL_MS) + + return true +} From d9106e9551a26afe3958a86b16fe563ea34b1308 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Fri, 1 May 2026 11:33:49 +0900 Subject: [PATCH 2/8] Update: Enables to share AiPrompt. --- packages/extension/src/components/option/ShareButton.tsx | 4 ++-- packages/extension/src/const.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/components/option/ShareButton.tsx b/packages/extension/src/components/option/ShareButton.tsx index ef975c28..3c63cbd7 100644 --- a/packages/extension/src/components/option/ShareButton.tsx +++ b/packages/extension/src/components/option/ShareButton.tsx @@ -1,5 +1,5 @@ import { useState } from "react" -import { Share2 } from "lucide-react" +import { Share } from "lucide-react" import { cn } from "@/lib/utils" import { t } from "@/services/i18n" import { shareCommandToHub } from "@/services/hubShare" @@ -28,7 +28,7 @@ export const ShareButton = ({ command }: Props) => { )} onClick={handleClick} > - = new Set([ OPEN_MODE.BACKGROUND_TAB, OPEN_MODE.SIDE_PANEL, OPEN_MODE.PAGE_ACTION, + OPEN_MODE.AI_PROMPT, ]) export const PAGE_ACTION_MAX = 12 // 10 actions + 1 start + 1 end From c13319543e9c2c57a67aac9e8e1439531a42c4e8 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 2 May 2026 13:48:10 +0900 Subject: [PATCH 3/8] Update: Add required properties for upload. --- .../src/components/option/ShareButton.tsx | 13 +++++++ .../option/editor/CommandTreeRenderer.tsx | 7 ++-- packages/extension/src/services/hubShare.ts | 34 ++++++++++++------- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/packages/extension/src/components/option/ShareButton.tsx b/packages/extension/src/components/option/ShareButton.tsx index 3c63cbd7..40f89489 100644 --- a/packages/extension/src/components/option/ShareButton.tsx +++ b/packages/extension/src/components/option/ShareButton.tsx @@ -3,8 +3,14 @@ import { Share } from "lucide-react" import { cn } from "@/lib/utils" import { t } from "@/services/i18n" import { shareCommandToHub } from "@/services/hubShare" +import { NEW_HUB_SHAREABLE_OPEN_MODES, COMMAND_SOURCE_TYPE } from "@/const" import type { SelectionCommand } from "@/types" +const VALID_SOURCE_TYPES = new Set([ + COMMAND_SOURCE_TYPE.SELF_CREATED, + COMMAND_SOURCE_TYPE.UNKNOWN, +]) + type Props = { command: SelectionCommand } @@ -19,6 +25,13 @@ export const ShareButton = ({ command }: Props) => { setTimeout(() => setStatus("idle"), 2000) } + if ( + !NEW_HUB_SHAREABLE_OPEN_MODES.has(command.openMode) || + !VALID_SOURCE_TYPES.has(command.sourceType ?? COMMAND_SOURCE_TYPE.UNKNOWN) + ) { + return null + } + return (
- {isCommand(field.content) && - NEW_HUB_SHAREABLE_OPEN_MODES.has(field.content.openMode) && ( - - )} + {isCommand(field.content) && ( + + )} {isPageActionCommand(field.content) && ( - locale?: string + locale: string tags?: string[] } @@ -49,18 +50,26 @@ export function toSubmitCommandInput( ): SubmitCommandInput | null { const { title, iconUrl, openMode } = cmd + const baseInput = { + title, + iconUrl, + openMode, + locale: getHubLocale(), + } + if (openMode === OPEN_MODE.PAGE_ACTION) { const pa = cmd as PageActionCommand + const targetUrl = pa.pageActionOption?.startUrl ?? null return { - title, - iconUrl: iconUrl || undefined, - openMode, + ...baseInput, + targetUrl, commandData: { - steps: pa.pageActionOption.steps, - startUrl: pa.pageActionOption.startUrl, - openMode: pa.pageActionOption.openMode, + pageActionOption: { + steps: pa.pageActionOption.steps, + startUrl: pa.pageActionOption.startUrl, + openMode: pa.pageActionOption.openMode, + }, }, - locale: getHubLocale(), } } @@ -68,16 +77,15 @@ export function toSubmitCommandInput( const sc = cmd as SearchCommand if (!sc.searchUrl) return null + const targetUrl = sc.searchUrl const commandData: Record = { searchUrl: sc.searchUrl } if (sc.openModeSecondary) commandData.openModeSecondary = sc.openModeSecondary if (sc.spaceEncoding) commandData.spaceEncoding = sc.spaceEncoding return { - title, - iconUrl: iconUrl || undefined, - openMode, + ...baseInput, + targetUrl, commandData, - locale: getHubLocale(), } } @@ -91,7 +99,7 @@ export function shareCommandToHub(command: SelectionCommand): boolean { if (!input) return false const locale = getHubLocale() - const hubUrl = `${NEW_HUB_URL}/${locale}` + const hubUrl = `${NEW_HUB_URL}/${locale}/dashboard/commands` const hubWindow = window.open(hubUrl, "_blank") if (!hubWindow) { From 95adca9319a0994f6dcc826702d7de0c9e82be71 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 2 May 2026 21:33:45 +0900 Subject: [PATCH 4/8] Update: Support AiPrompt mode. --- .../src/components/option/ShareButton.tsx | 1 + .../option/editor/CommandEditDialog.tsx | 48 +++ .../components/option/editor/CommandList.tsx | 10 +- .../option/editor/CommandTreeRenderer.tsx | 1 - packages/extension/src/const.ts | 10 +- .../extension/src/services/hubShare.test.ts | 321 ++++++++++++++++++ packages/extension/src/services/hubShare.ts | 28 +- 7 files changed, 403 insertions(+), 16 deletions(-) create mode 100644 packages/extension/src/services/hubShare.test.ts diff --git a/packages/extension/src/components/option/ShareButton.tsx b/packages/extension/src/components/option/ShareButton.tsx index 40f89489..e728281f 100644 --- a/packages/extension/src/components/option/ShareButton.tsx +++ b/packages/extension/src/components/option/ShareButton.tsx @@ -8,6 +8,7 @@ import type { SelectionCommand } from "@/types" const VALID_SOURCE_TYPES = new Set([ COMMAND_SOURCE_TYPE.SELF_CREATED, + COMMAND_SOURCE_TYPE.SELF_UPDATED, COMMAND_SOURCE_TYPE.UNKNOWN, ]) diff --git a/packages/extension/src/components/option/editor/CommandEditDialog.tsx b/packages/extension/src/components/option/editor/CommandEditDialog.tsx index cd92dcee..b40c2b68 100644 --- a/packages/extension/src/components/option/editor/CommandEditDialog.tsx +++ b/packages/extension/src/components/option/editor/CommandEditDialog.tsx @@ -93,6 +93,7 @@ import { commandSchema, CommandSchemaType, isPageActionType, + isAiPromptType, } from "@/types/schema" import type { SelectionCommand, @@ -358,6 +359,17 @@ const CommandEditDialogInner = ({ defaultValue: "", }) + const pageActionOption = useWatch({ + control: form.control, + name: "pageActionOption", + }) + + const aiPromptPrompt = useWatch({ + control: form.control, + name: "aiPromptOption.prompt", + defaultValue: "", + }) + const iconUrlSrc = searchUrl || startUrl const openPageActionRecorder = async () => { @@ -450,6 +462,42 @@ const CommandEditDialogInner = ({ } }, []) + useEffect(() => { + if (!initialized) return + if (!isUpdate) return + if (!isSearchType(command)) return + if (command.sourceType === COMMAND_SOURCE_TYPE.SELF_CREATED) return + if (searchUrl !== command.searchUrl) { + setValue("sourceType", COMMAND_SOURCE_TYPE.SELF_UPDATED) + setValue("sourceId", COMMAND_SOURCE_ID.SELF_UPDATED) + } + }, [initialized, isUpdate, searchUrl, command, setValue]) + + useEffect(() => { + if (!initialized) return + if (!isUpdate) return + if (!isPageActionType(command)) return + if (command.sourceType === COMMAND_SOURCE_TYPE.SELF_CREATED) return + if ( + JSON.stringify(pageActionOption) !== + JSON.stringify(command.pageActionOption) + ) { + setValue("sourceType", COMMAND_SOURCE_TYPE.SELF_UPDATED) + setValue("sourceId", COMMAND_SOURCE_ID.SELF_UPDATED) + } + }, [initialized, isUpdate, pageActionOption, command, setValue]) + + useEffect(() => { + if (!initialized) return + if (!isUpdate) return + if (!isAiPromptType(command)) return + if (command.sourceType === COMMAND_SOURCE_TYPE.SELF_CREATED) return + if (aiPromptPrompt !== command.aiPromptOption.prompt) { + setValue("sourceType", COMMAND_SOURCE_TYPE.SELF_UPDATED) + setValue("sourceId", COMMAND_SOURCE_ID.SELF_UPDATED) + } + }, [initialized, isUpdate, aiPromptPrompt, command, setValue]) + return ( diff --git a/packages/extension/src/components/option/editor/CommandList.tsx b/packages/extension/src/components/option/editor/CommandList.tsx index 35175698..90528563 100644 --- a/packages/extension/src/components/option/editor/CommandList.tsx +++ b/packages/extension/src/components/option/editor/CommandList.tsx @@ -27,13 +27,7 @@ import { } from "@/types/schema" import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics" -import { - SCREEN, - COMMAND_TYPE, - OPEN_MODE_TYPE_MAP, - COMMAND_SOURCE_TYPE, - COMMAND_SOURCE_ID, -} from "@/const" +import { SCREEN, COMMAND_TYPE, OPEN_MODE_TYPE_MAP } from "@/const" import type { Command, CommandFolder, SelectionCommand } from "@/types" // Imported services and hooks @@ -254,8 +248,6 @@ export const CommandList = ({ control }: CommandListProps) => { const cmd = commandArray.fields[index] cmd.id = crypto.randomUUID() cmd.title = title - cmd.sourceType = COMMAND_SOURCE_TYPE.SELF_CREATED - cmd.sourceId = COMMAND_SOURCE_ID.SELF_CREATED commandArray.insert(index + 1, cmd) } diff --git a/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx b/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx index 516d4138..84c3c9f2 100644 --- a/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx +++ b/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx @@ -13,7 +13,6 @@ import { isPageActionCommand, } from "@/services/option/commandUtils" import { calcLevel } from "@/services/option/commandTree" -import { NEW_HUB_SHAREABLE_OPEN_MODES } from "@/const" interface Props { nodes: FlattenNode[] diff --git a/packages/extension/src/const.ts b/packages/extension/src/const.ts index aced0433..0721a493 100644 --- a/packages/extension/src/const.ts +++ b/packages/extension/src/const.ts @@ -326,20 +326,20 @@ export const NEW_HUB_URL = import.meta.env.VITE_NEW_HUB_URL ?? "https://selection-command-hub.pages.dev" export const NEW_HUB_SUPPORTED_LOCALES = [ - "ja", - "en", - "ko", - "zh-CN", "de", + "en", "es", "fr", "hi", "id", "it", + "ja", + "ko", "ms", "pt-BR", "pt-PT", "ru", + "zh-CN", ] as const export type NewHubLocale = (typeof NEW_HUB_SUPPORTED_LOCALES)[number] @@ -372,6 +372,7 @@ export const COMMAND_USAGE = { export enum COMMAND_SOURCE_TYPE { DEFAULT = "default", SELF_CREATED = "selfCreated", + SELF_UPDATED = "selfUpdated", HUB_COMMUNITY = "hubCommunity", UNKNOWN = "unknown", } @@ -379,6 +380,7 @@ export enum COMMAND_SOURCE_TYPE { export const COMMAND_SOURCE_ID = { DEFAULT: "019db873-cc03-7484-86f1-2d349389ea2b", SELF_CREATED: "019db8a1-4021-7ae7-8a5d-474bf132e8ff", + SELF_UPDATED: "019de776-d3ea-76af-99fe-340ae9bab54d", } export const SHORTCUT_PLACEHOLDER = "_placeholder_" diff --git a/packages/extension/src/services/hubShare.test.ts b/packages/extension/src/services/hubShare.test.ts new file mode 100644 index 00000000..f0700465 --- /dev/null +++ b/packages/extension/src/services/hubShare.test.ts @@ -0,0 +1,321 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { + getHubLocale, + toSubmitCommandInput, + shareCommandToHub, +} from "./hubShare" +import { OPEN_MODE, PAGE_ACTION_OPEN_MODE } from "@/const" +import type { SearchCommand, PageActionCommand, AiPromptCommand } from "@/types" + +// ---- Fixtures -------------------------------------------------------------- + +const baseCmd = { + id: "cmd-1", + title: "Test Command", + iconUrl: "https://example.com/icon.png", +} + +const makeSearchCmd = (overrides?: Partial): SearchCommand => ({ + ...baseCmd, + openMode: OPEN_MODE.POPUP, + searchUrl: "https://google.com/search?q=%s", + ...overrides, +}) + +const makePageActionCmd = ( + overrides?: Partial, +): PageActionCommand => ({ + ...baseCmd, + openMode: OPEN_MODE.PAGE_ACTION, + pageActionOption: { + startUrl: "https://example.com", + steps: [], + openMode: PAGE_ACTION_OPEN_MODE.TAB, + }, + ...overrides, +}) + +const makeAiPromptCmd = ( + overrides?: Partial, +): AiPromptCommand => ({ + ...baseCmd, + openMode: OPEN_MODE.AI_PROMPT, + aiPromptOption: { + serviceId: "gemini", + prompt: "Summarize: {{SelectedText}}", + openMode: OPEN_MODE.SIDE_PANEL, + }, + ...overrides, +}) + +// ---- getHubLocale ---------------------------------------------------------- + +describe("getHubLocale", () => { + beforeEach(() => { + vi.spyOn(chrome.i18n, "getUILanguage") + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("GL-01: returns an exact-match locale", () => { + vi.mocked(chrome.i18n.getUILanguage).mockReturnValue("ja") + expect(getHubLocale()).toBe("ja") + }) + + it("GL-02: resolves locale by prefix match (zh-TW → zh-CN)", () => { + vi.mocked(chrome.i18n.getUILanguage).mockReturnValue("zh-TW") + expect(getHubLocale()).toBe("zh-CN") + }) + + it("GL-03: resolves locale by prefix match (pt-BR → pt-BR)", () => { + vi.mocked(chrome.i18n.getUILanguage).mockReturnValue("pt-BR") + expect(getHubLocale()).toBe("pt-BR") + }) + + it("GL-04: returns default 'en' for unsupported languages", () => { + vi.mocked(chrome.i18n.getUILanguage).mockReturnValue("xx-UNKNOWN") + expect(getHubLocale()).toBe("en") + }) +}) + +// ---- toSubmitCommandInput -------------------------------------------------- + +describe("toSubmitCommandInput", () => { + beforeEach(() => { + vi.spyOn(chrome.i18n, "getUILanguage").mockReturnValue("en") + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + // --- Search commands --- + + it("SC-01: converts a POPUP command correctly", () => { + const cmd = makeSearchCmd({ openMode: OPEN_MODE.POPUP }) + const result = toSubmitCommandInput(cmd) + expect(result).not.toBeNull() + expect(result!.openMode).toBe(OPEN_MODE.POPUP) + expect(result!.targetUrl).toBe("https://google.com/search?q=%s") + expect(result!.commandData).toMatchObject({ + searchUrl: "https://google.com/search?q=%s", + }) + }) + + it("SC-02: converts a TAB command correctly", () => { + const cmd = makeSearchCmd({ openMode: OPEN_MODE.TAB }) + const result = toSubmitCommandInput(cmd) + expect(result).not.toBeNull() + expect(result!.openMode).toBe(OPEN_MODE.TAB) + }) + + it("SC-03: returns null when searchUrl is not set", () => { + const cmd = makeSearchCmd({ searchUrl: undefined }) + expect(toSubmitCommandInput(cmd)).toBeNull() + }) + + it("SC-04: includes openModeSecondary in commandData", () => { + const cmd = makeSearchCmd({ openModeSecondary: OPEN_MODE.TAB }) + const result = toSubmitCommandInput(cmd) + expect(result!.commandData.openModeSecondary).toBe(OPEN_MODE.TAB) + }) + + it("SC-05: includes spaceEncoding in commandData", () => { + const cmd = makeSearchCmd({ spaceEncoding: "plus" as any }) + const result = toSubmitCommandInput(cmd) + expect(result!.commandData.spaceEncoding).toBe("plus") + }) + + it("SC-06: omits openModeSecondary from commandData when not set", () => { + const cmd = makeSearchCmd() + const result = toSubmitCommandInput(cmd) + expect(result!.commandData).not.toHaveProperty("openModeSecondary") + }) + + // --- PAGE_ACTION --- + + it("PA-01: converts a PAGE_ACTION command correctly", () => { + const cmd = makePageActionCmd() + const result = toSubmitCommandInput(cmd) + expect(result).not.toBeNull() + expect(result!.openMode).toBe(OPEN_MODE.PAGE_ACTION) + expect(result!.targetUrl).toBe("https://example.com") + expect(result!.commandData).toMatchObject({ + pageActionOption: { + startUrl: "https://example.com", + steps: [], + openMode: OPEN_MODE.TAB, + }, + }) + }) + + it("PA-02: sets targetUrl to null when startUrl is not set", () => { + const cmd = makePageActionCmd({ + pageActionOption: { + steps: [], + openMode: PAGE_ACTION_OPEN_MODE.TAB, + startUrl: undefined as any, + }, + }) + const result = toSubmitCommandInput(cmd) + expect(result!.targetUrl).toBeNull() + }) + + // --- AI_PROMPT --- + + it("AI-01: converts an AI_PROMPT command correctly (gemini)", () => { + const cmd = makeAiPromptCmd() + const result = toSubmitCommandInput(cmd) + expect(result).not.toBeNull() + expect(result!.openMode).toBe(OPEN_MODE.AI_PROMPT) + expect(result!.targetUrl).toBe("https://gemini.google.com/app") + expect(result!.commandData).toMatchObject({ + aiPromptOption: { + serviceId: "gemini", + prompt: "Summarize: {{SelectedText}}", + openMode: OPEN_MODE.SIDE_PANEL, + }, + }) + }) + + it("AI-02: converts an AI_PROMPT command correctly (chatgpt)", () => { + const cmd = makeAiPromptCmd({ + aiPromptOption: { + serviceId: "chatgpt", + prompt: "Translate: {{SelectedText}}", + openMode: OPEN_MODE.POPUP, + }, + }) + const result = toSubmitCommandInput(cmd) + expect(result!.targetUrl).toBe("https://chatgpt.com") + expect(result!.commandData).toMatchObject({ + aiPromptOption: { serviceId: "chatgpt" }, + }) + }) + + it("AI-03: sets targetUrl to empty string for unknown serviceId", () => { + const cmd = makeAiPromptCmd({ + aiPromptOption: { + serviceId: "unknown-service", + prompt: "Hello", + openMode: OPEN_MODE.POPUP, + }, + }) + const result = toSubmitCommandInput(cmd) + expect(result!.targetUrl).toBe("") + }) + + it("AI-04: inherits title, iconUrl, and locale from baseInput", () => { + const cmd = makeAiPromptCmd() + const result = toSubmitCommandInput(cmd) + expect(result!.title).toBe("Test Command") + expect(result!.iconUrl).toBe("https://example.com/icon.png") + expect(result!.locale).toBe("en") + }) +}) + +// ---- shareCommandToHub ----------------------------------------------------- + +describe("shareCommandToHub", () => { + let mockHubWindow: { postMessage: ReturnType } + + beforeEach(() => { + vi.spyOn(chrome.i18n, "getUILanguage").mockReturnValue("en") + vi.useFakeTimers() + + mockHubWindow = { postMessage: vi.fn() } + vi.spyOn(window, "open").mockReturnValue(mockHubWindow as any) + vi.spyOn(window, "addEventListener") + vi.spyOn(window, "removeEventListener") + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it("SH-01: opens Hub window and returns true for a valid command", () => { + const result = shareCommandToHub(makeSearchCmd()) + expect(result).toBe(true) + expect(window.open).toHaveBeenCalledWith( + expect.stringContaining("/en/dashboard/commands"), + "_blank", + ) + }) + + it("SH-02: returns false when the command has no searchUrl", () => { + const result = shareCommandToHub(makeSearchCmd({ searchUrl: undefined })) + expect(result).toBe(false) + expect(window.open).not.toHaveBeenCalled() + }) + + it("SH-03: returns false when window.open returns null", () => { + vi.mocked(window.open).mockReturnValue(null) + const result = shareCommandToHub(makeSearchCmd()) + expect(result).toBe(false) + }) + + it("SH-04: sends postMessage on each interval tick", () => { + shareCommandToHub(makeSearchCmd()) + vi.advanceTimersByTime(500) + expect(mockHubWindow.postMessage).toHaveBeenCalledTimes(1) + expect(mockHubWindow.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "share-command" }), + expect.any(String), + ) + vi.advanceTimersByTime(500) + expect(mockHubWindow.postMessage).toHaveBeenCalledTimes(2) + }) + + it("SH-05: stops retrying after receiving share-command-ack", () => { + const NEW_HUB_URL = + import.meta.env.VITE_NEW_HUB_URL ?? + "https://selection-command-hub.pages.dev" + + shareCommandToHub(makeSearchCmd()) + + const ackEvent = new MessageEvent("message", { + origin: NEW_HUB_URL, + data: { type: "share-command-ack" }, + }) + window.dispatchEvent(ackEvent) + + const beforeCount = mockHubWindow.postMessage.mock.calls.length + vi.advanceTimersByTime(2000) + expect(mockHubWindow.postMessage.mock.calls.length).toBe(beforeCount) + }) + + it("SH-06: can share an AI_PROMPT command", () => { + const result = shareCommandToHub(makeAiPromptCmd()) + expect(result).toBe(true) + vi.advanceTimersByTime(500) + expect(mockHubWindow.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "share-command", + command: expect.objectContaining({ + openMode: OPEN_MODE.AI_PROMPT, + targetUrl: "https://gemini.google.com/app", + }), + }), + expect.any(String), + ) + }) + + it("SH-07: can share a PAGE_ACTION command", () => { + const result = shareCommandToHub(makePageActionCmd()) + expect(result).toBe(true) + vi.advanceTimersByTime(500) + expect(mockHubWindow.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "share-command", + command: expect.objectContaining({ + openMode: OPEN_MODE.PAGE_ACTION, + targetUrl: "https://example.com", + }), + }), + expect.any(String), + ) + }) +}) diff --git a/packages/extension/src/services/hubShare.ts b/packages/extension/src/services/hubShare.ts index 88f98ae6..9c0822d3 100644 --- a/packages/extension/src/services/hubShare.ts +++ b/packages/extension/src/services/hubShare.ts @@ -4,10 +4,12 @@ import { OPEN_MODE, type NewHubLocale, } from "@/const" +import { getAiServicesFallback } from "@/services/aiPromptFallback" import type { SelectionCommand, SearchCommand, PageActionCommand, + AiPromptCommand, } from "@/types" // ---- 型定義 --------------------------------------------------------------- @@ -57,6 +59,24 @@ export function toSubmitCommandInput( locale: getHubLocale(), } + if (openMode === OPEN_MODE.AI_PROMPT) { + const ai = cmd as AiPromptCommand + const { serviceId } = ai.aiPromptOption + const service = getAiServicesFallback().find((s) => s.id === serviceId) + const targetUrl = service?.url ?? "" + return { + ...baseInput, + targetUrl, + commandData: { + aiPromptOption: { + serviceId, + prompt: ai.aiPromptOption.prompt, + openMode: ai.aiPromptOption.openMode, + }, + }, + } + } + if (openMode === OPEN_MODE.PAGE_ACTION) { const pa = cmd as PageActionCommand const targetUrl = pa.pageActionOption?.startUrl ?? null @@ -96,11 +116,15 @@ const MAX_RETRIES = 20 // 10秒 export function shareCommandToHub(command: SelectionCommand): boolean { const input = toSubmitCommandInput(command) - if (!input) return false + if (!input) { + console.warn( + "Unsupported command type or missing data. Cannot share to Hub.", + ) + return false + } const locale = getHubLocale() const hubUrl = `${NEW_HUB_URL}/${locale}/dashboard/commands` - const hubWindow = window.open(hubUrl, "_blank") if (!hubWindow) { console.error("[HubShare] Failed to open Hub page.") From 97b2047cf1643435ff4cb7588f5443f686bfce48 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 2 May 2026 21:37:28 +0900 Subject: [PATCH 5/8] Update: Translate comments. --- packages/extension/src/services/hubShare.ts | 22 ++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/extension/src/services/hubShare.ts b/packages/extension/src/services/hubShare.ts index 9c0822d3..70329bda 100644 --- a/packages/extension/src/services/hubShare.ts +++ b/packages/extension/src/services/hubShare.ts @@ -12,7 +12,7 @@ import type { AiPromptCommand, } from "@/types" -// ---- 型定義 --------------------------------------------------------------- +// ---- Type definitions ------------------------------------------------------ export interface SubmitCommandInput { title: string @@ -25,7 +25,7 @@ export interface SubmitCommandInput { tags?: string[] } -// ---- ロケール解決 ---------------------------------------------------------- +// ---- Locale resolution ----------------------------------------------------- export function getHubLocale(): NewHubLocale { const lang = ( @@ -34,18 +34,18 @@ export function getHubLocale(): NewHubLocale { "en" ).toLowerCase() - // 完全一致 + // Exact match for (const locale of NEW_HUB_SUPPORTED_LOCALES) { if (lang === locale.toLowerCase()) return locale } - // 前方一致(例: "zh-tw" → "zh-CN"、"pt-br" → "pt-BR") + // Prefix match (e.g. "zh-tw" → "zh-CN", "pt-br" → "pt-BR") for (const locale of NEW_HUB_SUPPORTED_LOCALES) { if (lang.startsWith(locale.split("-")[0].toLowerCase())) return locale } return "en" } -// ---- コマンドデータ変換 ---------------------------------------------------- +// ---- Command data conversion ----------------------------------------------- export function toSubmitCommandInput( cmd: SelectionCommand, @@ -93,7 +93,7 @@ export function toSubmitCommandInput( } } - // Search 系(popup / tab / window / backgroundTab / sidePanel) + // Search-based commands (popup / tab / window / backgroundTab / sidePanel) const sc = cmd as SearchCommand if (!sc.searchUrl) return null @@ -109,10 +109,10 @@ export function toSubmitCommandInput( } } -// ---- 共有メイン処理 -------------------------------------------------------- +// ---- Share main logic ------------------------------------------------------ const RETRY_INTERVAL_MS = 500 -const MAX_RETRIES = 20 // 10秒 +const MAX_RETRIES = 20 // 10 seconds export function shareCommandToHub(command: SelectionCommand): boolean { const input = toSubmitCommandInput(command) @@ -138,7 +138,7 @@ export function shareCommandToHub(command: SelectionCommand): boolean { window.removeEventListener("message", onAck) } - // Hub からの ack を受け取ったらリトライを停止する + // Stop retrying once an ack is received from the Hub const onAck = (event: MessageEvent) => { if (event.origin !== NEW_HUB_URL) return if ((event.data as { type?: string })?.type === "share-command-ack") { @@ -159,9 +159,9 @@ export function shareCommandToHub(command: SelectionCommand): boolean { { type: "share-command", command: input }, NEW_HUB_URL, ) - // ack が来るまで clearInterval しない + // Keep the interval running until ack is received } catch { - // hub ウィンドウがまだロード中 → 次のリトライへ + // Hub window still loading — retry on next tick } }, RETRY_INTERVAL_MS) From bc0035dbabb7b52163472fb063aaa29055a9b8e4 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 2 May 2026 22:55:20 +0900 Subject: [PATCH 6/8] Fix: Review comments and e2e testing. --- docs/composed-swimming-island.md | 1 + .../option/editor/CommandEditDialog.tsx | 9 ++++++--- packages/extension/src/const.ts | 2 +- packages/extension/src/services/hubShare.ts | 19 +++++++++++++------ 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/composed-swimming-island.md b/docs/composed-swimming-island.md index 2037c9a7..36489bef 100644 --- a/docs/composed-swimming-island.md +++ b/docs/composed-swimming-island.md @@ -98,6 +98,7 @@ export const NEW_HUB_SHAREABLE_OPEN_MODES: ReadonlySet = new Set([ OPEN_MODE.BACKGROUND_TAB, OPEN_MODE.SIDE_PANEL, OPEN_MODE.PAGE_ACTION, + OPEN_MODE.AI_PROMPT, // 意図的なスコープ拡張: AiPrompt コマンドも Hub に共有可能 ]) ``` diff --git a/packages/extension/src/components/option/editor/CommandEditDialog.tsx b/packages/extension/src/components/option/editor/CommandEditDialog.tsx index b40c2b68..d7f61a3d 100644 --- a/packages/extension/src/components/option/editor/CommandEditDialog.tsx +++ b/packages/extension/src/components/option/editor/CommandEditDialog.tsx @@ -467,17 +467,19 @@ const CommandEditDialogInner = ({ if (!isUpdate) return if (!isSearchType(command)) return if (command.sourceType === COMMAND_SOURCE_TYPE.SELF_CREATED) return + if (getValues("sourceType") === COMMAND_SOURCE_TYPE.SELF_UPDATED) return if (searchUrl !== command.searchUrl) { setValue("sourceType", COMMAND_SOURCE_TYPE.SELF_UPDATED) setValue("sourceId", COMMAND_SOURCE_ID.SELF_UPDATED) } - }, [initialized, isUpdate, searchUrl, command, setValue]) + }, [initialized, isUpdate, searchUrl, command, getValues, setValue]) useEffect(() => { if (!initialized) return if (!isUpdate) return if (!isPageActionType(command)) return if (command.sourceType === COMMAND_SOURCE_TYPE.SELF_CREATED) return + if (getValues("sourceType") === COMMAND_SOURCE_TYPE.SELF_UPDATED) return if ( JSON.stringify(pageActionOption) !== JSON.stringify(command.pageActionOption) @@ -485,18 +487,19 @@ const CommandEditDialogInner = ({ setValue("sourceType", COMMAND_SOURCE_TYPE.SELF_UPDATED) setValue("sourceId", COMMAND_SOURCE_ID.SELF_UPDATED) } - }, [initialized, isUpdate, pageActionOption, command, setValue]) + }, [initialized, isUpdate, pageActionOption, command, getValues, setValue]) useEffect(() => { if (!initialized) return if (!isUpdate) return if (!isAiPromptType(command)) return if (command.sourceType === COMMAND_SOURCE_TYPE.SELF_CREATED) return + if (getValues("sourceType") === COMMAND_SOURCE_TYPE.SELF_UPDATED) return if (aiPromptPrompt !== command.aiPromptOption.prompt) { setValue("sourceType", COMMAND_SOURCE_TYPE.SELF_UPDATED) setValue("sourceId", COMMAND_SOURCE_ID.SELF_UPDATED) } - }, [initialized, isUpdate, aiPromptPrompt, command, setValue]) + }, [initialized, isUpdate, aiPromptPrompt, command, getValues, setValue]) return ( diff --git a/packages/extension/src/const.ts b/packages/extension/src/const.ts index 0721a493..0bfa8789 100644 --- a/packages/extension/src/const.ts +++ b/packages/extension/src/const.ts @@ -323,7 +323,7 @@ export const HUB_URL = isDebug : "https://ujiro99.github.io/selection-command" export const NEW_HUB_URL = - import.meta.env.VITE_NEW_HUB_URL ?? "https://selection-command-hub.pages.dev" + import.meta.env?.VITE_NEW_HUB_URL ?? "https://selection-command-hub.pages.dev" export const NEW_HUB_SUPPORTED_LOCALES = [ "de", diff --git a/packages/extension/src/services/hubShare.ts b/packages/extension/src/services/hubShare.ts index 70329bda..882d523c 100644 --- a/packages/extension/src/services/hubShare.ts +++ b/packages/extension/src/services/hubShare.ts @@ -18,7 +18,7 @@ export interface SubmitCommandInput { title: string description?: string iconUrl?: string - targetUrl: string + targetUrl: string | null openMode: string commandData: Record locale: string @@ -38,7 +38,10 @@ export function getHubLocale(): NewHubLocale { for (const locale of NEW_HUB_SUPPORTED_LOCALES) { if (lang === locale.toLowerCase()) return locale } - // Prefix match (e.g. "zh-tw" → "zh-CN", "pt-br" → "pt-BR") + // Prefix match (e.g. "zh-tw" → "zh-CN", "pt-br" → "pt-BR"). + // Note: a bare "pt" browser locale resolves to the first matching entry in + // NEW_HUB_SUPPORTED_LOCALES (currently "pt-BR"). pt-PT users should have + // "pt-PT" set as their browser language to get the correct locale. for (const locale of NEW_HUB_SUPPORTED_LOCALES) { if (lang.startsWith(locale.split("-")[0].toLowerCase())) return locale } @@ -123,8 +126,7 @@ export function shareCommandToHub(command: SelectionCommand): boolean { return false } - const locale = getHubLocale() - const hubUrl = `${NEW_HUB_URL}/${locale}/dashboard/commands` + const hubUrl = `${NEW_HUB_URL}/${input.locale}/dashboard/commands` const hubWindow = window.open(hubUrl, "_blank") if (!hubWindow) { console.error("[HubShare] Failed to open Hub page.") @@ -160,8 +162,13 @@ export function shareCommandToHub(command: SelectionCommand): boolean { NEW_HUB_URL, ) // Keep the interval running until ack is received - } catch { - // Hub window still loading — retry on next tick + } catch (err) { + if (err instanceof DOMException) { + // Hub window still loading — retry on next tick + } else { + cleanup() + console.error("[HubShare] Unexpected error during postMessage:", err) + } } }, RETRY_INTERVAL_MS) From be512555c95f2060a90e21e85a1df4fbe9023794 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sun, 3 May 2026 14:59:02 +0900 Subject: [PATCH 7/8] Update: Fix review comment. --- .../src/components/option/ShareButton.tsx | 39 ++++++++++++------- packages/extension/src/services/hubShare.ts | 10 +++++ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/packages/extension/src/components/option/ShareButton.tsx b/packages/extension/src/components/option/ShareButton.tsx index e728281f..3865888e 100644 --- a/packages/extension/src/components/option/ShareButton.tsx +++ b/packages/extension/src/components/option/ShareButton.tsx @@ -1,5 +1,6 @@ -import { useState } from "react" +import { useState, useRef } from "react" import { Share } from "lucide-react" +import { Tooltip } from "@/components/Tooltip" import { cn } from "@/lib/utils" import { t } from "@/services/i18n" import { shareCommandToHub } from "@/services/hubShare" @@ -17,6 +18,7 @@ type Props = { } export const ShareButton = ({ command }: Props) => { + const buttonRef = useRef(null) const [status, setStatus] = useState<"idle" | "sent" | "error">("idle") const handleClick = (e: React.MouseEvent) => { @@ -34,22 +36,29 @@ export const ShareButton = ({ command }: Props) => { } return ( - + - + ) } diff --git a/packages/extension/src/services/hubShare.ts b/packages/extension/src/services/hubShare.ts index 882d523c..29a6ccf9 100644 --- a/packages/extension/src/services/hubShare.ts +++ b/packages/extension/src/services/hubShare.ts @@ -117,7 +117,11 @@ export function toSubmitCommandInput( const RETRY_INTERVAL_MS = 500 const MAX_RETRIES = 20 // 10 seconds +let isSharing = false + export function shareCommandToHub(command: SelectionCommand): boolean { + if (isSharing) return false + const input = toSubmitCommandInput(command) if (!input) { console.warn( @@ -133,11 +137,13 @@ export function shareCommandToHub(command: SelectionCommand): boolean { return false } + isSharing = true let retries = 0 const cleanup = () => { clearInterval(timer) window.removeEventListener("message", onAck) + isSharing = false } // Stop retrying once an ack is received from the Hub @@ -151,6 +157,10 @@ export function shareCommandToHub(command: SelectionCommand): boolean { const timer = setInterval(() => { retries++ + if (hubWindow.closed) { + cleanup() + return + } if (retries > MAX_RETRIES) { cleanup() console.error("[HubShare] Hub page did not respond in time.") From 8460805d242b2fd89720365af87de83f16f7e0c4 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sun, 3 May 2026 15:15:22 +0900 Subject: [PATCH 8/8] Update: Fix tests. --- docs/composed-swimming-island.md | 344 ------------------ .../option/editor/CommandEditDialog.tsx | 46 +-- .../option/editor/CommandTreeRenderer.tsx | 2 +- packages/extension/src/const.ts | 2 +- .../extension/src/services/hubShare.test.ts | 2 + packages/extension/src/services/hubShare.ts | 15 +- 6 files changed, 29 insertions(+), 382 deletions(-) delete mode 100644 docs/composed-swimming-island.md diff --git a/docs/composed-swimming-island.md b/docs/composed-swimming-island.md deleted file mode 100644 index 36489bef..00000000 --- a/docs/composed-swimming-island.md +++ /dev/null @@ -1,344 +0,0 @@ -# Plan: New Hub (selection-command-hub) へのコマンド投稿機能 - -## Context - -Selection Command Hub の新バージョン(`https://selection-command-hub.pages.dev`)が公開された。 -旧 Hub(GitHub Pages の静的サイト)では Google Apps Script エンドポイントへ POST していたが、 -新 Hub は Supabase + Server Action をバックエンドに持ち、 -Chrome 拡張から `window.postMessage` でコマンドデータを送信する新しいプロトコルを採用している。 - -**やること**: 拡張機能のオプションページのコマンド一覧に「Hub へ共有」ボタンを追加し、 -新 Hub の postMessage プロトコルでコマンドを投稿できるようにする。 - -旧 Hub との連携(ダウンロード機能 / MyCommands UI の注入)はそのまま維持する。 - ---- - -## 新しい共有フロー - -``` -[オプションページ] ShareButton クリック - → window.open(NEW_HUB_URL/lang, "_blank") ← オプションページのJSコンテキストから直接実行 - → setInterval でリトライしながら hubWindow.postMessage({ type: "share-command", command }, hubOrigin) - → Hub ページが ConfirmForm ダイアログを表示 - → ユーザーが「投稿」クリック → Hub の Server Action が Supabase に保存 -``` - -> **Note**: Chrome 拡張のオプションページ(`chrome-extension://…`)は通常の Web ページとして動作するため、 -> `window.open` でタブを開き、返された window 参照に対して `postMessage` を送信できる。 -> バックグラウンドスクリプトや追加のコンテントスクリプトは不要。 - ---- - -## 変更ファイル一覧 - -| 操作 | ファイル | 内容 | -| ---- | ------------------------------------------------------------------------- | ------------------------ | -| 修正 | `packages/extension/src/const.ts` | `NEW_HUB_URL` 定数を追加 | -| 新規 | `packages/extension/src/services/hubShare.ts` | 共有ロジック一式 | -| 新規 | `packages/extension/src/components/option/ShareButton.tsx` | 共有ボタン UI | -| 修正 | `packages/extension/src/components/option/editor/CommandTreeRenderer.tsx` | ShareButton を追加 | -| 修正 | `packages/extension/public/_locales/*/messages.json` | i18n キー追加(15言語) | - ---- - -## 詳細設計 - -### 0. `.env` への追加 - -`VITE_` プレフィックスを使い、Vite が自動的に `import.meta.env` に注入する形式を採用(既存の `VITE_MEASUREMENT_ID` 等と同じパターン)。 - -**`packages/extension/.env`**: - -``` -VITE_NEW_HUB_URL=https://selection-command-hub.pages.dev -``` - -開発環境向けに `.env.development` を作成: - -``` -VITE_NEW_HUB_URL=http://localhost:3001 -``` - ---- - -### 1. `const.ts` への追加 - -```typescript -// packages/extension/src/const.ts(HUB_URL の直後に追加) - -export const NEW_HUB_URL = - import.meta.env.VITE_NEW_HUB_URL ?? "https://selection-command-hub.pages.dev" - -// Hub がサポートする言語コード -export const NEW_HUB_SUPPORTED_LOCALES = [ - "ja", - "en", - "ko", - "zh-CN", - "de", - "es", - "fr", - "hi", - "id", - "it", - "ms", - "pt-BR", - "pt-PT", - "ru", -] as const - -export type NewHubLocale = (typeof NEW_HUB_SUPPORTED_LOCALES)[number] - -// 新 Hub に投稿できる openMode(それ以外は「共有」ボタン非表示) -export const NEW_HUB_SHAREABLE_OPEN_MODES: ReadonlySet = new Set([ - OPEN_MODE.POPUP, - OPEN_MODE.TAB, - OPEN_MODE.WINDOW, - OPEN_MODE.BACKGROUND_TAB, - OPEN_MODE.SIDE_PANEL, - OPEN_MODE.PAGE_ACTION, - OPEN_MODE.AI_PROMPT, // 意図的なスコープ拡張: AiPrompt コマンドも Hub に共有可能 -]) -``` - ---- - -### 2. `services/hubShare.ts`(新規) - -```typescript -// packages/extension/src/services/hubShare.ts - -import { - NEW_HUB_URL, - NEW_HUB_SUPPORTED_LOCALES, - OPEN_MODE, - type NewHubLocale, -} from "@/const" -import type { - SelectionCommand, - SearchCommand, - PageActionCommand, -} from "@/types" - -// ---- 型定義 --------------------------------------------------------------- - -export interface SubmitCommandInput { - title: string - description?: string - iconUrl?: string - openMode: string - commandData: Record - locale?: string - tags?: string[] -} - -// ---- ロケール解決 ---------------------------------------------------------- - -export function getHubLocale(): NewHubLocale { - const lang = ( - chrome.i18n.getUILanguage() ?? - navigator.language ?? - "en" - ).toLowerCase() - - // 完全一致 - for (const locale of NEW_HUB_SUPPORTED_LOCALES) { - if (lang === locale.toLowerCase()) return locale - } - // 前方一致(例: "zh-tw" → "zh-CN"、"pt-br" → "pt-BR") - for (const locale of NEW_HUB_SUPPORTED_LOCALES) { - if (lang.startsWith(locale.split("-")[0].toLowerCase())) return locale - } - return "en" -} - -// ---- コマンドデータ変換 ---------------------------------------------------- - -export function toSubmitCommandInput( - cmd: SelectionCommand, -): SubmitCommandInput | null { - const { title, iconUrl, openMode } = cmd - - if (openMode === OPEN_MODE.PAGE_ACTION) { - const pa = cmd as PageActionCommand - return { - title, - iconUrl: iconUrl || undefined, - openMode, - commandData: { - steps: pa.pageActionOption.steps, - startUrl: pa.pageActionOption.startUrl, - openMode: pa.pageActionOption.openMode, - }, - locale: getHubLocale(), - } - } - - // Search 系(popup / tab / window / backgroundTab / sidePanel) - const sc = cmd as SearchCommand - if (!sc.searchUrl) return null - - const commandData: Record = { searchUrl: sc.searchUrl } - if (sc.openModeSecondary) commandData.openModeSecondary = sc.openModeSecondary - if (sc.spaceEncoding) commandData.spaceEncoding = sc.spaceEncoding - - return { - title, - iconUrl: iconUrl || undefined, - openMode, - commandData, - locale: getHubLocale(), - } -} - -// ---- 共有メイン処理 -------------------------------------------------------- - -const RETRY_INTERVAL_MS = 500 -const MAX_RETRIES = 20 // 10秒 - -export function shareCommandToHub(command: SelectionCommand): boolean { - const input = toSubmitCommandInput(command) - if (!input) return false - - const locale = getHubLocale() - const hubUrl = `${NEW_HUB_URL}/${locale}` - - const hubWindow = window.open(hubUrl, "_blank") - if (!hubWindow) { - console.error("[HubShare] Failed to open Hub page.") - return false - } - - let retries = 0 - const timer = setInterval(() => { - retries++ - if (retries > MAX_RETRIES) { - clearInterval(timer) - console.error("[HubShare] Hub page did not respond in time.") - return - } - try { - hubWindow.postMessage( - { type: "share-command", command: input }, - NEW_HUB_URL, - ) - clearInterval(timer) - } catch { - // hub ウィンドウがまだロード中 → 次のリトライへ - } - }, RETRY_INTERVAL_MS) - - return true -} -``` - -> **注意**: `postMessage` の `targetOrigin` は `NEW_HUB_URL`(`https://selection-command-hub.pages.dev`)を指定し、 -> `"*"` は使用しない(API 仕様のセキュリティ要件)。 - ---- - -### 3. `ShareButton.tsx`(新規) - -`EditButton` / `RemoveButton` と同じパターンで実装する。 - -```typescript -// packages/extension/src/components/option/ShareButton.tsx - -import { useState } from "react" -import { Share2 } from "lucide-react" -import { cn } from "@/lib/utils" -import { t } from "@/services/i18n" -import { shareCommandToHub } from "@/services/hubShare" -import type { SelectionCommand } from "@/types" - -type Props = { - command: SelectionCommand -} - -export const ShareButton = ({ command }: Props) => { - const [status, setStatus] = useState<"idle" | "sent" | "error">("idle") - - const handleClick = (e: React.MouseEvent) => { - e.stopPropagation() - const ok = shareCommandToHub(command) - setStatus(ok ? "sent" : "error") - setTimeout(() => setStatus("idle"), 2000) - } - - return ( - - ) -} -``` - ---- - -### 4. `CommandTreeRenderer.tsx` の修正 - -差分: - -```diff -+ import { ShareButton } from "@/components/option/ShareButton" -+ import { NEW_HUB_SHAREABLE_OPEN_MODES } from "@/const" - -
-+ {isCommand(field.content) && -+ NEW_HUB_SHAREABLE_OPEN_MODES.has(field.content.openMode) && ( -+ -+ )} - {isPageActionCommand(field.content) && ( -``` - -変更点はこれだけ。Props の追加や CommandList.tsx への変更は不要(ShareButton が自己完結)。 - ---- - -### 5. i18n キーの追加 - -**15言語** の `messages.json` に以下を追加: - -```json -"Option_shareButton_tooltip": { - "message": "Share to Hub" -} -``` - -対象ファイル: `packages/extension/public/_locales/{en,ja,ko,zh_CN,de,es,fr,hi,id,it,ms,pt_BR,pt_PT,ru,…}/messages.json` - -日本語(`ja`): `"Hubに共有"` - ---- - -## ダウンロード記録 API について - -`POST /api/commands/{commandId}/download` は新 Hub の**フロントエンド側**が内部的に呼び出すもので、 -拡張機能から呼び出す必要はない(今回のスコープ外)。 - ---- - -## 検証方法 - -1. `pnpm dev` で拡張機能を開発ビルド(`isDebug=true` → `NEW_HUB_URL=http://localhost:3001`) -2. Chrome で拡張機能のオプションページを開く -3. 検索コマンドの行に Share2 アイコンが表示されることを確認 -4. ボタンをクリック → 新 Hub のタブが開くことを確認 -5. Hub の ConfirmForm ダイアログが表示され、コマンドデータが入力済みであることを確認 -6. PageAction コマンドでも同様に確認 -7. API / aiPrompt / copy などの unsupported コマンドには Share ボタンが表示されないことを確認 diff --git a/packages/extension/src/components/option/editor/CommandEditDialog.tsx b/packages/extension/src/components/option/editor/CommandEditDialog.tsx index d7f61a3d..59d9e239 100644 --- a/packages/extension/src/components/option/editor/CommandEditDialog.tsx +++ b/packages/extension/src/components/option/editor/CommandEditDialog.tsx @@ -465,41 +465,31 @@ const CommandEditDialogInner = ({ useEffect(() => { if (!initialized) return if (!isUpdate) return - if (!isSearchType(command)) return if (command.sourceType === COMMAND_SOURCE_TYPE.SELF_CREATED) return if (getValues("sourceType") === COMMAND_SOURCE_TYPE.SELF_UPDATED) return - if (searchUrl !== command.searchUrl) { - setValue("sourceType", COMMAND_SOURCE_TYPE.SELF_UPDATED) - setValue("sourceId", COMMAND_SOURCE_ID.SELF_UPDATED) - } - }, [initialized, isUpdate, searchUrl, command, getValues, setValue]) - useEffect(() => { - if (!initialized) return - if (!isUpdate) return - if (!isPageActionType(command)) return - if (command.sourceType === COMMAND_SOURCE_TYPE.SELF_CREATED) return - if (getValues("sourceType") === COMMAND_SOURCE_TYPE.SELF_UPDATED) return - if ( - JSON.stringify(pageActionOption) !== - JSON.stringify(command.pageActionOption) - ) { - setValue("sourceType", COMMAND_SOURCE_TYPE.SELF_UPDATED) - setValue("sourceId", COMMAND_SOURCE_ID.SELF_UPDATED) - } - }, [initialized, isUpdate, pageActionOption, command, getValues, setValue]) + const changed = + (isSearchType(command) && searchUrl !== command.searchUrl) || + (isPageActionType(command) && + JSON.stringify(pageActionOption) !== + JSON.stringify(command.pageActionOption)) || + (isAiPromptType(command) && + aiPromptPrompt !== command.aiPromptOption.prompt) - useEffect(() => { - if (!initialized) return - if (!isUpdate) return - if (!isAiPromptType(command)) return - if (command.sourceType === COMMAND_SOURCE_TYPE.SELF_CREATED) return - if (getValues("sourceType") === COMMAND_SOURCE_TYPE.SELF_UPDATED) return - if (aiPromptPrompt !== command.aiPromptOption.prompt) { + if (changed) { setValue("sourceType", COMMAND_SOURCE_TYPE.SELF_UPDATED) setValue("sourceId", COMMAND_SOURCE_ID.SELF_UPDATED) } - }, [initialized, isUpdate, aiPromptPrompt, command, getValues, setValue]) + }, [ + initialized, + isUpdate, + searchUrl, + pageActionOption, + aiPromptPrompt, + command, + getValues, + setValue, + ]) return ( diff --git a/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx b/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx index 84c3c9f2..a3d248c5 100644 --- a/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx +++ b/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx @@ -70,7 +70,7 @@ export const CommandTreeRenderer: React.FC = ({
{isCommand(field.content) && ( - + )} {isPageActionCommand(field.content) && ( { }) afterEach(() => { + _resetShareState() vi.restoreAllMocks() vi.useRealTimers() }) diff --git a/packages/extension/src/services/hubShare.ts b/packages/extension/src/services/hubShare.ts index 29a6ccf9..10e05290 100644 --- a/packages/extension/src/services/hubShare.ts +++ b/packages/extension/src/services/hubShare.ts @@ -28,20 +28,14 @@ export interface SubmitCommandInput { // ---- Locale resolution ----------------------------------------------------- export function getHubLocale(): NewHubLocale { - const lang = ( - chrome.i18n.getUILanguage() ?? - navigator.language ?? - "en" - ).toLowerCase() + const uiLang = chrome.i18n.getUILanguage() + const lang = (uiLang || navigator.language || "en").toLowerCase() // Exact match for (const locale of NEW_HUB_SUPPORTED_LOCALES) { if (lang === locale.toLowerCase()) return locale } // Prefix match (e.g. "zh-tw" → "zh-CN", "pt-br" → "pt-BR"). - // Note: a bare "pt" browser locale resolves to the first matching entry in - // NEW_HUB_SUPPORTED_LOCALES (currently "pt-BR"). pt-PT users should have - // "pt-PT" set as their browser language to get the correct locale. for (const locale of NEW_HUB_SUPPORTED_LOCALES) { if (lang.startsWith(locale.split("-")[0].toLowerCase())) return locale } @@ -119,6 +113,11 @@ const MAX_RETRIES = 20 // 10 seconds let isSharing = false +// Exposed for testing purposes to reset the sharing state between tests +export function _resetShareState(): void { + isSharing = false +} + export function shareCommandToHub(command: SelectionCommand): boolean { if (isSharing) return false