【vscode拡張機能】qiitaに投稿する際に画像もアップロードしたい

# 前書き

先日、Qiitaのアップデートで、以下の機能が追加された。

[リリースノート - ファイルのアップロード機能を公開しました - Qiita](https://qiita.com/release-notes/2023-06-29)

これは何かというと、
今までQiitaの記事に画像を貼り付ける方法が、
- Qiitaの記事を書く画面に画像を一度貼り付ける
- 他の外部サービスに画像を置いて、そのURLを使う
のどちらかしかなかった(はず)。

しかし、このアップロード機能を使うことで、Qiitaの記事を書く画面に行って画像を貼り付けることなく、
簡単に画像をアップロードして、
画像のurlを取得したり、間違えてアップロードしてもすぐ削除できるようになった。[^1]

[^1]: あと何気に、１ヶ月の画像アップロード上限も確認できるようになった。今まではAPIからしか確認できなかった。

これはローカルで記事を書いている時に使えるのでは…！と思い、早速作成することにした。

# 拡張機能紹介

今回の記事の話は、過去に作成していたvscodeの拡張機能「[vscode_qiitaapi](https://marketplace.visualstudio.com/items?itemName=yuuyu.vscode-qiitaapi&ssr=false)」のアップデートに含めた内容になります。

マーケットプレイスはこちら：[vscode_qiitaapi - Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=yuuyu.vscode-qiitaapi&ssr=false#overview)
拡張機能自体のお話はこちら：[【vscode・qiita】vscodeからqiitaに投稿する拡張機能を作ってみた](https://qiita.com/items/a4a4d78b20bb1c649e9b)

# 方針決定

一番良い状態は、記事を書いた後、QiitaのAPIを使ってそのまま全自動で画像もアップロードすることだが、
残念ながらQiitaのAPIは画像のアップロードには対応していない(セキュリティなどの理由からとかなんとか…)。

なので、極力手間をかけずに、画像をアップロードする方法が理想になる。

今回のQiitaアップデートで追加された機能は、[アップロードするページのurlが決まっている](https://qiita.com/settings/uploading_images)ので、

コマンドが実行されたら、`1.このurlを開き`、`2.記事内にあるローカルパスで指定された画像(=アップロード対象の画像)をまとめてアップロードする`ことが出来れば、
お手軽にアップロードできそう。

そこで、今回のアップロード機能を使ったお手軽アップロード方法で、思いついたことを試してみる。

## クリップボードに自動挿入し、アップロードは手動で行う

1つ目に思いついた方法はこれ。
記事にあるアップロード対象の画像ファイル達を自動でクリップボードに入れることで、
ユーザーは、開かれたページに画像を貼り付け、「画像アドレスを一括コピー」するだけで、画像のアップロードができる。という方法。


・・・と、思って調べてみたものの、拡張機能の1つであるurl取得の際に使った`clipboardy`ライブラリはテキストベースしか扱えないらしく、
画像ファイル自体をコピーすることができなかった。
他のライブラリも調べたが、どうやらnode.jsからクリップボードにコピーできるのがテキストベースが限界のようで、画像ファイルをコピーすることはできないようだった。

```txt:ChatGPTの解答
nodejsで、ファイルをクリップボードにコピーする方法はありますか？

---

Node.jsでは、ファイルを直接クリップボードにコピーする方法は提供されていません。ただし、クリップボードへのコピーを実現するために、いくつかの方法があります。
1. ファイルの内容をテキストとしてクリップボードにコピーする方法：
 (略・「画像ファイル」と指定しなかったため、txtファイルの話が書かれていた)
1. ファイルのパスをクリップボードにコピーする方法：
 (略)
```

node.jsで出来ないのであれば、シェルコマンドなら出来るのでは？と思い調べてみるも、
意外にもシェルコマンドでもファイル情報をクリップボードにコピーすることはできなかった。
(なお、ChatGPTはmacで`pbcopy`、windowsで`clip`を進めてきたが、macの方は不可だった。windowsは未確認)

最終的にChatGPTさんは、`C++`、`Objective-C`、`Swift`などの言語を通じれば出来るかもしれないよ、という風に言われたが、
OS毎に対応させる必要が出てくることと、
そもそも拡張機能に含めることが出来なくなってくるので、この方法は断念することにした。

(これで出来るじゃん？という方法があれば教えていただけると嬉しいです。)

## クリップボードに入れる画像を用意し、コピー&ペーストを手動で行う

クリップボードにファイルをコピーすることが出来ないのであれば、クリップボードに入れる画像のリスト(ディレクトリ)を用意して、
コピーから手動で行う方法にする。

これは、ファイル操作だけを自動で行うので、`fs`を使えば実現できる。

今回は、この方法を使って、
- アップロード対象の画像uriを取得する
- 画像を1つのディレクトリにまとめる
- そのディレクトリを開ける
- ユーザーに画像をコピー&ペーストしてもらい、アップロードした画像のurlを取得する
- 画像のurlを記事に反映させる
といった流れで実装することにした。

# 画像一覧を作成する

アップロードする画像を取得するために、まずは画像一覧を作成する。
そのためには、markdownから画像のパスを取得する必要がある。

Qiitaでは、`![画像の説明](画像のパス)`という形と、`<img src=画像のパス>`の2種類で画像を貼り付けることができる。
今回実装する中で、`<img src=画像のパス>`の形が、src部分を簡単に取得できなかったため、一旦保留にすることにした。
そのため、今回は`![画像の説明](画像のパス)`の形で画像を貼り付けられていることを前提に実装する。

## Parserを使う

markdown内の`![]()`を、正規表現などで取得してもいいが、
コードブロックが存在していたり、エスケープされていたり、他に知らない書き方で画像ではないものがあったりするかもしれないので、
自作ではなく、既存のライブラリを使うことにした。

今回、markdownのパーサーとして3つのライブラリを調べ、用途に合うかどうかを確認した。

### markdown-it

[markdown-it - npm](https://www.npmjs.com/package/markdown-it)

まず1つ目。[Zenn](https://zenn.dev/)の記事表示にも使われているとかなんとか。

`MarkdownIt.parse`を呼ぶことで、token化されたmarkdownを取得できる。

ただこのtokenは、htmlに変換するためのもので、画像のパスを取得することは出来るものの、
そのパスが元々どこに記述されていたのか、もしくはtokenから元のmarkdownを取得する方法が出来なかった。

そのため、今回は使わないことにした。

### remark

[remark - npm](https://www.npmjs.com/package/remark)

2つ目。色々な機能があるらしい。

とりあえず使ってみようとしたところ、`esm package`になっていた。

vscodeの拡張機能開発が、どうやらまだesmに対応していないらしく、今回は諦めることにした。

[Enable consuming of ES modules in extensions · Issue #130367 · microsoft/vscode](https://github.com/microsoft/vscode/issues/130367)

### marked

[marked - npm](https://www.npmjs.com/package/marked)

3つ目。今回使用するもの。

こちらも`markdown-it`と同じく、`marked.lexer`を呼ぶことで、token化されたmarkdownを取得できる。
ただ、こちらのtokenはhtmlベースではなく、元のmarkdownを元にしたtokenであるため、
今回のケースでは使い勝手が良かった。

```ts:markdown.ts
import { marked } from 'marked';

const content = `
# title
![image1](./image1.png)
`;
const tokens = marked.lexer(content);
console.dir(tokens, { depth: null });
/*
[
  { type: 'space', raw: '\n' },
  {
    type: 'heading',
    raw: '# title\n',
    depth: 1,
    text: 'title',
    tokens: [ { type: 'text', raw: 'title', text: 'title' } ]
  },
  {
    type: 'paragraph',
    raw: '![image1](./image1.png)\n',
    text: '![image1](./image1.png)',
    tokens: [
      {
        type: 'image',
        raw: '![image1](./image1.png)',
        href: './image1.png',
        title: null,
        text: 'image1'
      }
    ]
  },
  links: [Object: null prototype] {}
]
*/
```

このように、`tokens`の中に、`type: 'image'`というものがあるので、これを取得すれば画像のパスを取得できる。
また、`raw`が元のmarkdownの文字列なので、これを全て結合すれば、元のmarkdownを取得できる。
(なお、これを自動的に結合して元のmarkdownを取得する方法はないので、セルフ結合する必要がある。)

## 画像ディレクトリを作成する

画像をコピーするためのディレクトリを作成する。

node.jsであれば`fs`を使えば簡単に作成できるが、vscodeの拡張機能を開発する場合、`vscode.workspace.fs`を使うことも出来る。

[VS Code API | Visual Studio Code Extension API](https://code.visualstudio.com/api/references/vscode-api#workspace)

普通の`fs`と何が違うの？というと、ローカルディスクだけでなく、リモートの場所にあるファイルも操作できるようになる、らしい。
vscodeがリモートで動いていても動作できるようになっていると思われる。

ただ、全ての`fs`機能が拡張されているわけではなく、`vscode.workspace.fs`では無い機能も存在するもよう。

### ディレクトリの存在確認

`fs`では、`fs.existsSync`でディレクトリの存在確認ができるが、`vscode.workspace.fs`には存在しない。
そのため、`vscode.workspace.fs.stat`を使って、ディレクトリの存在確認を行う(くらいしか思いつかなかった)。

```ts:fs.ts
import { vscode } from 'vscode';

async function existsSync(path: vscode.Uri): Promise<boolean> {
  try {
    await vscode.workspace.fs.stat(path);
    return true;
  } catch (error) {
    if (error instanceof Error && error.name === 'EntryNotFound (FileSystemError)') {
      return false;
    }
    throw error;
  }
}
```

`vscode.workspace.fs.stat`は、ファイル情報を取得するためのもので、ファイルが存在しない場合は例外を投げる。
今回は、その例外が`EntryNotFound (FileSystemError)`であることを確認して、ファイルが存在しない場合は`false`を返すようにした。

### ディレクトリの作成

`vscode.workspace.fs.createDirectory`を使うことで、ディレクトリを作成できる。
ちなみに削除は`vscode.workspace.fs.delete`を使うことが出来る。再起的に削除する場合は、第二引数に`{ recursive: true }`を指定する。

今回は、`uploadImages`という名前のディレクトリを、投稿する記事と同じディレクトリ上に作成する。

```ts:fs.ts
// 作業ディレクトリのパスを取得する
const workspacePath = vscode.window.activeTextEditor?.document.uri;
// アクティブエディタのディレクトリに「uploadImages」という名前のディレクトリを作成
const uploadImagesDir = workspacePath.with({
  path: `${workspacePath.path.substring(0, workspacePath.path.lastIndexOf('/'))}/uploadImages`,
});
// ディレクトリが存在しない場合は作成する
if (!(await existsSync(uploadImagesDir))) {
  await vscode.workspace.fs.createDirectory(uploadImagesDir);
}
```

## 画像一覧を作成する

画像ディレクトリを作成したら、そこに画像をコピーする。
画像自体は、ローカルパス上に存在し、かつコピペ後には一覧を削除する方針にするため、コピーではなくハードリンク[^hardlink]による複製を行う方が効率が良い。

[^hardlink]: 普段使われるショートカットはシンボリックリンクだが、今回はハードリンクを使う。シンボリックリンクは画像情報を持たないため、そのままコピペしてもqiita側に画像を貼り付けることが出来ない。

しかし、`vscode.workspace.fs`にはハードリンクを作成する機能が無いため、`fs`を使う必要がある。
また、ハードリンクは、同じディスク上でしか作成できないため、ユーザーの環境によっては動作しない可能性がある。
そのため、今回はデフォルトをハードリンクにして、ユーザーが設定で通常コピーでも変更できるようにする。

```ts:fs.ts
import { vscode } from 'vscode';
import fs from 'fs';

async function copyFile(src: vscode.Uri, dest: vscode.Uri, hardlink: boolean): Promise<void> {
  if (hardlink) {
    await fs.promise.link(src.fsPath, dest.fsPath);
  } else {
    await vscode.workspace.fs.copy(src, dest);
  }
}
```

これで、あとは`marked.lexer`で取得した画像のパスを元に、画像をコピーするだけ。

```ts:markdown.ts
function makeImageList(localPathList: {uri: vscode.Uri, fileName: string}[], uploadImagesDir: vscode.Uri, hardlink: boolean): Promise<void[]> {
  return Promise.all(
    localPathList.map((localPath) => {
      const uploadPath = uploadImagesDir.with({
        path: `${uploadImagesDir.path}/${localPath.fileName}`,
      });
      return copyFile(localPath, uploadPath, hardlink);
    }),
  );
}
```

これで、画像一覧を作成することが出来た。

# 画像一覧とQiitaの画像アップロードページを開く

画像一覧を作成したら、そのディレクトリを開く。

vscodeの拡張機能では、`vscode.env.openExternal`を使うことで、外部のページを開くことが出来る。

この方法で、画像一覧ディレクトリのFinder(explorer)と画像アップロードページを両方とも開くことが出来る。

```ts:open.ts
import { vscode } from 'vscode';

async function openUploadImagesDir(uploadImagesDir: vscode.Uri): Promise<void> {
  await vscode.env.openExternal(uploadImagesDir);
}

async function openQiitaUploadPage(): Promise<void> {
  await vscode.env.openExternal(vscode.Uri.parse('https://qiita.com/settings/uploading_images'));
}
```

## 余談

ディレクトリの開き方について、`vscode.env.openExternal`を使う方法は、この記事を書いている時にcopilotの予測入力で知る(urlは知っていた)。

当初コードを書いていた時は、copilotに`vscode.commands.executeCommand('revealFileInExplorer', uploadImagesDir);`を勧められたのだが、
`revealFileInExplorer`はmacOSだと`revealFileInFinder`になるし、
2引数目のディレクトリの指定は意味をなさないため、
`execSync`くらいしか方法がないと思っていた。

わざわざそんな複雑なことをする必要はなかったらしい。

# 「アドレスを一括コピー」から画像のurlを書き換える

画像一覧を作成したら、ユーザーに画像をコピーしてもらい、画像のurlを取得する。

url自体は、markdownの画像表示がそのまま返ってくるので、分割して画像のurlを取得する。

```ts:upload.ts
function extractImageStrings(str: string): { image: string, alt: string; url: string }[] {
  const regex = /!\[(.*?)\]\((.*?)\)/g;
  const matches = str.matchAll(regex);
  const result: { image: string, alt: string; url: string }[] = [];

  for (const match of matches) {
    const [image, alt, url] = match;
    result.push({ image, alt, url });
  }

  return result;
}
```

~~markdown上のcopilotの方が綺麗なコードを書いてくれる…。これがコメントの必要性というものか…。~~

あとは、先ほどの`token`の、`type: 'image'`の所を、`extractImageStrings`で取得した画像のurlに書き換えれば、
画像のurlを更新することが出来る。

これで、ローカルの画像をQiitaにアップロードすることが出来た。

# 終わりに

今回は、Qiitaの画像アップロード機能を使って、ローカルの画像をQiitaにアップロードする機能を作成した。

今回実際に作成しているものは、別にvscodeの拡張機能である必要はないので、cliでも良さそう。

そういえば最近、[Qiita CLI(ベータ版)](https://blog.qiita.com/qiita-cli-beta-release/)なるものが出来たらしいですね・・・。