Open9

Chrome のコンテキストメニューから Notion API を呼び出す

nikogolinikogoli

0. モチベ

Web上で記事などを読んでいるとき、「この数行の文章をメモしておきたい…」と思うことがある。もちろん別窓でテキストエディタなどを開いてコピペすればいいのだが、文章を読んでいる途中はできるだけそのページに意識を留めておきたい。

で、chrome 拡張を使ってコンテキストメニュー (右クリックで出るやつ) に「Notion に追加」ボタンを加えることで、「今読んでいるページから目を離さずに Notion に特定の文章をコピペする」ことを実現しよう、という話。

nikogolinikogoli

1. 具体的には

Notion API は chrome 拡張から直接呼び出すことができない(はず)なので、Deno Deploy にプロキシサーバもどきを置いてそこ経由で API を呼ぶ。

  1. chrome 拡張を作り、テキスト選択時のコンテキストメニューに独自の項目が出るようにする
  2. この「独自の項目」が押されるとページの URL と選択テキストを body に入れて fetch("https://xxxx.deno.dev/create_memo")するように、chrome 拡張で onclick を設定する
  3. Deno Deploy 側ではリクエストをチェックし、ページの URL と選択テキストから callout を作ってメモ用の Notion のページを対象にして notion.blocks.children.append() する
nikogolinikogoli

Known Issues

  • 複数パラグラフが1つのパラグラフ block にまとめられてしまう
  • callout の第1文は notion 上で取り回しづらい → パラグラフに入れて第1文は URL ?
  • 現状、コンテキストメニューのボタンを押した結果を Active なタブ側で表示できない
nikogolinikogoli

その他

  • 短文コメントを差し込んで POST したい感もある
  • 1つのページを読みながら適宜メモする用途としては、なんか微妙にこれじゃない感がある
    同じ URL が並ぶのは無駄だし、タグ付けみたいなもっと分類や注釈機能のほうがほしい
    要するに、「メモ」で終わって「(操作のための) 情報の切り出し」までは至らない感じ
nikogolinikogoli

コード

chrome 拡張

manifest.json
{
  "name": "custom context menu",
  "description": "Custom Context Menu for Chrome",
  "version": "0.1.0",
  "manifest_version": 3,
  "background": {
    "service_worker": "background.js",
    "type": "module"
  },
  "permissions": ["contextMenus"]
}
.env
{
    "URL_PREFIX": "https://xxxx.deno.dev",
    "USER_TOKEN": "Deno Deploy 側で突き合わせてアクセスチェックを行うための文字列"
}
background.js
function onClicked(){
  const func = async (info, _tab) =>{
    const { pageUrl, selectionText } = info

    const url = chrome.runtime.getURL('.env')
    const env_obj = await fetch(url)
      .then(async response => await response.text() )
      .then( tx => JSON.parse(tx) )
    const { URL_PREFIX, USER_TOKEN } = env_obj

    const headers = new Headers()
    headers.append("Authorization", `Bearer ${USER_TOKEN}`)

    const body_data = {
      org_url: pageUrl,
      text: selectionText
    }
    
    await fetch(`${URL_PREFIX}/create_memo`, {
      credentials: "include",
      method: 'POST',
      headers: headers,
      body: JSON.stringify(body_data),
    })
    .then( async res => {
      if (res.status == 500){ // sdk のエラーメッセージを流用しているので、ここだけ body が JSON
        const response_message = "Calling API failed. Please check message in console."
        const e_message = await res.json()
        console.log(response_message)
        console.log(e_message)
      } else {
        const response_message = await res.text()
        console.log(response_message)
        console.log(res)
      }
    })
  }
  return func
}

chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.create({
    "id": "custom context",
    "title": "Sample Context Menu",
    "type": "normal",
    "contexts": ["selection"]
  });
});

chrome.contextMenus.onClicked.addListener( onClicked() )
nikogolinikogoli

Deno Deploy

環境変数として NOTION_TOKEN, USER_TOKEN, MEMOPAGE_ID の3つを設定している。

