1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Raycastの拡張機能を作ってみる

Posted at

最近流行りのraycast、みなさん使ったことがありますか?

シュートカットで便利な機能を複数使うことができて、使用されている方も多いと思います。

Raycastでは自身で拡張機能を作れるということなので、今回はQiitaの記事をタグ検索できる拡張機能を作ってみようと思います。

Raycastとは

Raycastは、MacOS向けのアプリケーションランチャーツールです。

アプリケーションの起動、ファイルの検索、スニペットの管理など、様々な機能をショートカットキー一つで素早く実行できるようにするツールです。

今回作る拡張機能

今回はQiitaAPIを使用して、Qiitaの記事をtagで検索する拡張機能を作成しようと考えました。

Qiitaを書いていると他の記事を読んでもらうために関連記事を書くことが多いのですが、例えばGoの関連記事が20記事以上もあって、それを一々検索してURLをコピーするのはめんどくさすぎます。

なので、一気にタグ検索をかけて検索にひっかかる記事をコピーできるような機能を作ろうと思います。

拡張機能のセットアップ

raycastを開いて、検索バーでCreate Extensionと検索します。

image.png

すると作成画面が出てくるので、必要な情報を入力していきます。
image.png

今回は、TemplateとしてSubmit Formを使用します。
image.png

次の画面が出たら作成完了です。
image.png

Locationで指定した位置にプロジェクトが作成されていると思います。

そのプロジェクト位置でnodeパッケージのインストールを行います。

zsh
npm install

yarnでも互換性があるかなと思ったのですが、無理だったので後述します。

実際に書いてみる

プロジェクトには、srcファイルの中にtsxファイルが一つだけ配置されている状態になっています。

先ほどTemplateで選択したSubmitFormのおかげである程度Formの原型ができているので、それをベースとして作成しました。

詳しい実装は下のリポジトリにあります。

今回はその中でもraycastで用意されていたhookを使っていくつか実装したりとかしたので、その部分の紹介をしたいと思います。

Form

下のドキュメントを読んでいただければわかりますが、FormにはTextFieldだけでなく、DropDownMenuやTagPikker等も入力フィールドとして準備されています。

今回、バリデーションを行うためにuseFormというHookを使用しています。

バリデーション(validation)

確認や検証を表すことであり、プログラミングでは入力されたデータの値が想定されているデータ型なのかということを調べることを言います。

useFormは下記の部分で定義しています。
まず、型アノテーションで入力フォームのプロパティとそのデータ型を定義して、useFormのジェネリクスとして設定します。
第一引数のonSubmitでは、入力したい内容をフォームを送信した際にやる処理をに記述します。
第二引数のvalidationでは、入力必須にしたいフォームの入力を指定しています。

search-qiita-article-by-tag.tsx
type SearchArticleValues = {
  accessToken: string;
  userID: string;
  tag: string;
};

const { handleSubmit, itemProps, setValue } = useForm<SearchArticleValues>({
    async onSubmit(values) {
      await saveAccessToken(values.accessToken); //ここら辺は後々説明
      await saveUserID(values.userID);
      await saveTag(values.tag);

      const query = `?query=user:${values.userID}+tag:${values.tag}`;
      const urls: string[] = [];
      const articles: ArticleInfo[] = [];

      try {
        await showToast({
          style: Toast.Style.Animated,
          title: "Fetching Article...",
        });

        setAccessToken(values.accessToken);
        const res = await apiClient.get(query);

        res.data.forEach((item: QiitaItemRes) => {
          if (item) {
            articles.push({
              title: item.title,
              url: item.url,
              image: "",
              likes_count: item.likes_count,
              stocks_count: item.stocks_count,
              tags: item.tags,
            });
          }
        });

        const setArticle = await setOgps(articles);

        await showToast({
          style: Toast.Style.Success,
          title: "Success Copied!",
        });

        await push(<ResultView articles={setArticle} urls={urls} />);
      } catch (err) {
        await showToast({
          style: Toast.Style.Failure,
          title: `Fetching Error: ${err}`,
        });
      }
    },
    validation: {
      accessToken: FormValidation.Required,
      userID: FormValidation.Required,
    },
  });

showToast

showToastで、左下にポップアップ表示することができます。

例えば、上のuseFormのonSubmit内で下のようなコードを書いてると思います。

await showToast({
    style: Toast.Style.Success, 
    title: "Success Copied!",
});

これはフォームにおけるonSubmitが成功した時に表示しているのですが、その場合にはraycastの左下に以下のような感じで表示されます。

image.png

styleでは、緑の丸の部分の表示するのかをいくつか選択することができます。

style 表示内容
Toast.Style.Success 緑色の円で成功を表す
Toast.Style.Failure 赤色の縁で失敗を表す
Toast.Style.Animated ぐるぐると円が回転する、ローディングを表す

LocalStrage

Raycastの拡張機能には、ストレージ用のAPIが用意されており、それを使ってデータを暗号化しつつ、永続化できるようにしています。

データの永続化をさせることでキャッシュができるようになるので、今回はそれで入力フォームのキャッシュを行っています。

実際にfunc.tsで以下のようにStorageへの格納取り出しを行っています。

func.ts
import { LocalStorage } from "@raycast/api";

export const saveAccessToken = async (accessToken: string) => {
  await LocalStorage.setItem("accessToken", accessToken);
};

export const getAccessToken = async () => {
  const accessToken = await LocalStorage.getItem<string>("accessToken");
  return accessToken?.toString();
};

