概要
知見管理ソフトのObsidianとSNSのMisskeyを繋げるプラグインであるMisskey Connectorを作った。これを使うことでObsidianからMisskeyにノートを投稿したりObsidianにMisskeyのノートを埋め込めるようになる。このノートにはこのプラグインが解決する問題とコードを書いた。
環境
名前 | バージョン等 |
---|---|
Obsidian | v1.5.11 |
Misskey | 2024.3.1-io.4a |
Misskey Connector | 2.1.1 |
設計
やりたいこと
- Misskeyにある有用なノートを自分の手元にある知見管理アプリで管理しておきたい。
- Obsidianから今なにやってるかとかを共有したい。
やること・やらないこと
埋め込みと投稿は大前提として。
- 日本語の説明
- Misskeyの主要ユーザーは日本人1なので
- ファイル名の秘匿化
- Misskey上でこの機能を実現する個人開発のプラグインに対して肯定的な反応が多数寄せられてたので
- 簡単な認証方式
- いきなりAPIキーを用意しろとか言われても怖いしだるいので
- アップロードできるファイル拡張子の制限
- PDF間違えて上げちゃったとかいう事故が起こらんように
- シンプルさ
- NoteTweet🐦 for Obsidianのように特定のノートへ特定の書式で書くと投稿できるみたいなのは実装しない。というもの、コピペはだるいからMisskeyに投稿するけど日記って意味では本来Obsidianに残しときたいという欲求を満たすために作ったから。投稿は引用として埋め込めるんだからノートしたのをあとから埋め込めばいい。
実装
内部の設定
interface MisskeyPluginSettings {
accounts: Account[];
}
interface Account {
isSelected: boolean;
memo: string;
domain: string;
prevText: string;
postText: string;
isFileNameHidden: boolean;
visibility: "public" | "home" | "followers"; // "specified"はサポートしない
uploadAllowedList: string[];
embedFormat: string;
accountToken: null | string;
}
const createDefaultAccount = (): Account => ({
isSelected: false,
memo: "",
domain: "",
prevText: "",
postText: "",
isFileNameHidden: false,
visibility: "public",
uploadAllowedList: ["png", "jpg", "jpeg", "gif", "bmp", "svg", "mp3", "webm", "wav", "m4a", "ogg", "3gp", "flac", "mp4", "webm", "ogv"],
embedFormat: "html",
accountToken: null,
});
const selectedAccount = createDefaultAccount();
selectedAccount.isSelected = true;
const DEFAULT_SETTINGS: Partial<MisskeyPluginSettings> = {
accounts: [selectedAccount],
}
設定画面
コードが長いし見た方が理解しやすいので画像だけ。
ファイルをMisskeyのドライブに投稿する
これは今思ったけどノートの内容から画像を探すのとアップロードするの別にした方がいいよね。
/**
* noteから画像をアップロードし、その画像のIDを返す
* @param note 投稿内容
* @private
*/
private async uploadFileToMisskey(note: string): Promise<string[]> {
// ![[path]]をすべて検索する
const matches = note.matchAll(/!\[\[(.*?)]]/g);
const imageIDList: string[] = [];
await Promise.all(Array.from(matches).map(async match => {
// data:やhttp(s)://で始まるURLはアップロードしない
// 別にdataはサポートしてもよさそうだけど使ってる人いるかな。リクエスト来るまではとりあえず無効にしとく
const fileName = match[1];
if (/^(data:|https?:\/\/)/.test(fileName)) {
return
}
const selectedAccount = this.getSelectedAccount();
let targetFile = null; // 探しているファイルへの参照を保持するための変数
// もし![[fileName]]がファイルパスそのものをさしていた場合
const file = this.app.vault.getAbstractFileByPath(fileName);
if (file instanceof TFile) {
targetFile = file;
} else {
// NOTE: Avoid iterating all files to find a file by its path
// https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines#Avoid+iterating+all+files+to+find+a+file+by+its+path // pathからファイルを探すならこの方法は避けるように書いてある。ただ`![[filePath]]`記法があった時に、ファイルはディレクトリの下にあるがfilePathはフルパスでない場合がある。
// そのため、`filePath`がファイルの場所を直接指していない場合、この方法でしかファイルを探せないと自分は理解している。
for (const file of this.app.vault.getFiles()) {
if (file.name === fileName) {
targetFile = file;
break;
}
}
if (targetFile === null) {
new Notice(i18n.t("fileNotFound") + fileName);
return;
}
}
// ファイルの拡張子を取得
const extension = targetFile.extension;
// アップロードが許可されている拡張子かチェック
if (!selectedAccount.uploadAllowedList.includes(extension)) {
new Notice(i18n.t("thisFileTypeIsNotAllowed") + fileName);
return;
}
// ファイルを読み込んで、Misskeyにアップロードする
new Notice(i18n.t("uploadingImage"))
const fileContent = await this.app.vault.readBinary(targetFile);
const domain = selectedAccount.domain;
const token = selectedAccount.accountToken;
if (token === null) {
new Notice("アクセストークンが設定されていません。設定画面から設定してください。");
return;
}
const blob = new Blob([fileContent], {type: "application/octet-stream"});
const formData = new FormData();
formData.append('i', token);
formData.append('file', blob, selectedAccount.isFileNameHidden ? new Date().toISOString() : fileName);
// 画像をアップロード
try{
const data = await (await fetch(`https://${domain}/api/drive/files/create`, {
method: "POST",
body: formData
})).json();
new Notice(data.error ? ("Error:" + data.error.message) : ("画像をアップロードしました。"));
imageIDList.push(data.id);
} catch (error) {
new Notice(i18n.t("imageCannotBeUploaded") + error);
return;
}
}));
return imageIDList;
}
Misskeyへノートを投稿する
/**
* Misskeyへノートを投稿する。
* @param note 投稿内容
* @param noteVisibility 投稿の公開範囲。ただし"specified"はサポートしない
* @param fileIds 添付ファイルのドライブにあるID。省略可能
* @private
*/
private async postToMisskey(note: string, noteVisibility: "public" | "home" | "followers",
fileIds: string[] = []): Promise<void> {
const domain = this.getSelectedAccount().domain;
const token = this.getSelectedAccount().accountToken;
if (token === null) {
new Notice("アクセストークンが設定されていません。設定画面から設定してください。");
return;
}
// 投稿の前部分と後部分を取得
const prevText = this.getSelectedAccount().prevText;
const postText = this.getSelectedAccount().postText;
let bodyObject: object = {
i: token,
text: prevText.replace("\\n", "\n") + note + postText.replace("\\n", "\n"),
visibility: noteVisibility
};
// fileIdsに空配列を渡すとエラーが出るので、空の場合は省略
if (fileIds.length > 0) {
bodyObject = {
...bodyObject,
fileIds: fileIds
}
}
const urlParams: RequestUrlParam = {
"url": `https://${domain}/api/notes/create`,
"method": "POST",
"headers": {
"Content-Type": "application/json"
},
"body": JSON.stringify(bodyObject)
};
try {
const data = (await requestUrl(urlParams)).json;
new Notice(data.error ? ("Error:" + data.error.message) : ("ノートを送信しました。"));
return data;
} catch (error) {
new Notice(i18n.t("noteCannotBeSend") + error);
}
URLからMisskeyのノートを取得し、引用形式で返す
/**
* Misskeyのノートを取得し、引用形式で返す
* @param urls ノートのURL。複数指定可能
* @param isResolveRenote リノートを解決するかどうか
*/
private async quoteFromMisskeyNote(urls: string[] | string, isResolveRenote=true): Promise<string[][]> {
const embedFormat = this.getSelectedAccount().embedFormat;
if (typeof urls === "string") {
urls = [urls];
}
const notes: string[][] = [];
for (const url of urls) {
// URLの形式が正しいかチェック
const regex = /https?:\/\/([a-zA-Z0-9.-]+)\/notes\/([a-zA-Z0-9]+)(?=[^a-zA-Z0-9]|$)/g;
let match;
if ((match = regex.exec(url)) === null) {
new Notice(i18n.t("urlIsNotCorrect") + url);
continue;
}
const misskeyDomain = match[1];
const noteId = match[2];
let bodyObject: object = {
noteId: noteId
};
// URLが現在のプロフィールのドメインと一緒だった場合、アクセストークンを送る。
// これは公開範囲が限定されたノートを取得するため
if (this.getSelectedAccount().domain === misskeyDomain) {
bodyObject = {
...bodyObject,
"i": this.getSelectedAccount().accountToken
};
}
const urlParams: RequestUrlParam = {
"url": `https://${misskeyDomain}/api/notes/show`,
"method": "POST",
"headers": {
"Content-Type": "application/json"
},
"body": JSON.stringify(bodyObject)
};
const response = await requestUrl(urlParams).catch(
(error) => {
new Notice(i18n.t("noteCannotBeQuoted") + url);
return;
}
);
if (response === undefined) {
continue;
}
// Misskeyのドメインを網羅することはできないので200が帰ってきたらMisskeyのノートとみなす
if (response.status !== 200) {
new Notice(i18n.t("noteCannotBeQuoted") + url);
continue;
}
const data = await response.json;
// ノートには本文がなく、画像だけが添付されている場合がある。
let note = data.text ? data.text + "\n" : "";
// 添付ファイルがある場合は対象のURLを取得し、メディアとして埋め込む
for (const file of (data.files || [])) {
if (embedFormat === "markdown") {
note += `\n`
} else if (file.type.startsWith("image")){
note += `<img src="${file.url}" alt="${file.name}">\n`;
} else if (file.type.startsWith("video")) {
note += `<video controls><source src="${file.url}"></video>\n`;
} else if (file.type.startsWith("audio")) {
note += `<audio controls src="${file.url}"></audio>\n`;
} else {
note += `[${file.name}](${file.url})\n`
}
}
// 引用元のノートがある場合、それを引用として表示する
if (isResolveRenote && data.renote?.id){
const renote = (await this.quoteFromMisskeyNote(`https://${misskeyDomain}/notes/${data.renote.id}`, false))
if (renote.length){
note += `
> RN: > > ${renote[0][1]}
`;
}
}
// 引用元のユーザー情報を表示するための処理
note += "\n";
// 初期アイコンはidenticon(一度移動する必要がある)なので、それ以外の場合のみアイコンを埋め込む
if (new URL(data.user.avatarUrl).pathname.split('/')[1] !== 'identicon') {
const iconSize = 20;
if (embedFormat === "markdown") {
note += ``;
} else if (embedFormat === "html") {
note += `<img src="${data.user.avatarUrl}" alt="${data.user.username}" width="${iconSize}">`;
}
}
// data.user.nameはバージョンによってはnullの場合がある。少なくともv2023.11ではnull。空文字にしとく
note += ` ${data.user.name || ""}[`+ i18n.t("openOriginalNote", { username: data.user.username}) +`](${url})`;
note = note.split("\n").map((line) => "> " + line).join("\n");
const pattern = /:(\w+):/g;
// 絵文字を走査する。ユーザー名に絵文字が含まれている場合があるためこの位置になる
while ((match = pattern.exec(note)) !== null) {
const emojiName = match[1];
const url = `https://${misskeyDomain}/api/emoji?name=${emojiName}`;
const response = await requestUrl({
"url": url,
"method": "GET"
}).catch((error) => {
new Notice(i18n.t("emojiCannotBeFetched") + emojiName);
return;
});
if (response?.status != 200){
new Notice(i18n.t("emojiCannotBeFetched") + emojiName);
continue;
}
const data = await response.json;
const emojiSize = 20;
if (embedFormat === "markdown") {
note = note.replace(`:${emojiName}:`, ``);
} else if (embedFormat === "html") {
note = note.replace(`:${emojiName}:`, `<img src="${data.url}" alt="${emojiName}" width="${emojiSize}">`);
}
}
note = "\n" + note + "\n";
notes.push([url, note]);
}
// 複数個ノートがある場合、それらは別々の引用として表示されるべき
for (let i = 0; i < notes.length - 1; i++) {
notes[i][1] += "\n";
}
return notes;
}
Misskeyに投稿するコマンド
this.addCommand({
id: "post-to-misskey",
name: "Post the current line to Misskey",
editorCallback: async (editor) => {
if (!this.isSettingsValid()) { return; }
new Notice(i18n.t("postingToMisskey"))
const text = editor.getLine(editor.getCursor().line);
const imageIDs = await this.uploadFileToMisskey(text);
// ![[path]]を削除。拡張子による除外などでアップロードされていないファイルがある可能性があるものの
// この記法はObsidianの独自記法なので削除しても問題ないと判断
const pattern = /!\[\[.*?]]/g;
await this.postToMisskey(text.replace(pattern, ''),
this.getSelectedAccount().visibility, imageIDs);
},
});
現在の行からURL探して引用するコマンド
this.addCommand({
id: "embed-misskey-note",
name: "Embed a Misskey note",
editorCallback: async (editor) => {
if (!this.isSettingsValid()) { return; }
new Notice(i18n.t("collectingNotes"))
const text = editor.getLine(editor.getCursor().line);
// URLを見つけるための正規表現パターン
const urlPattern = /(\b(https?):\/\/[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%=~_|])/ig;
// テキストからURLを抽出
const urls = text.match(urlPattern);
if (!urls) { return; }
// URLからMisskeyのノートを取得
const notes = await this.quoteFromMisskeyNote(urls);
for (const [url, note] of notes) {
const replacedText = editor.getLine(editor.getCursor().line).replace(url, note);
editor.setLine(editor.getCursor().line, replacedText);
}
new Notice(i18n.t("noteQuoted"))
},
});
参考文献
Misskey.ioのAPIドキュメント Obsidianの開発者用ドキュメント