🗒️

Miro APIでふせんや図形を取得する。Miro廃墟から資産を救い出す。

2023/05/02に公開

Miro廃墟問題

マイベストのデザイナーの 横田(Twitter)です。

掲題のとおり、Miroをエクスポートした話。

UXデザインやプロダクトマネジメントその他諸々でよく使われるMiro。
アイデアを発散したり、クラスタリングするのに適しているのでさまざまなところでアイデアや概念の集積所になっています。

そして実際には、うまくエクスポートする手段がないがために、ワイワイやったあと野ざらしになっている.ということがよくある。
おそらくすべてのスタートアップで起きている。

普及していませんが、あるデザイナーはこれをMiro廃墟問題とよんでいます。

Miroはハレの場である。いつしか廃墟化する。
-- Yasuhiro Yokota 1991 - Current

視覚的にとてもみやすいMiroですが、僕らが実務的に難儀することとして、Miroで整理するということはなにか1つの軸で整理されているということです。
構造を任意に組み替えてみたりすることはできません。

だとすると、祝祭の成果をうまく日常世界にもどしたい。慣れ親しんだNotionやSpreadsheetで、複数の軸でラベリングしたり、ビューを切り替えて使ったりしたい。

一応、GUIから付箋などを縦一列のcsvでエクスポートする機能はあるものの、それだと片手落ちな場合が多いです。
(MindMapソフトである、MindNodeはクリップボードに保存すると構造的でいい感じになってくれるのでおすすめ。)

視覚的にグルーピングされた200枚の付箋をNotion Databaseにうまく格納したい。

今回のユースケースは、デザインリサーチプロジェクトで何度も何度もグルーピングされた付箋たちをもとに、Notion Databaseに格納したいというものでした。
MiroのGUIでは、テキストをエクスポートすることはできても、肝心のグルーピングの概念をエクスポートすることはできません。しかも、3段階にグルーピングされているので、これを手作業で移すのは大変。

ということでMiro APIで大幅に省力化し、再現できるようにしたので、そのメモです。

※ Notion Databaseに特化した話はしません。

前提

手元で実行できればいいので、書き捨てのTypeScriptを作成し、node-tsで実行して、csvとして保存することにします。

セットアップ

yarn init -y
yarn add @mirohq/miro-api node-ts @types/node

Miro APIを使う

Get items on board

今回はボードIDを特定したうえでアイテムを取得する Get items on board を使ってみます。
あまり良く見てませんが、 v1(deprecated)とv2でだいぶが変更あったみたいなので、ドキュメント類は注意。
https://developers.miro.com/reference/get-items

Access Tokenの取得

Profile SettingでCreate new appすると、Access Tokenを取得することができます。今回は、App Credentialsは使いませんでした。

https://developers.miro.com/

APIの利用

@mirohq/miro-apiからimportしようとするとうまく動きませんでしたが、以下のようにすればMiroAPiにアクセスできます。

import {  MiroApi } from "@mirohq/miro-api/dist/api";
const api = new MiroApi(ACCESS_TOKEN || "");

へーと思ったことメモ

  • board_id は MiroのボードURLに含まれるIDです。まとめて取りたいなら getBoards()を使うこと。今回は直指定。
  • getItems()では、デフォルトで10件のitem(図形・付箋など)が取得できます。続きの位置はcursorというプロパティに入っているので、それを再リクエスト時のcursorにわたすと続きがやってきます。(紛らわしいのでnext_cursorとかいう名前で返してほしいものですが)
  • parentというプロパティがありますが、これはグループではなく、FrameのIDっぽいです。どうやら、Groupの概念はないらしい。
  • contentというプロパティに文字がはいっていますが、改行や太字はHTMLで表現されて返ってきます。<p><strong>太字</strong>ふつうの<br/>文字></p>。正規表現で削る。
  • getItems()では、filterとかsortとかいったエコなプロパティはありません。一旦全件とってこい仕様。ちなみに1000件程度でインターバルなしでとっても怒られませんでした。
  • itemオブジェクトは、GenericItemという型を使えます。付箋一枚の中身はこのような感じです。色んな種類のitemを清濁併せ呑むやつです。
  GenericItem {
    createdAt: 2023-04-30T04:12:29.000Z,
    createdBy: CreatedBy { id: '123456789', type: 'user' },
    data: WidgetDataOutput {
      shape: 'square',
      format: undefined,
      type: undefined,
      content: 'テキストテキスト',
      contentType: undefined,
      description: undefined,
      html: undefined,
      mode: undefined,
      previewUrl: undefined,
      providerName: undefined,
      providerUrl: undefined,
      title: undefined,
      url: undefined,
      assigneeId: undefined,
      dueDate: undefined,
      fields: undefined,
      owned: undefined,
      status: undefined,
      imageUrl: undefined,
      documentUrl: undefined
    },
    geometry: Geometry {
      height: 389.01141239675457,
      rotation: undefined,
      width: 339.5318906445358
    },
    id: '123456789',
    modifiedAt: 2023-04-30T05:03:46.000Z,
    modifiedBy: ModifiedBy { id: '123456789', type: 'user' },
    parent: undefined,
    position: Position {
      origin: 'center',
      x: -39444.606618882106,
      y: 14550.48618112686,
      relativeTo: 'canvas_center'
    },
    type: 'sticky_note'
  },