export const saveUserID = async (id: string) => {
  await LocalStorage.setItem("userId", id);
};

export const getUserID = async () => {
  const accessToken = await LocalStorage.getItem("userId");
  return accessToken?.toString();
};

export const saveTag = async (tag: string) => {
  await LocalStorage.setItem("tag", tag);
};

export const getTag = async () => {
  const tag = await LocalStorage.getItem("tag");
  return tag?.toString();
};

setItemでストレージに、keyvalueでデータを格納します。

await LocalStorage.setItem(key, value)

getItemでkeyを指定してvalueを取り出します。

const value = await LocalStorage.getItem(key)

Navigation

Raycastでの拡張機能において、画面遷移を簡単に実装する仕組みとして、Navigationが用意されています。

useNavigationというhookを使うことで、コンポーネントの切り替え表示することができます。

まず下のようにコンポーエントを準備します。

components/ResultView.tsx
import { ActionPanel, Action, List } from "@raycast/api";
import { ArticleInfo } from "../types";

type ResultViewProps = {
  articles: ArticleInfo[];
  urls: string[];
};

export const ResultView = (props: ResultViewProps) => {
  return (
    <List isShowingDetail>
      {props.articles.map((article, i) => (
        <List.Item
          key={i}
          title={`${article.title}`}
          actions={
            <ActionPanel>
              <Action.CopyToClipboard title="All Articles Copy" content={props.urls.join("\n\n")} />
              <Action.CopyToClipboard title={"Copy Article URL"} content={article.url} />
              <Action.OpenInBrowser url={article.url} />
            </ActionPanel>
          }
          detail={
            <List.Item.Detail
              markdown={`![](${article.image})`}
              metadata={
                <List.Item.Detail.Metadata>
                  <List.Item.Detail.Metadata.Label title="title" text={props.articles[i].title} />
                  <List.Item.Detail.Metadata.Label title="url" text={props.articles[i].url} />
                  <List.Item.Detail.Metadata.Label
                    title="likes_count"
                    text={props.articles[i].likes_count.toString()}
                  />
                  <List.Item.Detail.Metadata.Label
                    title="stocks_count"
                    text={props.articles[i].stocks_count.toString()}
                  />
                  <List.Item.Detail.Metadata.Label
                    title="tags"
                    text={props.articles[i].tags.map((tag) => tag.name).join(" ")}
                  />
                </List.Item.Detail.Metadata>
              }
            />
          }
        />
      ))}
    </List>
  );
};

そこから画面遷移元でuseNavigationからpush関数を受け取理、pushの引数にコンポーネントを指定して、画面遷移をします。

search-qiita-article-by-tag.tsx
const { push } = useNavigation();

await push(<ResultView articles={setArticle} urls={urls} />);

今回は使用していないですが、useNavigationにはpop関数も用意されており、pop関数では現在コンポーネントを取り除き、前画面に戻ることができます。

関数 機能
push 新しいコンポーネントを描画(画面遷移)
pop 現在表示されてるコンポーネントを削除(前画面に戻る)

軽くCIを実装する

Raycastの拡張機能には、eslintとprettierでコードの静的解析と整形におけるフォーマットが決められています。

なので、prettierにおけるコードの整形をGithubActionsで自動化し、整形する際はactions-diff-pr-managementでPRを出すようにしました。

.github/workflows/lint.yml
name: lint
on:
  pull_request:
    types:
      - opened
      - reopened
      - synchronize
    branches:
      - develop
      - main

jobs:
  lint:
    runs-on: macos-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - name: checkout
        uses: actions/checkout@v4
      - name: setup node
        uses: actions/setup-node@v4
        with:
          node-version: '20.x'
      - name: run install
        run: npm install
      - name: format
        run: npm run fix-lint
      - name: pr-formatter
        uses: dev-hato/actions-diff-pr-management@v1
        with:
            github-token: ${{ secrets.GITHUB_TOKEN }}
            branch-name-prefix: fix-lint

yarnではなく、npmを使った理由

元々yarnでも動かせるのではないかなと思ったのですが、actions上では下のようにyarnを使ってるのに、npmで実際にどのパッケージのversionを入れたかを管理するpackage-lock.jsonを探してしまうというエラーに遭遇しました。

yarn run v1.22.22
$ ray lint --fix
missing package-lock.json. Run 'npm install' to generate the file
/home/runner/work/raycast-extension-search-qiita-article/raycast-extension-search-qiita-article/package.json
  15:15  warning  Command's title has to be Title Cased. Expected "Search Qiita Article by Tag"
ready  - validate package.json file
error  - validate package-lock.json
ready  - validate extension icons
ready  - run ESLint
ready  - run Prettier 3.4.2
 ›   Error: linting issues found (tip: try re-running with '--fix')
error Command failed with exit code 2.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
Error: Process completed with exit code 2.

ローカルではこのようなエラーが出なかったのにactionsではこうなった原因はわかりませんが、yarnではなくnpmで動かすことをお勧めします。

最後に

拡張機能は、Raycast/ExtensionsにPRを出すことで拡張機能を簡単に世に公開できます。
今回は個人使用なのでそこまではやりませんでしたが、便利な拡張機能ができた際にはPR出すことをお勧めします。

というかやり方を調べてない

ぜひ自分でも拡張機能を作ってみてください!

1
0
0

Register as a new user and use Qiita more conveniently

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?