概要
知見管理ソフトの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から今なにやってるかとかを共有したい。
やること・やらないこと
埋め込みと投稿は大前提として。
日本語の説明
ファイル名の秘匿化
簡単な認証方式
いきなり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 += `![${ file . name }](${ file . url }) \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 += `![${ data . user . username }|${ iconSize }](${ data . user . avatarUrl })` ;
} 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 }:` , `![${ emojiName }|${ emojiSize }](${ data . url })` );
} 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の開発者用ドキュメント