構造化的に取得するために実施した工夫

ワークショップの結果できあがったアイテムの構造はたまたまこんな感じでした。

  1. 大カテゴリ - round_rectangle
  2. 中カテゴリ - rectangle
  3. 小カテゴリ - 少し大きなsticky_note
  4. ふせん - sticky_note

全部で300アイテムあるので、なるべくそのままにしてとりこむことに。

構造を表現するために検討したアプローチは次の通り。

Miro上のGroupでまとめる

  • getItem()ではGroupという概念がない & 今回のユースケースでは3階層あるので諦め。

色でまとめる

  • getItem()では色が取得できないので諦め。(一応、全体としてはstyleという概念があるみたいです)

y座標縦順で頑張って表現

座標・サイズ・typeが取得できるので、ぜんぶ縦に並べてy座標順に取得する。そして、それぞれのプロパティごとでitemの種別をこちらで意味づけすればやりたいことはできる。(今回限りの仕様なので、後者の作業はspreadsheetでやった)

コード全体

//index.ts
import { GenericItem, MiroApi } from "@mirohq/miro-api/dist/api";
require('dotenv').config()
const {ACCESS_TOKEN} = process.env;
import fs from 'fs/promises';

const BOARD_ID = "ボードID";
const api = new MiroApi(ACCESS_TOKEN || "");

// cursor尽きるまで順繰り取得
const getItems = async (boardId: string, limit: number) => {
  let items: GenericItem[] = [];
  let cursor = undefined;
  do {
    const result = await api.getItems(boardId, {
      cursor: cursor,
    });
    if (result.body.data && result.body.data.length > 0) {
      items.push(...result.body.data);

      if (items.length >= limit) {
        break;
      }
      cursor = result.body.cursor;
    } else {
      console.log("No items found.");
      break;
    }
  } while (cursor !== undefined);

  return items.reverse();
};

(async function () {
  const items = await getItems(BOARD_ID, 10000); 
  // 空文字列のitemは不要なためフィルタ
  const filteredItems = items.filter((item: GenericItem) => item.data?.content !== '');
  //y 座標順に並べ替え
  const sortedItems = filteredItems.sort((a: GenericItem, b: GenericItem) => a.position?.y! - b.position?.y!);

  const headers = ['shape', 'type', 'width', 'height', 'content'];
  const data = sortedItems.map((item) => [
    item.data?.shape || '',
    item.type || '',
    item.geometry?.width?.toString() || '',
    item.geometry?.height?.toString() || '',
    item.data?.content?.replace(/<p>/g, '').replace(/<\/p>/g, '') || '',
  ]);

  // csvに書き出し
  const csv = [headers, ...data].map((row) => row.join(',')).join('\n');
  await fs.writeFile('data.csv', csv);
})();

これで気軽にMiroできる

で、廃墟問題といっておきながら、一方的に吸い上げてるだけなので、Miroが廃墟になる問題は解決できてないことにきづきました。いつしか双方向同期も‥?

さておき。
インターン生に頼むとしたら申し訳ないような苦行が30分で解決しました。
これでエクスポートどうしようとMiroでの作業をためらわずに済む道筋ができました。
itemの種別を特定するキーはshape/type/sizeなので、組織内でなにか共通ルールとして運用すれば、Miro <-> 他ツールの同期的運用が叶うかもしれません。

Discussion