diff --git a/rest/page-data.ts b/rest/page-data.ts index 8eec51b..e0bee80 100644 --- a/rest/page-data.ts +++ b/rest/page-data.ts @@ -48,11 +48,7 @@ export async function importPages( ); formData.append("name", "undefined"); - if (!csrf) { - const result = await getCSRFToken(sid); - if (!result.ok) return result; - csrf = result.value; - } + csrf ??= await getCSRFToken(sid); const path = `https://scrapbox.io/api/page-data/import/${project}.json`; const res = await fetch( diff --git a/rest/replaceLinks.ts b/rest/replaceLinks.ts new file mode 100644 index 0000000..e8ead4a --- /dev/null +++ b/rest/replaceLinks.ts @@ -0,0 +1,88 @@ +import type { + NotFoundError, + NotLoggedInError, + NotMemberError, +} from "../deps/scrapbox.ts"; +import { + cookie, + getCSRFToken, + makeCustomError, + tryToErrorLike, +} from "./utils.ts"; +import type { Result } from "./utils.ts"; + +/** `replaceLinks`の認証情報 */ +export interface ReplaceLinksInit { + /** connect.sid */ sid: string; + /** CSRF token + * + * If it isn't set, automatically get CSRF token from scrapbox.io server. + */ + csrf?: string; +} + +/** 指定したproject内の全てのリンクを書き換える + * + * リンクと同一のタイトルは書き換わらないので注意 + * - タイトルも書き換えたいときは/browser/mod.tsの`patch()`などで書き換えること + * + * @param project これで指定したproject内の全てのリンクが置換対象となる + * @param from 置換前のリンク + * @param to 置換後のリンク + * @param options connect.sidなど + * @return 置換されたリンクがあったページの数 + */ +export async function replaceLinks( + project: string, + from: string, + to: string, + init?: ReplaceLinksInit, +): Promise< + Result< + number, + NotFoundError | NotLoggedInError | NotMemberError + > +> { + const path = `https://scrapbox.io/api/pages/${project}/replace/links`; + const sid = init?.sid; + const csrf = init?.csrf ?? await getCSRFToken(sid); + + const res = await fetch( + path, + { + method: "POST", + headers: { + "Content-Type": "application/json;charset=utf-8", + "X-CSRF-TOKEN": csrf, + ...(sid + ? { + Cookie: cookie(sid), + } + : {}), + }, + body: JSON.stringify({ from, to }), + }, + ); + + if (!res.ok) { + const value = tryToErrorLike(await res.text()) as + | false + | NotFoundError + | NotLoggedInError + | NotMemberError; + if (!value) { + throw makeCustomError( + "UnexpectedError", + `Unexpected error has occuerd when fetching "${path}"`, + ); + } + return { + ok: false, + value, + }; + } + + // messageには"2 pages have been successfully updated!"というような文字列が入っているはず + const { message } = (await res.json()) as { message: string }; + return { ok: true, value: parseInt(message.match(/\d+/)?.[0] ?? "0") }; +} diff --git a/rest/utils.ts b/rest/utils.ts index f337bf9..f8a9a81 100644 --- a/rest/utils.ts +++ b/rest/utils.ts @@ -1,4 +1,12 @@ import type { ErrorLike } from "../deps/scrapbox.ts"; +import { getProfile } from "./profile.ts"; + +// scrapbox.io内なら`window._csrf`にCSRF tokenが入っている +declare global { + interface Window { + __csrf?: string; + } +} /** HTTP headerのCookieに入れる文字列を作る * @@ -12,17 +20,12 @@ export type Result = { ok: true; value: T } | { ok: false; value: E }; * @param sid - connect.sidに入っている文字列。不正な文字列を入れてもCSRF tokenを取得できるみたい */ export async function getCSRFToken( - sid: string, -): Promise> { - const res = await fetch("https://scrapbox.io/api/users/me", { - headers: { Cookie: cookie(sid) }, - }); - if (!res.ok) { - const value = (await res.json()) as ErrorLike; - return { ok: false, value }; - } - const { csrfToken } = (await res.json()) as { csrfToken: string }; - return { ok: true, value: csrfToken }; + sid?: string, +): Promise { + if (window.__csrf) return window.__csrf; + + const user = await getProfile(sid ? { sid } : undefined); + return user.csrfToken; } // cf. https://blog.uhy.ooo/entry/2021-04-09/typescript-is-any-as/#%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E5%AE%9A%E7%BE%A9%E5%9E%8B%E3%82%AC%E3%83%BC%E3%83%89%E3%81%AE%E5%BC%95%E6%95%B0%E3%81%AE%E5%9E%8B%E3%82%92%E3%81%A9%E3%81%86%E3%81%99%E3%82%8B%E3%81%8B