types.ts
export type ResultBase = {
  ok: true,
  data: Record<string, string>
} | {
  ok: false,
  e_message: Record<string, string>
}

export type RequestForMemo = {
  org_url: string,
  text: string,
}

export function isRequestForMemo(arg: Record<string, unknown>): arg is RequestForMemo {    
  const { org_url, text } = arg
  return ( typeof org_url == "string" && typeof text == "string" )
}

export const HEADER_OPS = {
  'Access-Control-Allow-Method':  'OPTIONS, POST, GET',
  'Access-Control-Allow-Credentials': 'true',
  'Access-Control-Allow-Headers': 'Content-Type, Origin, Authorization',
}
funcs.ts
import { RequestForMemo, ResultBase } from "./types.ts"
import { to_richtx } from "https://pax.deno.dev/nikogoli/notion-helper/mod.ts"
import { Client } from "https://deno.land/x/notion_sdk@v1.0.4/src/mod.ts"
import { BlockObjectRequest } from "https://deno.land/x/notion_sdk@v1.0.4/src/api-endpoints.ts"


async function call_insert_api(
  block_data: BlockObjectRequest,
):Promise<ResultBase> {
  const notion = new Client({auth: Deno.env.get("NOTION_TOKEN")})
  return await notion.blocks.children.append({
    block_id: Deno.env.get("MEMOPAGE_ID") ?? "",
    children: [block_data],
  })
  .then( _res => {
    return {ok: true as const, data: {data: ""} }
  } )
  .catch((e: Record<string, string>) =>{
    console.error(e)
    return {ok: false as const, e_message: e }
  })
}


export async function insert_memo(
  memo_data: RequestForMemo,
): Promise<ResultBase>{
  const { org_url, text } = memo_data
  const quate_block_data: Required<BlockObjectRequest> = {
    object: "block",
    type: "quote",
    quote: { rich_text: to_richtx("text", org_url) }
  }
  const block_data: Required<BlockObjectRequest> = {
    object: "block",
    type: "callout",
    callout: {
      rich_text: to_richtx("text", text),
      color: "default",
      children: [quate_block_data]
    }
  }
  return await call_insert_api(block_data)
}
serve.ts
import { serve, PathParams } from "https://deno.land/x/sift@0.6.0/mod.ts";
import { ConnInfo } from "https://deno.land/std@0.155.0/http/server.ts";

import { HEADER_OPS, isRequestForMemo } from "./types.ts"
import { insert_memo } from "./funcs.ts"


function check_origin(
  headers: Headers,
  options: Record<string,string>,
){
  const origin = headers.get("origin")
  return {
    ...options,
    'Access-Control-Allow-Origin': origin ?? "null"
  }
}


function is_auth_ok(headers: Headers){
  const auth_head = headers.get("Authorization")
  return (auth_head?.split(" ")[1] == Deno.env.get("USER_TOKEN"))
}


serve({
  "/": (_request: Request) => {
    return new Response("Hellow", {headers: HEADER_OPS, status: 200})
  },

  "/create_memo": async (
    request: Request,
    _connInfo: ConnInfo,
    _params: PathParams
  ) => {
    const headers = check_origin(request.headers, HEADER_OPS)
    if (request.method == "OPTIONS"){
      return new Response("options", {headers, status: 200})
    }
    if (!is_auth_ok(request.headers)){
      return new Response("Unauthorized", {headers, "status" : 401 })
    }
    const data = await request.json()
    if (isRequestForMemo(data)){
      return await insert_memo(data).then( result => {
        if (result.ok){
          return new Response("Create new Memo!!", {headers: headers, status: 200})
        } else {
          return new Response(JSON.stringify(result.e_message), {headers, "status" : 500 })
        }
      })
    } else {
      return new Response("Bad params", {headers, "status" : 400 })
    }
  },

  404: (request: Request) => {
    const headers = check_origin(request.headers, HEADER_OPS)
    if (request.method == "OPTIONS"){
      return new Response("options", {headers: headers, status: 200})
    }
    return new Response("Not found", {headers: headers, status: 404})
  },
})