diff --git a/browser/dom/_internal.test.ts b/browser/dom/_internal.test.ts index 39bbd01..20ef6b0 100644 --- a/browser/dom/_internal.test.ts +++ b/browser/dom/_internal.test.ts @@ -1,4 +1,4 @@ -import { assertEquals } from "../../deps/testing.ts"; +import { assertEquals } from "@std/assert"; import { decode, encode } from "./_internal.ts"; Deno.test("encode()", async (t) => { diff --git a/browser/dom/cursor.d.ts b/browser/dom/cursor.d.ts index 1485d88..fee9007 100644 --- a/browser/dom/cursor.d.ts +++ b/browser/dom/cursor.d.ts @@ -1,4 +1,4 @@ -import { type BaseLine, BaseStore } from "../../deps/scrapbox.ts"; +import { type BaseLine, BaseStore } from "@cosense/types/userscript"; import type { Position } from "./position.ts"; import type { Page } from "./page.d.ts"; diff --git a/browser/dom/edit.ts b/browser/dom/edit.ts index b277316..069475b 100644 --- a/browser/dom/edit.ts +++ b/browser/dom/edit.ts @@ -3,7 +3,7 @@ import { press } from "./press.ts"; import { getLineCount } from "./node.ts"; import { range } from "../../range.ts"; import { textInput } from "./dom.ts"; -import { isArray, isNumber, isString } from "../../is.ts"; +import { isArray, isNumber, isString } from "@core/unknownutil"; import { delay } from "@std/async/delay"; export const undo = (count = 1): void => { diff --git a/browser/dom/extractCodeFiles.test.ts b/browser/dom/extractCodeFiles.test.ts index c40df35..f2ead22 100644 --- a/browser/dom/extractCodeFiles.test.ts +++ b/browser/dom/extractCodeFiles.test.ts @@ -1,6 +1,6 @@ import { extractCodeFiles } from "./extractCodeFiles.ts"; -import type { Line } from "../../deps/scrapbox.ts"; -import { assertSnapshot } from "../../deps/testing.ts"; +import type { Line } from "@cosense/types/userscript"; +import { assertSnapshot } from "@std/testing/snapshot"; import sample from "./sample-lines1.json" with { type: "json" }; Deno.test("extractCodeFiles", async (t) => { diff --git a/browser/dom/extractCodeFiles.ts b/browser/dom/extractCodeFiles.ts index c425ad0..f5e1f11 100644 --- a/browser/dom/extractCodeFiles.ts +++ b/browser/dom/extractCodeFiles.ts @@ -1,4 +1,4 @@ -import type { Line } from "../../deps/scrapbox.ts"; +import type { Line } from "@cosense/types/userscript"; /** 一つのソースコードを表す */ export interface CodeFile { diff --git a/browser/dom/getCachedLines.ts b/browser/dom/getCachedLines.ts index edf7a80..2f5f2e0 100644 --- a/browser/dom/getCachedLines.ts +++ b/browser/dom/getCachedLines.ts @@ -1,4 +1,4 @@ -import type { Line, Scrapbox } from "../../deps/scrapbox.ts"; +import type { Line, Scrapbox } from "@cosense/types/userscript"; declare const scrapbox: Scrapbox; let isLatestData = false; diff --git a/browser/dom/node.ts b/browser/dom/node.ts index 22e96f7..f4c60ed 100644 --- a/browser/dom/node.ts +++ b/browser/dom/node.ts @@ -1,8 +1,8 @@ -import { isNone, isNumber, isString } from "../../is.ts"; -import { ensureArray } from "../../ensure.ts"; +import { isNumber, isString, isUndefined } from "@core/unknownutil"; +import { ensure, isArray } from "@core/unknownutil"; import { getCachedLines } from "./getCachedLines.ts"; import { takeInternalLines } from "./takeInternalLines.ts"; -import type { BaseLine, Line } from "../../deps/scrapbox.ts"; +import type { BaseLine, Line } from "@cosense/types/userscript"; import { lines } from "./dom.ts"; import * as Text from "../../text.ts"; @@ -15,7 +15,7 @@ import * as Text from "../../text.ts"; export const getLineId = ( value?: number | string | T, ): string | undefined => { - if (isNone(value)) return undefined; + if (isUndefined(value)) return undefined; // 行番号のとき if (isNumber(value)) return getBaseLine(value)?.id; @@ -40,7 +40,7 @@ export const getLineId = ( export const getLineNo = ( value?: number | string | T, ): number | undefined => { - if (isNone(value)) return undefined; + if (isUndefined(value)) return undefined; // 行番号のとき if (isNumber(value)) return value; @@ -52,7 +52,7 @@ export const getLineNo = ( export const getLine = ( value?: number | string | T, ): Line | undefined => { - if (isNone(value)) return undefined; + if (isUndefined(value)) return undefined; // 行番号のとき if (isNumber(value)) return getLines()[value]; @@ -64,7 +64,7 @@ export const getLine = ( export const getBaseLine = ( value?: number | string | T, ): BaseLine | undefined => { - if (isNone(value)) return undefined; + if (isUndefined(value)) return undefined; // 行番号のとき if (isNumber(value)) return takeInternalLines()[value]; @@ -79,9 +79,9 @@ export const getLineDOM = ( if (isLineDOM(value)) return value; const id = getLineId(value); - if (isNone(id)) return id; + if (isUndefined(id)) return id; const line = document.getElementById(`L${id}`); - if (isNone(line)) return undefined; + if (isUndefined(line)) return undefined; return line as HTMLDivElement; }; export const isLineDOM = (dom: unknown): dom is HTMLDivElement => @@ -90,15 +90,14 @@ export const isLineDOM = (dom: unknown): dom is HTMLDivElement => export const getLineCount = (): number => takeInternalLines().length; export const getLines = (): readonly Line[] => { - const lines = getCachedLines(); - ensureArray(lines, "scrapbox.Page.lines"); - return lines; + const lines = ensure(getCachedLines(), isArray); + return lines as Line[]; }; export const getText = ( value?: number | string | T, ): string | undefined => { - if (isNone(value)) return undefined; + if (isUndefined(value)) return undefined; // 数字と文字列は行として扱う if (isNumber(value) || isString(value)) return getBaseLine(value)?.text; @@ -118,7 +117,7 @@ export const getText = ( //中に含まれている文字の列番号を全て取得し、それに対応する文字列を返す const chars = [] as number[]; const line = getBaseLine(value); - if (isNone(line)) return; + if (isUndefined(line)) return; for (const dom of getChars(value)) { chars.push(getIndex(dom)); } @@ -127,30 +126,30 @@ export const getText = ( export const getExternalLink = (dom: HTMLElement): HTMLElement | undefined => { const link = dom.closest(".link"); - if (isNone(link)) return undefined; + if (isUndefined(link)) return undefined; return link as HTMLElement; }; export const getInternalLink = (dom: HTMLElement): HTMLElement | undefined => { const link = dom.closest(".page-link"); - if (isNone(link)) return undefined; + if (isUndefined(link)) return undefined; return link as HTMLElement; }; export const getLink = (dom: HTMLElement): HTMLElement | undefined => { const link = dom.closest(".link, .page-link"); - if (isNone(link)) return undefined; + if (isUndefined(link)) return undefined; return link as HTMLElement; }; export const getFormula = (dom: HTMLElement): HTMLElement | undefined => { const formula = dom.closest(".formula"); - if (isNone(formula)) return undefined; + if (isUndefined(formula)) return undefined; return formula as HTMLElement; }; export const getNextLine = ( value?: number | string | T, ): Line | undefined => { const index = getLineNo(value); - if (isNone(index)) return undefined; + if (isUndefined(index)) return undefined; return getLine(index + 1); }; @@ -159,26 +158,26 @@ export const getPrevLine = ( value?: number | string | T, ): Line | undefined => { const index = getLineNo(value); - if (isNone(index)) return undefined; + if (isUndefined(index)) return undefined; return getLine(index - 1); }; export const getHeadLineDOM = (): HTMLDivElement | undefined => { const line = lines()?.firstElementChild; - if (isNone(line)) return undefined; + if (isUndefined(line)) return undefined; return line as HTMLDivElement; }; export const getTailLineDOM = (): HTMLDivElement | undefined => { const line = lines()?.lastElementChild; - if (isNone(line)) return undefined; + if (isUndefined(line)) return undefined; return line as HTMLDivElement; }; export const getIndentCount = ( value?: number | string | T, ): number | undefined => { const text = getText(value); - if (isNone(text)) return undefined; + if (isUndefined(text)) return undefined; return Text.getIndentCount(text); }; /** 指定した行の配下にある行の数を返す @@ -189,7 +188,7 @@ export const getIndentLineCount = ( value?: number | string | T, ): number | undefined => { const index = getLineNo(value); - if (isNone(index)) return; + if (isUndefined(index)) return; return Text.getIndentLineCount(index, getLines()); }; @@ -210,7 +209,7 @@ export const getIndex = (dom: HTMLSpanElement): number => { if (!isCharDOM(dom)) throw Error("A char DOM is required."); const index = dom.className.match(/c-(\d+)/)?.[1]; - if (isNone(index)) throw Error('.char-index must have ".c-{\\d}"'); + if (isUndefined(index)) throw Error('.char-index must have ".c-{\\d}"'); return parseInt(index); }; export const getHeadCharDOM = ( @@ -242,7 +241,7 @@ export const getDOMFromPoint = ( const char = targets.find((target) => isCharDOM(target)); const line = targets.find((target) => isLineDOM(target)); return { - char: isNone(char) ? undefined : char as HTMLSpanElement, - line: isNone(line) ? undefined : line as HTMLDivElement, + char: isUndefined(char) ? undefined : char as HTMLSpanElement, + line: isUndefined(line) ? undefined : line as HTMLDivElement, }; }; diff --git a/browser/dom/open.ts b/browser/dom/open.ts index b99f1b4..0e7ed7d 100644 --- a/browser/dom/open.ts +++ b/browser/dom/open.ts @@ -3,7 +3,7 @@ import { type PageTransitionContext, pushPageTransition, } from "./pushPageTransition.ts"; -import type { Scrapbox } from "../../deps/scrapbox.ts"; +import type { Scrapbox } from "@cosense/types/userscript"; declare const scrapbox: Scrapbox; export interface OpenOptions { diff --git a/browser/dom/page.d.ts b/browser/dom/page.d.ts index 1f49b76..10fe41f 100644 --- a/browser/dom/page.d.ts +++ b/browser/dom/page.d.ts @@ -1,5 +1,5 @@ -import { BaseStore } from "../../deps/scrapbox.ts"; -import type { Page as PageData } from "../../deps/scrapbox-rest.ts"; +import { BaseStore } from "@cosense/types/userscript"; +import type { Page as PageData } from "@cosense/types/rest"; export interface SetPositionOptions { /** カーソルが画面外に移動したとき、カーソルが見える位置までページをスクロールするかどうか diff --git a/browser/dom/selection.d.ts b/browser/dom/selection.d.ts index b7d2616..a8b6896 100644 --- a/browser/dom/selection.d.ts +++ b/browser/dom/selection.d.ts @@ -1,4 +1,4 @@ -import { type BaseLine, BaseStore } from "../../deps/scrapbox.ts"; +import { type BaseLine, BaseStore } from "@cosense/types/userscript"; import type { Position } from "./position.ts"; export interface Range { diff --git a/browser/dom/takeInternalLines.ts b/browser/dom/takeInternalLines.ts index 666eb56..5877653 100644 --- a/browser/dom/takeInternalLines.ts +++ b/browser/dom/takeInternalLines.ts @@ -1,5 +1,5 @@ import { lines } from "./dom.ts"; -import type { BaseLine } from "../../deps/scrapbox.ts"; +import type { BaseLine } from "@cosense/types/userscript"; /** Scrapbox内部の本文データの参照を取得する * diff --git a/browser/dom/textInputEventListener.ts b/browser/dom/textInputEventListener.ts index dc2346a..4bb05ba 100644 --- a/browser/dom/textInputEventListener.ts +++ b/browser/dom/textInputEventListener.ts @@ -1,4 +1,4 @@ -import type { Scrapbox } from "../../deps/scrapbox.ts"; +import type { Scrapbox } from "@cosense/types/userscript"; import { textInput } from "./dom.ts"; import { decode, encode } from "./_internal.ts"; declare const scrapbox: Scrapbox; diff --git a/browser/websocket/_codeBlock.test.ts b/browser/websocket/_codeBlock.test.ts index 69ebd49..12fd9d6 100644 --- a/browser/websocket/_codeBlock.test.ts +++ b/browser/websocket/_codeBlock.test.ts @@ -1,4 +1,5 @@ -import { assertEquals, assertSnapshot } from "../../deps/testing.ts"; +import { assertEquals } from "@std/assert"; +import { assertSnapshot } from "@std/testing/snapshot"; import { extractFromCodeTitle } from "./_codeBlock.ts"; Deno.test("extractFromCodeTitle()", async (t) => { diff --git a/browser/websocket/applyCommit.ts b/browser/websocket/applyCommit.ts index 11fcb02..0a1db24 100644 --- a/browser/websocket/applyCommit.ts +++ b/browser/websocket/applyCommit.ts @@ -1,6 +1,7 @@ import type { CommitNotification } from "../../deps/socket.ts"; -import type { Line } from "../../deps/scrapbox-rest.ts"; +import type { Page } from "@cosense/types/rest"; import { getUnixTimeFromId } from "./id.ts"; +type Line = Page["lines"][number]; export interface ApplyCommitProp { /** changesの作成日時 diff --git a/browser/websocket/deletePage.ts b/browser/websocket/deletePage.ts index f287309..94de937 100644 --- a/browser/websocket/deletePage.ts +++ b/browser/websocket/deletePage.ts @@ -1,5 +1,5 @@ -import { push, type PushOptions, type RetryError } from "./push.ts"; -import type { Result } from "../../rest/util.ts"; +import { push, type PushError, type PushOptions } from "./push.ts"; +import type { Result } from "option-t/plain_result"; export type DeletePageOptions = PushOptions; @@ -13,7 +13,7 @@ export const deletePage = ( project: string, title: string, options?: DeletePageOptions, -): Promise> => +): Promise> => push( project, title, diff --git a/browser/websocket/diffToChanges.test.ts b/browser/websocket/diffToChanges.test.ts index 506cb23..3c84153 100644 --- a/browser/websocket/diffToChanges.test.ts +++ b/browser/websocket/diffToChanges.test.ts @@ -1,5 +1,5 @@ import { diffToChanges } from "./diffToChanges.ts"; -import { assertEquals } from "../../deps/testing.ts"; +import { assertEquals } from "@std/assert"; Deno.test("diffToChanges()", async ({ step }) => { const userId = "xxxyyy"; diff --git a/browser/websocket/diffToChanges.ts b/browser/websocket/diffToChanges.ts index c5bd1b2..dc71094 100644 --- a/browser/websocket/diffToChanges.ts +++ b/browser/websocket/diffToChanges.ts @@ -1,5 +1,5 @@ import { diff, toExtendedChanges } from "../../deps/onp.ts"; -import type { Line } from "../../deps/scrapbox.ts"; +import type { Line } from "@cosense/types/userscript"; import type { DeleteChange, InsertChange, diff --git a/browser/websocket/findMetadata.test.ts b/browser/websocket/findMetadata.test.ts index 348c922..6cd12e7 100644 --- a/browser/websocket/findMetadata.test.ts +++ b/browser/websocket/findMetadata.test.ts @@ -1,5 +1,6 @@ import { findMetadata, getHelpfeels } from "./findMetadata.ts"; -import { assertEquals, assertSnapshot } from "../../deps/testing.ts"; +import { assertEquals } from "@std/assert"; +import { assertSnapshot } from "@std/testing/snapshot"; const text = `てすと [ふつうの]リンク diff --git a/browser/websocket/findMetadata.ts b/browser/websocket/findMetadata.ts index 7f13e60..59fe676 100644 --- a/browser/websocket/findMetadata.ts +++ b/browser/websocket/findMetadata.ts @@ -1,4 +1,5 @@ -import { type BaseLine, type Node, parse } from "../../deps/scrapbox.ts"; +import { type Node, parse } from "@progfay/scrapbox-parser"; +import type { BaseLine } from "@cosense/types/userscript"; import { toTitleLc } from "../../title.ts"; import { parseYoutube } from "../../parser/youtube.ts"; diff --git a/browser/websocket/id.ts b/browser/websocket/id.ts index 0a69505..77c3551 100644 --- a/browser/websocket/id.ts +++ b/browser/websocket/id.ts @@ -1,35 +1,3 @@ -import { getProject } from "../../rest/project.ts"; -import { getProfile } from "../../rest/profile.ts"; - -/** cached user ID */ -let userId: string | undefined; -export const getUserId = async (): Promise => { - if (userId !== undefined) return userId; - - const user = await getProfile(); - if (user.isGuest) { - throw new Error("this script can only be executed by Logged in users"); - } - userId = user.id; - return userId; -}; - -/** cached pairs of project name and project id */ -const projectMap = new Map(); -export const getProjectId = async (project: string): Promise => { - const cachedId = projectMap.get(project); - if (cachedId !== undefined) return cachedId; - - const result = await getProject(project); - if (!result.ok) { - const { name, message } = result.value; - throw new Error(`${name} ${message}`); - } - const { id } = result.value; - projectMap.set(project, id); - return id; -}; - const zero = (n: string) => n.padStart(8, "0"); export const createNewLineId = (userId: string): string => { diff --git a/browser/websocket/isSameArray.test.ts b/browser/websocket/isSameArray.test.ts index 93ab60a..1a753a0 100644 --- a/browser/websocket/isSameArray.test.ts +++ b/browser/websocket/isSameArray.test.ts @@ -1,5 +1,5 @@ import { isSameArray } from "./isSameArray.ts"; -import { assert } from "../../deps/testing.ts"; +import { assert } from "@std/assert"; Deno.test("isSameArray()", () => { assert(isSameArray([1, 2, 3], [1, 2, 3])); diff --git a/browser/websocket/isSimpleCodeFile.test.ts b/browser/websocket/isSimpleCodeFile.test.ts index acf7973..babff62 100644 --- a/browser/websocket/isSimpleCodeFile.test.ts +++ b/browser/websocket/isSimpleCodeFile.test.ts @@ -1,4 +1,4 @@ -import { assert, assertFalse } from "../../deps/testing.ts"; +import { assert, assertFalse } from "@std/assert"; import { isSimpleCodeFile } from "./isSimpleCodeFile.ts"; import type { SimpleCodeFile } from "./updateCodeFile.ts"; diff --git a/browser/websocket/listen.ts b/browser/websocket/listen.ts index dcdd795..230f5ca 100644 --- a/browser/websocket/listen.ts +++ b/browser/websocket/listen.ts @@ -1,3 +1,9 @@ +import { createOk, isErr, type Result, unwrapOk } from "option-t/plain_result"; +import type { + NotFoundError, + NotLoggedInError, + NotMemberError, +} from "@cosense/types/rest"; import { type ProjectUpdatesStreamCommit, type ProjectUpdatesStreamEvent, @@ -5,8 +11,10 @@ import { socketIO, wrap, } from "../../deps/socket.ts"; +import type { HTTPError } from "../../rest/responseIntoResult.ts"; +import type { AbortError, NetworkError } from "../../rest/robustFetch.ts"; +import { getProjectId } from "./pull.ts"; import { connect, disconnect } from "./socket.ts"; -import { getProjectId } from "./id.ts"; export type { ProjectUpdatesStreamCommit, ProjectUpdatesStreamEvent, @@ -27,11 +35,24 @@ export async function* listenStream( events: ["commit" | "event", ...("commit" | "event")[]], options?: ListenStreamOptions, ): AsyncGenerator< - ProjectUpdatesStreamEvent | ProjectUpdatesStreamCommit, + Result< + ProjectUpdatesStreamEvent | ProjectUpdatesStreamCommit, + | NotFoundError + | NotLoggedInError + | NotMemberError + | NetworkError + | AbortError + | HTTPError + >, void, unknown > { - const projectId = await getProjectId(project); + const result = await getProjectId(project); + if (isErr(result)) { + yield result; + return; + } + const projectId = unwrapOk(result); const injectedSocket = options?.socket; const socket = injectedSocket ?? await socketIO(); @@ -45,9 +66,13 @@ export async function* listenStream( data: { projectId, pageId: null, projectUpdatesStream: true }, }); - yield* response( - ...events.map((event) => `projectUpdatesStream:${event}` as const), - ); + for await ( + const streamEvent of response( + ...events.map((event) => `projectUpdatesStream:${event}` as const), + ) + ) { + yield createOk(streamEvent); + } } finally { if (!injectedSocket) await disconnect(socket); } diff --git a/browser/websocket/makeChanges.ts b/browser/websocket/makeChanges.ts index 267ac8f..74581ee 100644 --- a/browser/websocket/makeChanges.ts +++ b/browser/websocket/makeChanges.ts @@ -1,5 +1,5 @@ import { diffToChanges } from "./diffToChanges.ts"; -import type { Page } from "../../deps/scrapbox-rest.ts"; +import type { Page } from "@cosense/types/rest"; import type { Change } from "../../deps/socket.ts"; import { findMetadata, getHelpfeels } from "./findMetadata.ts"; import { isSameArray } from "./isSameArray.ts"; diff --git a/browser/websocket/patch.ts b/browser/websocket/patch.ts index 1afe546..a8a9532 100644 --- a/browser/websocket/patch.ts +++ b/browser/websocket/patch.ts @@ -1,11 +1,12 @@ import type { Change, DeletePageChange, PinChange } from "../../deps/socket.ts"; import { makeChanges } from "./makeChanges.ts"; -import type { Line, Page } from "../../deps/scrapbox-rest.ts"; -import { push, type PushOptions, type RetryError } from "./push.ts"; +import type { Page } from "@cosense/types/rest"; +import { push, type PushError, type PushOptions } from "./push.ts"; import { suggestUnDupTitle } from "./suggestUnDupTitle.ts"; -import type { Result } from "../../rest/util.ts"; +import type { Result } from "option-t/plain_result"; export type PatchOptions = PushOptions; +type Line = Page["lines"][number]; export interface PatchMetadata extends Page { /** 書き換えを再試行した回数 @@ -32,7 +33,7 @@ export const patch = ( metadata: PatchMetadata, ) => string[] | undefined | Promise, options?: PatchOptions, -): Promise> => +): Promise> => push( project, title, diff --git a/browser/websocket/pin.ts b/browser/websocket/pin.ts index e3065e4..7557975 100644 --- a/browser/websocket/pin.ts +++ b/browser/websocket/pin.ts @@ -1,6 +1,6 @@ +import type { Result } from "option-t/plain_result"; import type { Change, Socket } from "../../deps/socket.ts"; -import { push, type PushOptions, type RetryError } from "./push.ts"; -import type { Result } from "../../rest/util.ts"; +import { push, type PushError, type PushOptions } from "./push.ts"; export interface PinOptions extends PushOptions { /** ピン留め対象のページが存在しないときの振る舞いを変えるoption @@ -22,7 +22,7 @@ export const pin = ( project: string, title: string, options?: PinOptions, -): Promise> => +): Promise> => push( project, title, @@ -51,7 +51,7 @@ export const unpin = ( project: string, title: string, options: UnPinOptions, -): Promise> => +): Promise> => push( project, title, diff --git a/browser/websocket/pull.ts b/browser/websocket/pull.ts index 39a50d9..3982e2c 100644 --- a/browser/websocket/pull.ts +++ b/browser/websocket/pull.ts @@ -1,16 +1,108 @@ -import type { Page } from "../../deps/scrapbox-rest.ts"; -import { getPage } from "../../rest/pages.ts"; +import { + createErr, + createOk, + isErr, + mapForResult, + type Result, + unwrapOk, +} from "option-t/plain_result"; +import type { + NotFoundError, + NotLoggedInError, + NotMemberError, + Page, +} from "@cosense/types/rest"; +import { + getPage, + type GetPageOption, + type TooLongURIError, +} from "../../rest/pages.ts"; +import { getProfile } from "../../rest/profile.ts"; +import { getProject } from "../../rest/project.ts"; +import type { HTTPError } from "../../rest/responseIntoResult.ts"; +import type { AbortError, NetworkError } from "../../rest/robustFetch.ts"; +import type { BaseOptions } from "../../rest/util.ts"; + +export interface PushMetadata extends Page { + projectId: string; + userId: string; +} + +export type PullError = + | NotFoundError + | NotLoggedInError + | Omit + | NotMemberError + | TooLongURIError + | HTTPError + | NetworkError + | AbortError; export const pull = async ( project: string, title: string, -): Promise => { - const result = await getPage(project, title); + options?: GetPageOption, +): Promise> => { + const [pageRes, userRes, projectRes] = await Promise.all([ + getPage(project, title, options), + getUserId(options), + getProjectId(project, options), + ]); + if (isErr(pageRes)) return pageRes; + if (isErr(userRes)) return userRes; + if (isErr(projectRes)) return projectRes; + return createOk({ + ...unwrapOk(pageRes), + projectId: unwrapOk(projectRes), + userId: unwrapOk(userRes), + }); +}; +// TODO: 編集不可なページはStream購読だけ提供する + +/** cached user ID */ +let userId: string | undefined; +const getUserId = async (init?: BaseOptions): Promise< + Result< + string, + Omit | NetworkError | AbortError | HTTPError + > +> => { + if (userId) return createOk(userId); - // TODO: 編集不可なページはStream購読だけ提供する - if (!result.ok) { - throw new Error(`You have no privilege of editing "/${project}/${title}".`); - } + const result = await getProfile(init); + if (isErr(result)) return result; + + const user = unwrapOk(result); + return "id" in user ? createOk(user.id) : createErr({ + name: "NotLoggedInError", + message: "This script cannot be used without login", + }); +}; + +/** cached pairs of project name and project id */ +const projectMap = new Map(); +export const getProjectId = async ( + project: string, + options?: BaseOptions, +): Promise< + Result< + string, + | NotFoundError + | NotLoggedInError + | NotMemberError + | NetworkError + | AbortError + | HTTPError + > +> => { + const cachedId = projectMap.get(project); + if (cachedId) return createOk(cachedId); - return result.value; + return mapForResult( + await getProject(project, options), + ({ id }) => { + projectMap.set(project, id); + return id; + }, + ); }; diff --git a/browser/websocket/push.ts b/browser/websocket/push.ts index 1585241..c2a0747 100644 --- a/browser/websocket/push.ts +++ b/browser/websocket/push.ts @@ -5,18 +5,32 @@ import { type PageCommitError, type PageCommitResponse, type PinChange, + type Result as SocketResult, type Socket, socketIO, type TimeoutError, - type UnexpectedError, wrap, } from "../../deps/socket.ts"; import { connect, disconnect } from "./socket.ts"; -import { getProjectId, getUserId } from "./id.ts"; import { pull } from "./pull.ts"; -import type { Page } from "../../deps/scrapbox-rest.ts"; +import type { + ErrorLike, + NotFoundError, + NotLoggedInError, + NotMemberError, + Page, +} from "@cosense/types/rest"; import { delay } from "@std/async/delay"; -import type { Result } from "../../rest/util.ts"; +import { + createErr, + createOk, + isErr, + type Result, + unwrapOk, +} from "option-t/plain_result"; +import type { HTTPError } from "../../rest/responseIntoResult.ts"; +import type { AbortError, NetworkError } from "../../rest/robustFetch.ts"; +import type { TooLongURIError } from "../../rest/pages.ts"; export interface PushOptions { /** 外部からSocketを指定したいときに使う */ @@ -40,6 +54,22 @@ export interface PushMetadata extends Page { userId: string; } +export interface UnexpectedError extends ErrorLike { + name: "UnexpectedError"; +} + +export type PushError = + | RetryError + | UnexpectedError + | NotFoundError + | NotLoggedInError + | Omit + | NotMemberError + | TooLongURIError + | HTTPError + | NetworkError + | AbortError; + /** 特定のページのcommitをpushする * * serverからpush errorが返ってきた場合、エラーに応じてpushを再試行する @@ -64,18 +94,15 @@ export const push = async ( | [DeletePageChange] | [PinChange], options?: PushOptions, -): Promise> => { +): Promise> => { const injectedSocket = options?.socket; const socket = injectedSocket ?? await socketIO(); await connect(socket); + const pullResult = await pull(project, title); + if (isErr(pullResult)) return pullResult; + let metadata = unwrapOk(pullResult); try { - let page: PushMetadata = await Promise.all([ - pull(project, title), - getProjectId(project), - getUserId(), - ]).then(([page_, projectId, userId]) => ({ ...page_, projectId, userId })); - const { request } = wrap(socket); let attempts = 0; let changes: Change[] | [DeletePageChange] | [PinChange] = []; @@ -85,19 +112,17 @@ export const push = async ( while ( options?.maxAttempts === undefined || attempts < options.maxAttempts ) { - const pending = makeCommit(page, attempts, changes, reason); + const pending = makeCommit(metadata, attempts, changes, reason); changes = pending instanceof Promise ? await pending : pending; attempts++; - if (changes.length === 0) { - return { ok: true, value: page.commitId }; - } + if (changes.length === 0) return createOk(metadata.commitId); const data: PageCommit = { kind: "page", - projectId: page.projectId, - pageId: page.id, - parentId: page.commitId, - userId: page.userId, + projectId: metadata.projectId, + pageId: metadata.id, + parentId: metadata.commitId, + userId: metadata.userId, changes, cursor: null, freeze: true, @@ -108,22 +133,19 @@ export const push = async ( const result = (await request("socket.io-request", { method: "commit", data, - })) as Result< + })) as SocketResult< PageCommitResponse, UnexpectedError | TimeoutError | PageCommitError >; if (result.ok) { - page.commitId = result.value.commitId; + metadata.commitId = result.value.commitId; // success - return { ok: true, value: page.commitId }; + return createOk(metadata.commitId); } const name = result.value.name; if (name === "UnexpectedError") { - const error = new Error(); - error.name = result.value.name; - error.message = JSON.stringify(result.value); - throw error; + return createErr({ name, message: JSON.stringify(result.value) }); } if (name === "TimeoutError" || name === "SocketIOError") { await delay(3000); @@ -132,26 +154,21 @@ export const push = async ( } if (name === "NotFastForwardError") { await delay(1000); - page = { - ...await pull(project, title), - projectId: page.projectId, - userId: page.userId, - }; + const pullResult = await pull(project, title); + if (isErr(pullResult)) return pullResult; + metadata = unwrapOk(pullResult); } reason = name; // go back to the diff loop break; } } - return { - ok: false, - value: { - name: "RetryError", - attempts, - // from https://github.com/denoland/deno_std/blob/0.223.0/async/retry.ts#L23 - message: `Retrying exceeded the maxAttempts (${attempts}).`, - }, - }; + return createErr({ + name: "RetryError", + attempts, + // from https://github.com/denoland/deno_std/blob/0.223.0/async/retry.ts#L23 + message: `Retrying exceeded the maxAttempts (${attempts}).`, + }); } finally { if (!injectedSocket) await disconnect(socket); } diff --git a/browser/websocket/suggestUnDupTitle.test.ts b/browser/websocket/suggestUnDupTitle.test.ts index b0be4df..5835123 100644 --- a/browser/websocket/suggestUnDupTitle.test.ts +++ b/browser/websocket/suggestUnDupTitle.test.ts @@ -1,5 +1,5 @@ import { suggestUnDupTitle } from "./suggestUnDupTitle.ts"; -import { assertEquals } from "../../deps/testing.ts"; +import { assertEquals } from "@std/assert"; Deno.test("suggestUnDupTitle()", () => { assertEquals(suggestUnDupTitle("title"), "title_2"); diff --git a/browser/websocket/updateCodeBlock.ts b/browser/websocket/updateCodeBlock.ts index 8191897..f0db325 100644 --- a/browser/websocket/updateCodeBlock.ts +++ b/browser/websocket/updateCodeBlock.ts @@ -1,4 +1,4 @@ -import type { Line } from "../../deps/scrapbox-rest.ts"; +import type { Page } from "@cosense/types/rest"; import type { DeleteChange, InsertChange, @@ -9,8 +9,10 @@ import { diffToChanges } from "./diffToChanges.ts"; import { isSimpleCodeFile } from "./isSimpleCodeFile.ts"; import type { SimpleCodeFile } from "./updateCodeFile.ts"; import { countBodyIndent, extractFromCodeTitle } from "./_codeBlock.ts"; -import { push, type PushOptions, type RetryError } from "./push.ts"; -import type { Result } from "../../rest/util.ts"; +import { push, type PushError, type PushOptions } from "./push.ts"; +import type { Result } from "option-t/plain_result"; + +type Line = Page["lines"][number]; export interface UpdateCodeBlockOptions extends PushOptions { /** `true`でデバッグ出力ON */ @@ -30,7 +32,7 @@ export const updateCodeBlock = ( newCode: string | string[] | SimpleCodeFile, target: TinyCodeBlock, options?: UpdateCodeBlockOptions, -): Promise> => { +): Promise> => { const newCodeBody = getCodeBody(newCode); const bodyIndent = countBodyIndent(target); const oldCodeWithoutIndent: Line[] = target.bodyLines.map((e) => { diff --git a/browser/websocket/updateCodeFile.ts b/browser/websocket/updateCodeFile.ts index 851b300..4a8f0f8 100644 --- a/browser/websocket/updateCodeFile.ts +++ b/browser/websocket/updateCodeFile.ts @@ -1,4 +1,4 @@ -import type { Line } from "../../deps/scrapbox-rest.ts"; +import type { Page } from "@cosense/types/rest"; import type { DeleteChange, InsertChange, @@ -8,8 +8,9 @@ import { getCodeBlocks, type TinyCodeBlock } from "../../rest/getCodeBlocks.ts"; import { createNewLineId } from "./id.ts"; import { diff, toExtendedChanges } from "../../deps/onp.ts"; import { countBodyIndent } from "./_codeBlock.ts"; -import { push, type PushOptions, type RetryError } from "./push.ts"; -import type { Result } from "../../rest/util.ts"; +import { push, type PushError, type PushOptions } from "./push.ts"; +import type { Result } from "option-t/plain_result"; +type Line = Page["lines"][number]; /** コードブロックの上書きに使う情報のinterface */ export interface SimpleCodeFile { @@ -58,7 +59,7 @@ export const updateCodeFile = ( project: string, title: string, options?: UpdateCodeFileOptions, -): Promise> => { +): Promise> => { /** optionsの既定値はこの中に入れる */ const defaultOptions: Required< Omit @@ -72,9 +73,9 @@ export const updateCodeFile = ( return push( project, title, - async (page) => { + (page) => { const lines: Line[] = page.lines; - const codeBlocks = await getCodeBlocks({ project, title, lines }, { + const codeBlocks = getCodeBlocks({ project, title, lines }, { filename: codeFile.filename, }); const commits = [ diff --git a/deno.jsonc b/deno.jsonc index 2ec949f..7143942 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -2,7 +2,7 @@ "name": "@cosense/std", "version": "0.0.0", "tasks": { - "check": "deno fmt && deno lint && deno check --remote **/*.ts && deno test --allow-read", + "check": "deno fmt && deno lint --fix && deno check --remote **/*.ts && deno test --allow-read", "check:dry": "deno fmt --check && deno lint && deno check --remote **/*.ts && deno test --allow-read", "update:check": "deno run --allow-env --allow-read --allow-write=.,'~/.local/share/deno-wasmbuild' --allow-run=git,deno --allow-net=deno.land,raw.githubusercontent.com,esm.sh,jsr.io,registry.npmjs.org jsr:@molt/cli@0.19", "update": "deno task update:check --write", @@ -10,6 +10,7 @@ "publish": "deno run --allow-env --allow-run=deno --allow-read --allow-write=deno.jsonc jsr:@david/publish-on-tag@0.1.x" }, "imports": { + "@core/unknownutil": "jsr:@core/unknownutil@^3.18.1", "@cosense/types/rest": "jsr:@cosense/types@0.10/rest", "@cosense/types/userscript": "jsr:@cosense/types@0.10/userscript", "@progfay/scrapbox-parser": "jsr:@progfay/scrapbox-parser@9", @@ -18,7 +19,8 @@ "@std/hash": "./vendor/deno.land/std@0.160.0/hash/md5.ts", "@std/testing/snapshot": "jsr:@std/testing@0/snapshot", "@takker/onp": "./vendor/raw.githubusercontent.com/takker99/onp/0.0.1/mod.ts", - "@takker/scrapbox-userscript-websocket": "./vendor/raw.githubusercontent.com/takker99/scrapbox-userscript-websocket/0.2.4/mod.ts" + "@takker/scrapbox-userscript-websocket": "./vendor/raw.githubusercontent.com/takker99/scrapbox-userscript-websocket/0.2.4/mod.ts", + "option-t": "npm:option-t@^49.1.0" }, "exports": { ".": "./mod.ts", @@ -26,6 +28,8 @@ "./browser": "./browser/mod.ts", "./browser/dom": "./browser/dom/mod.ts", "./browser/websocket": "./browser/websocket/mod.ts", + "./parseAbsoluteLink": "./parseAbsoluteLink.ts", + "./title": "./title.ts", "./text": "./text.ts" }, "compilerOptions": { diff --git a/deno.lock b/deno.lock index db35bc8..7ad02f0 100644 --- a/deno.lock +++ b/deno.lock @@ -2,6 +2,7 @@ "version": "3", "packages": { "specifiers": { + "jsr:@core/unknownutil@^3.18.1": "jsr:@core/unknownutil@3.18.1", "jsr:@cosense/types@0.10": "jsr:@cosense/types@0.10.1", "jsr:@progfay/scrapbox-parser@9": "jsr:@progfay/scrapbox-parser@9.0.0", "jsr:@std/assert@1": "jsr:@std/assert@1.0.1", @@ -13,9 +14,13 @@ "jsr:@std/internal@^1.0.1": "jsr:@std/internal@1.0.1", "jsr:@std/path@1.0.0-rc.2": "jsr:@std/path@1.0.0-rc.2", "jsr:@std/path@^1.0.2": "jsr:@std/path@1.0.2", - "jsr:@std/testing@0": "jsr:@std/testing@0.225.3" + "jsr:@std/testing@0": "jsr:@std/testing@0.225.3", + "npm:option-t@^49.1.0": "npm:option-t@49.1.0" }, "jsr": { + "@core/unknownutil@3.18.1": { + "integrity": "6aaa108b623ff971d062dd345da7ca03b97f61ae3d29c8677bff14fec11f0d76" + }, "@cosense/types@0.10.1": { "integrity": "13d2488a02c7b0b035a265bc3299affbdab1ea5b607516379685965cd37b2058" }, @@ -62,6 +67,12 @@ "jsr:@std/path@1.0.0-rc.2" ] } + }, + "npm": { + "option-t@49.1.0": { + "integrity": "sha512-K5o4+D8rSE1VcmfwrieRZWyShPxX27NEGuZPT11S4CbvjE73YUDR/sanKAYnhVRj6Hn1mKcjfhjhbOCD2ef3qg==", + "dependencies": {} + } } }, "remote": { @@ -135,7 +146,9 @@ "https://deno.land/std@0.224.0/path/windows/to_file_url.ts": "40e560ee4854fe5a3d4d12976cef2f4e8914125c81b11f1108e127934ced502e", "https://deno.land/std@0.224.0/testing/snapshot.ts": "35ca1c8e8bfb98d7b7e794f1b7be8d992483fcff572540e41396f22a5bddb944", "https://esm.sh/@progfay/scrapbox-parser@9.0.0": "24b6a5a7b95e3b8f49a259fce1f39b0d9c333b2c8afc71d844d551c47473b3b1", + "https://esm.sh/option-t@49.1.0/maybe": "ab18c4aaccc1f75da79c06c5e9e435ac17385e698ad191b1392eca2daf64ff84", "https://esm.sh/v135/@progfay/scrapbox-parser@9.0.0/denonext/scrapbox-parser.mjs": "36ea0381aaf840f8e1d177d3f349d191e70a2b33292010b87e84ea1d34fd4937", + "https://esm.sh/v135/option-t@49.1.0/denonext/maybe.js": "663a6bdadc7b26347b55486297c3b89ff6c31098a730b8f9bd3065609df6442b", "https://raw.githubusercontent.com/scrapbox-jp/types/0.9.0/base.ts": "63bd526c652edbdd43aa86b119828b87828b25dfbb997ce521437cc6fc40fbcb", "https://raw.githubusercontent.com/scrapbox-jp/types/0.9.0/baseStore.ts": "b05ed2f2de45d187afc8f9fbb4767b209929cc6924f67d1d6f252eb3bb64c32f", "https://raw.githubusercontent.com/scrapbox-jp/types/0.9.0/blocks.ts": "d37c6bb33b600ece0bfeb5b63246300c567bdf38e559adf01e1d26c967f8999f", @@ -191,11 +204,13 @@ }, "workspace": { "dependencies": [ + "jsr:@core/unknownutil@^3.18.1", "jsr:@cosense/types@0.10", "jsr:@progfay/scrapbox-parser@9", "jsr:@std/assert@1", "jsr:@std/async@1", - "jsr:@std/testing@0" + "jsr:@std/testing@0", + "npm:option-t@^49.1.0" ] } } diff --git a/deps/hash.ts b/deps/hash.ts deleted file mode 100644 index 9958823..0000000 --- a/deps/hash.ts +++ /dev/null @@ -1 +0,0 @@ -export { Md5 } from "@std/hash"; diff --git a/deps/scrapbox-rest.ts b/deps/scrapbox-rest.ts deleted file mode 100644 index 660036a..0000000 --- a/deps/scrapbox-rest.ts +++ /dev/null @@ -1,29 +0,0 @@ -export type { - BadRequestError, - BaseLine as Line, - ErrorLike, - ExportedData, - GuestUser, - ImportedData, - InvalidURLError, - MemberProject, - MemberUser, - NoQueryError, - NotFoundError, - NotLoggedInError, - NotMemberError, - NotMemberProject, - NotPrivilegeError, - Page, - PageList, - PageSnapshotList, - PageSnapshotResult, - ProjectId, - ProjectResponse, - ProjectSearchResult, - SearchedTitle, - SearchResult, - SessionError, - Snapshot, - TweetInfo, -} from "@cosense/types/rest"; diff --git a/deps/scrapbox.ts b/deps/scrapbox.ts deleted file mode 100644 index d3e17f1..0000000 --- a/deps/scrapbox.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type { - BaseLine, - BaseStore, - Line, - Scrapbox, -} from "@cosense/types/userscript"; -export * from "@progfay/scrapbox-parser"; diff --git a/deps/testing.ts b/deps/testing.ts deleted file mode 100644 index a165a85..0000000 --- a/deps/testing.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "@std/assert"; -export * from "@std/testing/snapshot"; diff --git a/ensure.ts b/ensure.ts deleted file mode 100644 index d002b0a..0000000 --- a/ensure.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const ensureArray: ( - value: unknown, - name: string, -) => asserts value is T[] = (value, name) => { - if (Array.isArray(value)) return; - throw new TypeError(`"${name}" must be an array but actual is "${value}"`); -}; diff --git a/is.ts b/is.ts deleted file mode 100644 index eacc3b5..0000000 --- a/is.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ErrorLike } from "./deps/scrapbox-rest.ts"; -// These code are based on https://deno.land/x/unknownutil@v1.1.0/is.ts - -export const isNone = (value: unknown): value is undefined | null => - value === null || value === undefined; -export const isString = (value: unknown): value is string => - typeof value === "string"; -export const isNumber = (value: unknown): value is number => - typeof value === "number"; -export const isArray = (value: unknown): value is T[] => - Array.isArray(value); -export const isObject = (value: unknown): value is Record => - typeof value === "object" && value !== null; - -export const isErrorLike = (e: unknown): e is ErrorLike => { - if (!isObject(e)) return false; - return (e.name === undefined || typeof e.name === "string") && - typeof e.message === "string"; -}; - -/** 与えられたobjectもしくはJSONテキストをErrorLikeに変換できるかどうか試す - * - * @param e 試したいobjectもしくはテキスト - * @return 変換できなかったら`false`を返す。変換できたらそのobjectを返す - */ -export const tryToErrorLike = (e: unknown): false | ErrorLike => { - try { - const json = typeof e === "string" ? JSON.parse(e) : e; - if (!isErrorLike(json)) return false; - return json; - } catch (e2: unknown) { - if (e2 instanceof SyntaxError) return false; - // JSONのparse error以外はそのまま投げる - throw e2; - } -}; diff --git a/parseAbsoluteLink.ts b/parseAbsoluteLink.ts index 576bf5b..de83d44 100644 --- a/parseAbsoluteLink.ts +++ b/parseAbsoluteLink.ts @@ -1,4 +1,9 @@ -import type { LinkNode } from "./deps/scrapbox.ts"; +/** Parse `LinkNode` of [@progfay/scrapbox-parser](https://jsr.io/@progfay/scrapbox-parser) in detail + * + * @module + */ + +import type { LinkNode } from "@progfay/scrapbox-parser"; import { parseYoutube } from "./parser/youtube.ts"; import { parseVimeo } from "./parser/vimeo.ts"; import { parseSpotify } from "./parser/spotify.ts"; @@ -112,10 +117,10 @@ export const parseAbsoluteLink = ( return { type: "absoluteLink", content, href, ...baseLink }; }; -type AudioURL = `${string}.${"mp3" | "ogg" | "wav" | "acc"}`; +export type AudioURL = `${string}.${"mp3" | "ogg" | "wav" | "acc"}`; const isAudioURL = (url: string): url is AudioURL => /\.(?:mp3|ogg|wav|aac)$/.test(url); -type VideoURL = `${string}.${"mp4" | "webm"}`; +export type VideoURL = `${string}.${"mp4" | "webm"}`; const isVideoURL = (url: string): url is VideoURL => /\.(?:mp4|webm)$/.test(url); diff --git a/parser/anchor-fm.test.ts b/parser/anchor-fm.test.ts index fdbe9af..d31fe4b 100644 --- a/parser/anchor-fm.test.ts +++ b/parser/anchor-fm.test.ts @@ -1,5 +1,5 @@ import { parseAnchorFM } from "./anchor-fm.ts"; -import { assertSnapshot } from "../deps/testing.ts"; +import { assertSnapshot } from "@std/testing/snapshot"; Deno.test("spotify links", async (t) => { await t.step("is", async (t) => { diff --git a/parser/spotify.test.ts b/parser/spotify.test.ts index cc4c780..9a9b34e 100644 --- a/parser/spotify.test.ts +++ b/parser/spotify.test.ts @@ -1,5 +1,5 @@ import { parseSpotify } from "./spotify.ts"; -import { assertSnapshot } from "../deps/testing.ts"; +import { assertSnapshot } from "@std/testing/snapshot"; Deno.test("spotify links", async (t) => { await t.step("is", async (t) => { diff --git a/parser/vimeo.test.ts b/parser/vimeo.test.ts deleted file mode 100644 index 80bf1bc..0000000 --- a/parser/vimeo.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { parseVimeo } from "./vimeo.ts"; -import { assertSnapshot } from "../deps/testing.ts"; - -Deno.test("vimeo links", async (t) => { - await t.step("is", async (t) => { - await assertSnapshot( - t, - parseVimeo("https://vimeo.com/121284607"), - ); - }); - - await t.step("is not", async (t) => { - await assertSnapshot( - t, - parseVimeo( - "https://gyazo.com/da78df293f9e83a74b5402411e2f2e01", - ), - ); - await assertSnapshot( - t, - parseVimeo( - "ほげほげ", - ), - ); - await assertSnapshot( - t, - parseVimeo( - "https://yourtube.com/watch?v=rafere", - ), - ); - await assertSnapshot( - t, - parseVimeo( - "https://example.com", - ), - ); - }); -}); diff --git a/parser/youtube.test.ts b/parser/youtube.test.ts index 10c96a8..184310a 100644 --- a/parser/youtube.test.ts +++ b/parser/youtube.test.ts @@ -1,5 +1,5 @@ import { parseYoutube } from "./youtube.ts"; -import { assertSnapshot } from "../deps/testing.ts"; +import { assertSnapshot } from "@std/testing/snapshot"; Deno.test("youtube links", async (t) => { await t.step("is", async (t) => { diff --git a/range.test.ts b/range.test.ts index 18288a3..047841f 100644 --- a/range.test.ts +++ b/range.test.ts @@ -1,5 +1,5 @@ import { range } from "./range.ts"; -import { assertStrictEquals } from "./deps/testing.ts"; +import { assertStrictEquals } from "@std/assert"; Deno.test("range()", () => { let count = 0; diff --git a/rest/auth.ts b/rest/auth.ts index 8d9173a..483f9ad 100644 --- a/rest/auth.ts +++ b/rest/auth.ts @@ -1,4 +1,7 @@ +import { createOk, mapForResult, type Result } from "option-t/plain_result"; import { getProfile } from "./profile.ts"; +import type { HTTPError } from "./responseIntoResult.ts"; +import type { AbortError, NetworkError } from "./robustFetch.ts"; import type { BaseOptions } from "./util.ts"; /** HTTP headerのCookieに入れる文字列を作る @@ -13,10 +16,12 @@ export const cookie = (sid: string): string => `connect.sid=${sid}`; */ export const getCSRFToken = async ( init?: BaseOptions, -): Promise => { +): Promise> => // deno-lint-ignore no-explicit-any - if ((globalThis as any)._csrf) return (globalThis as any)._csrf; - - const user = await getProfile(init); - return user.csrfToken; -}; + (globalThis as any)._csrf + // deno-lint-ignore no-explicit-any + ? createOk((globalThis as any)._csrf) + : mapForResult( + await getProfile(init), + (user) => user.csrfToken, + ); diff --git a/rest/error.ts b/rest/error.ts deleted file mode 100644 index 3b19eaa..0000000 --- a/rest/error.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ErrorLike } from "../deps/scrapbox-rest.ts"; -import { tryToErrorLike } from "../is.ts"; - -/** 想定されない応答が帰ってきたときに投げる例外 */ -export class UnexpectedResponseError extends Error { - name = "UnexpectedResponseError"; - - constructor( - public response: Response, - ) { - super( - `${response.status} ${response.statusText} when fetching ${response.url}`, - ); - - // @ts-ignore only available on V8 - if (Error.captureStackTrace) { - // @ts-ignore only available on V8 - Error.captureStackTrace(this, UnexpectedResponseError); - } - } -} - -/** 失敗した要求からエラー情報を取り出す */ -export const makeError = async ( - res: Response, -): Promise<{ ok: false; value: T }> => { - const response = res.clone(); - const text = await response.text(); - const value = tryToErrorLike(text); - if (!value) { - throw new UnexpectedResponseError(response); - } - return { - ok: false, - value: value as T, - }; -}; diff --git a/rest/getCodeBlock.ts b/rest/getCodeBlock.ts index 79cfde0..03e2146 100644 --- a/rest/getCodeBlock.ts +++ b/rest/getCodeBlock.ts @@ -2,11 +2,20 @@ import type { NotFoundError, NotLoggedInError, NotMemberError, -} from "../deps/scrapbox-rest.ts"; +} from "@cosense/types/rest"; import { cookie } from "./auth.ts"; -import { makeError } from "./error.ts"; import { encodeTitleURI } from "../title.ts"; -import { type BaseOptions, type Result, setDefaults } from "./util.ts"; +import { type BaseOptions, setDefaults } from "./util.ts"; +import { + isErr, + mapAsyncForResult, + mapErrAsyncForResult, + type Result, + unwrapOk, +} from "option-t/plain_result"; +import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; +import { parseHTTPError } from "./parseHTTPError.ts"; +import type { AbortError, NetworkError } from "./robustFetch.ts"; const getCodeBlock_toRequest: GetCodeBlock["toRequest"] = ( project, @@ -15,28 +24,30 @@ const getCodeBlock_toRequest: GetCodeBlock["toRequest"] = ( options, ) => { const { sid, hostName } = setDefaults(options ?? {}); - const path = `https://${hostName}/api/code/${project}/${ - encodeTitleURI(title) - }/${encodeTitleURI(filename)}`; return new Request( - path, + `https://${hostName}/api/code/${project}/${encodeTitleURI(title)}/${ + encodeTitleURI(filename) + }`, sid ? { headers: { Cookie: cookie(sid) } } : undefined, ); }; -const getCodeBlock_fromResponse: GetCodeBlock["fromResponse"] = async (res) => { - if (!res.ok) { - return res.status === 404 && - res.headers.get("Content-Type")?.includes?.("text/plain") - ? { - ok: false, - value: { name: "NotFoundError", message: "Code block is not found" }, - } - : makeError(res); - } - return { ok: true, value: await res.text() }; -}; +const getCodeBlock_fromResponse: GetCodeBlock["fromResponse"] = async (res) => + mapAsyncForResult( + await mapErrAsyncForResult( + responseIntoResult(res), + async (res) => + res.response.status === 404 && + res.response.headers.get("Content-Type")?.includes?.("text/plain") + ? { name: "NotFoundError", message: "Code block is not found" } + : (await parseHTTPError(res, [ + "NotLoggedInError", + "NotMemberError", + ])) ?? res, + ), + (res) => res.text(), + ); export interface GetCodeBlock { /** /api/code/:project/:title/:filename の要求を組み立てる @@ -62,7 +73,7 @@ export interface GetCodeBlock { fromResponse: (res: Response) => Promise< Result< string, - NotFoundError | NotLoggedInError | NotMemberError + NotFoundError | NotLoggedInError | NotMemberError | HTTPError > >; @@ -74,7 +85,12 @@ export interface GetCodeBlock { ): Promise< Result< string, - NotFoundError | NotLoggedInError | NotMemberError + | NotFoundError + | NotLoggedInError + | NotMemberError + | NetworkError + | AbortError + | HTTPError > >; } @@ -92,10 +108,9 @@ export const getCodeBlock: GetCodeBlock = async ( filename, options, ) => { - const { fetch } = setDefaults(options ?? {}); const req = getCodeBlock_toRequest(project, title, filename, options); - const res = await fetch(req); - return await getCodeBlock_fromResponse(res); + const res = await setDefaults(options ?? {}).fetch(req); + return isErr(res) ? res : getCodeBlock_fromResponse(unwrapOk(res)); }; getCodeBlock.toRequest = getCodeBlock_toRequest; diff --git a/rest/getCodeBlocks.test.ts b/rest/getCodeBlocks.test.ts index 9c4967b..dee7774 100644 --- a/rest/getCodeBlocks.test.ts +++ b/rest/getCodeBlocks.test.ts @@ -1,6 +1,8 @@ -import type { Line } from "../deps/scrapbox-rest.ts"; -import { assertEquals, assertSnapshot } from "../deps/testing.ts"; +import type { Page } from "@cosense/types/rest"; +import { assertEquals } from "@std/assert"; +import { assertSnapshot } from "@std/testing/snapshot"; import { getCodeBlocks } from "./getCodeBlocks.ts"; +type Line = Page["lines"][number]; // https://scrapbox.io/takker/コードブロック記法 const project = "takker"; @@ -229,11 +231,11 @@ const sample: Line[] = [ Deno.test("getCodeBlocks()", async (t) => { await assertSnapshot( t, - await getCodeBlocks({ project, title, lines: sample }), + getCodeBlocks({ project, title, lines: sample }), ); await t.step("filename filter", async (st) => { const filename = "インデント.md"; - const codeBlocks = await getCodeBlocks({ project, title, lines: sample }, { + const codeBlocks = getCodeBlocks({ project, title, lines: sample }, { filename, }); const yet = []; @@ -245,7 +247,7 @@ Deno.test("getCodeBlocks()", async (t) => { }); await t.step("language name filter", async (st) => { const lang = "py"; - const codeBlocks = await getCodeBlocks({ project, title, lines: sample }, { + const codeBlocks = getCodeBlocks({ project, title, lines: sample }, { lang, }); const yet = []; @@ -257,7 +259,7 @@ Deno.test("getCodeBlocks()", async (t) => { }); await t.step("title line ID filter", async (st) => { const titleLineId = "63b7b1261280f00000c9bc34"; - const codeBlocks = await getCodeBlocks({ project, title, lines: sample }, { + const codeBlocks = getCodeBlocks({ project, title, lines: sample }, { titleLineId, }); const yet = []; diff --git a/rest/getCodeBlocks.ts b/rest/getCodeBlocks.ts index a377f3f..db127ba 100644 --- a/rest/getCodeBlocks.ts +++ b/rest/getCodeBlocks.ts @@ -1,9 +1,9 @@ -import type { Line } from "../deps/scrapbox-rest.ts"; -import { pull } from "../browser/websocket/pull.ts"; +import type { Page } from "@cosense/types/rest"; import { type CodeTitle, extractFromCodeTitle, } from "../browser/websocket/_codeBlock.ts"; +type Line = Page["lines"][number]; /** pull()から取れる情報で構成したコードブロックの最低限の情報 */ export interface TinyCodeBlock { @@ -45,11 +45,10 @@ export interface GetCodeBlocksFilter { * @param filter 取得するコードブロックを絞り込むfilter * @return コードブロックの配列 */ -export const getCodeBlocks = async ( - target: { project: string; title: string; lines?: Line[] }, +export const getCodeBlocks = ( + target: { project: string; title: string; lines: Line[] }, filter?: GetCodeBlocksFilter, -): Promise => { - const lines = await getLines(target); +): TinyCodeBlock[] => { const codeBlocks: TinyCodeBlock[] = []; let currentCode: CodeTitle & { @@ -61,7 +60,7 @@ export const getCodeBlocks = async ( lang: "", indent: 0, }; - for (const line of lines) { + for (const line of target.lines) { if (currentCode.isCodeBlock) { const body = extractFromCodeBody(line.text, currentCode.indent); if (body === null) { @@ -93,30 +92,16 @@ export const getCodeBlocks = async ( return codeBlocks.filter((codeBlock) => isMatchFilter(codeBlock, filter)); }; -/** targetを`Line[]`に変換する */ -const getLines = async ( - target: { project: string; title: string; lines?: Line[] }, -): Promise => { - if (target.lines !== undefined) { - return target.lines; - } else { - const head = await pull(target.project, target.title); - return head.lines; - } -}; - /** コードブロックのフィルターに合致しているか検証する */ const isMatchFilter = ( codeBlock: TinyCodeBlock, filter?: GetCodeBlocksFilter, -): boolean => { - const equals = (a: unknown, b: unknown) => !a || a === b; - return ( - equals(filter?.filename, codeBlock.filename) && - equals(filter?.lang, codeBlock.lang) && - equals(filter?.titleLineId, codeBlock.titleLine.id) - ); -}; +): boolean => + equals(filter?.filename, codeBlock.filename) && + equals(filter?.lang, codeBlock.lang) && + equals(filter?.titleLineId, codeBlock.titleLine.id); + +const equals = (a: unknown, b: unknown): boolean => !a || a === b; /** 行テキストがコードブロックの一部であればそのテキストを、そうでなければnullを返す * diff --git a/rest/getGyazoToken.ts b/rest/getGyazoToken.ts index 462c04d..df05a46 100644 --- a/rest/getGyazoToken.ts +++ b/rest/getGyazoToken.ts @@ -1,7 +1,16 @@ -import type { NotLoggedInError } from "../deps/scrapbox-rest.ts"; +import { + isErr, + mapAsyncForResult, + mapErrAsyncForResult, + type Result, + unwrapOk, +} from "option-t/plain_result"; +import type { NotLoggedInError } from "@cosense/types/rest"; import { cookie } from "./auth.ts"; -import { makeError } from "./error.ts"; -import { type BaseOptions, type Result, setDefaults } from "./util.ts"; +import { parseHTTPError } from "./parseHTTPError.ts"; +import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; +import type { AbortError, NetworkError } from "./robustFetch.ts"; +import { type BaseOptions, setDefaults } from "./util.ts"; export interface GetGyazoTokenOptions extends BaseOptions { /** Gyazo Teamsのチーム名 @@ -21,10 +30,10 @@ export const getGyazoToken = async ( ): Promise< Result< string | undefined, - NotLoggedInError + NotLoggedInError | NetworkError | AbortError | HTTPError > > => { - const { sid, hostName, gyazoTeamsName } = setDefaults(init ?? {}); + const { fetch, sid, hostName, gyazoTeamsName } = setDefaults(init ?? {}); const req = new Request( `https://${hostName}/api/login/gyazo/oauth-upload/token${ gyazoTeamsName ? `?gyazoTeamsName=${gyazoTeamsName}` : "" @@ -33,10 +42,14 @@ export const getGyazoToken = async ( ); const res = await fetch(req); - if (!res.ok) { - return makeError(res); - } + if (isErr(res)) return res; - const { token } = (await res.json()) as { token?: string }; - return { ok: true, value: token }; + return mapAsyncForResult( + await mapErrAsyncForResult( + responseIntoResult(unwrapOk(res)), + async (error) => + (await parseHTTPError(error, ["NotLoggedInError"])) ?? error, + ), + (res) => res.json().then((json) => json.token as string | undefined), + ); }; diff --git a/rest/getTweetInfo.ts b/rest/getTweetInfo.ts index b2e859f..4962cac 100644 --- a/rest/getTweetInfo.ts +++ b/rest/getTweetInfo.ts @@ -1,12 +1,23 @@ +import { + isErr, + mapAsyncForResult, + mapErrAsyncForResult, + orElseAsyncForResult, + type Result, + unwrapOk, +} from "option-t/plain_result"; +import { toResultOkFromMaybe } from "option-t/maybe"; import type { BadRequestError, InvalidURLError, SessionError, TweetInfo, -} from "../deps/scrapbox-rest.ts"; +} from "@cosense/types/rest"; import { cookie, getCSRFToken } from "./auth.ts"; -import { makeError } from "./error.ts"; -import { type ExtendedOptions, type Result, setDefaults } from "./util.ts"; +import { parseHTTPError } from "./parseHTTPError.ts"; +import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; +import type { AbortError, NetworkError } from "./robustFetch.ts"; +import { type ExtendedOptions, setDefaults } from "./util.ts"; /** 指定したTweetの情報を取得する * @@ -23,18 +34,28 @@ export const getTweetInfo = async ( | SessionError | InvalidURLError | BadRequestError + | NetworkError + | AbortError + | HTTPError > > => { const { sid, hostName, fetch, csrf } = setDefaults(init ?? {}); + + const csrfResult = await orElseAsyncForResult( + toResultOkFromMaybe(csrf), + () => getCSRFToken(init), + ); + if (isErr(csrfResult)) return csrfResult; + const req = new Request( `https://${hostName}/api/embed-text/twitter?url=${ - encodeURIComponent(url.toString()) + encodeURIComponent(`${url}`) }`, { method: "POST", headers: { "Content-Type": "application/json;charset=utf-8", - "X-CSRF-TOKEN": csrf ?? await getCSRFToken(init), + "X-CSRF-TOKEN": unwrapOk(csrfResult), ...(sid ? { Cookie: cookie(sid) } : {}), }, body: JSON.stringify({ timeout: 3000 }), @@ -42,20 +63,25 @@ export const getTweetInfo = async ( ); const res = await fetch(req); + if (isErr(res)) return res; - if (!res.ok) { - if (res.status === 422) { - return { - ok: false, - value: { + return mapErrAsyncForResult( + await mapAsyncForResult( + responseIntoResult(unwrapOk(res)), + (res) => res.json() as Promise, + ), + async (res) => { + if (res.response.status === 422) { + return { name: "InvalidURLError", - message: (await res.json()).message as string, - }, - }; - } - return makeError(res); - } - - const tweet = (await res.json()) as TweetInfo; - return { ok: true, value: tweet }; + message: (await res.response.json()).message as string, + }; + } + const parsed = await parseHTTPError(res, [ + "SessionError", + "BadRequestError", + ]); + return parsed ?? res; + }, + ); }; diff --git a/rest/getWebPageTitle.ts b/rest/getWebPageTitle.ts index ec7c4f7..503a943 100644 --- a/rest/getWebPageTitle.ts +++ b/rest/getWebPageTitle.ts @@ -1,11 +1,22 @@ +import { + isErr, + mapAsyncForResult, + mapErrAsyncForResult, + orElseAsyncForResult, + type Result, + unwrapOk, +} from "option-t/plain_result"; +import { toResultOkFromMaybe } from "option-t/maybe"; import type { BadRequestError, InvalidURLError, SessionError, -} from "../deps/scrapbox-rest.ts"; +} from "@cosense/types/rest"; import { cookie, getCSRFToken } from "./auth.ts"; -import { makeError } from "./error.ts"; -import { type ExtendedOptions, type Result, setDefaults } from "./util.ts"; +import { parseHTTPError } from "./parseHTTPError.ts"; +import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; +import type { AbortError, NetworkError } from "./robustFetch.ts"; +import { type ExtendedOptions, setDefaults } from "./util.ts"; /** 指定したURLのweb pageのtitleをscrapboxのserver経由で取得する * @@ -22,19 +33,28 @@ export const getWebPageTitle = async ( | SessionError | InvalidURLError | BadRequestError + | NetworkError + | AbortError + | HTTPError > > => { const { sid, hostName, fetch, csrf } = setDefaults(init ?? {}); + const csrfResult = await orElseAsyncForResult( + toResultOkFromMaybe(csrf), + () => getCSRFToken(init), + ); + if (isErr(csrfResult)) return csrfResult; + const req = new Request( `https://${hostName}/api/embed-text/url?url=${ - encodeURIComponent(url.toString()) + encodeURIComponent(`${url}`) }`, { method: "POST", headers: { "Content-Type": "application/json;charset=utf-8", - "X-CSRF-TOKEN": csrf ?? await getCSRFToken(init), + "X-CSRF-TOKEN": unwrapOk(csrfResult), ...(sid ? { Cookie: cookie(sid) } : {}), }, body: JSON.stringify({ timeout: 3000 }), @@ -42,20 +62,21 @@ export const getWebPageTitle = async ( ); const res = await fetch(req); + if (isErr(res)) return res; - if (!res.ok) { - if (res.status === 422) { - return { - ok: false, - value: { - name: "InvalidURLError", - message: (await res.json()).message as string, - }, - }; - } - return makeError(res); - } - - const { title } = (await res.json()) as { title: string }; - return { ok: true, value: title }; + return mapAsyncForResult( + await mapErrAsyncForResult( + responseIntoResult(unwrapOk(res)), + async (error) => + (await parseHTTPError(error, [ + "SessionError", + "BadRequestError", + "InvalidURLError", + ])) ?? error, + ), + async (res) => { + const { title } = (await res.json()) as { title: string }; + return title; + }, + ); }; diff --git a/rest/link.ts b/rest/link.ts index 546537c..b8fbc24 100644 --- a/rest/link.ts +++ b/rest/link.ts @@ -1,12 +1,22 @@ +import { + createOk, + isErr, + mapAsyncForResult, + mapErrAsyncForResult, + type Result, + unwrapOk, +} from "option-t/plain_result"; import type { ErrorLike, NotFoundError, NotLoggedInError, SearchedTitle, -} from "../deps/scrapbox-rest.ts"; +} from "@cosense/types/rest"; import { cookie } from "./auth.ts"; -import { makeError } from "./error.ts"; -import { type BaseOptions, type Result, setDefaults } from "./util.ts"; +import { parseHTTPError } from "./parseHTTPError.ts"; +import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; +import type { AbortError, NetworkError } from "./robustFetch.ts"; +import { type BaseOptions, setDefaults } from "./util.ts"; /** 不正なfollowingIdを渡されたときに発生するエラー */ export interface InvalidFollowingIdError extends ErrorLike { @@ -18,6 +28,11 @@ export interface GetLinksOptions extends BaseOptions { followingId?: string; } +export interface GetLinksResult { + pages: SearchedTitle[]; + followingId: string; +} + /** 指定したprojectのリンクデータを取得する * * @param project データを取得したいproject @@ -26,10 +41,15 @@ export const getLinks = async ( project: string, options?: GetLinksOptions, ): Promise< - Result<{ - pages: SearchedTitle[]; - followingId: string; - }, NotFoundError | NotLoggedInError | InvalidFollowingIdError> + Result< + GetLinksResult, + | NotFoundError + | NotLoggedInError + | InvalidFollowingIdError + | NetworkError + | AbortError + | HTTPError + > > => { const { sid, hostName, fetch, followingId } = setDefaults(options ?? {}); @@ -41,26 +61,28 @@ export const getLinks = async ( ); const res = await fetch(req); + if (isErr(res)) return res; - if (!res.ok) { - if (res.status === 422) { - return { - ok: false, - value: { - name: "InvalidFollowingIdError", - message: await res.text(), - }, - }; - } - - return makeError(res); - } - - const pages = (await res.json()) as SearchedTitle[]; - return { - ok: true, - value: { pages, followingId: res.headers.get("X-following-id") ?? "" }, - }; + return mapAsyncForResult( + await mapErrAsyncForResult( + responseIntoResult(unwrapOk(res)), + async (error) => + error.response.status === 422 + ? { + name: "InvalidFollowingIdError", + message: await error.response.text(), + } as InvalidFollowingIdError + : (await parseHTTPError(error, [ + "NotFoundError", + "NotLoggedInError", + ])) ?? error, + ), + (res) => + res.json().then((pages: SearchedTitle[]) => ({ + pages, + followingId: res.headers.get("X-following-id") ?? "", + })), + ); }; /** 指定したprojectの全てのリンクデータを取得する @@ -70,57 +92,64 @@ export const getLinks = async ( * @param project データを取得したいproject * @return 認証が通らなかったらエラーを、通ったらasync generatorを返す */ -export const readLinksBulk = async ( +export async function* readLinksBulk( project: string, options?: BaseOptions, -): Promise< - | NotFoundError - | NotLoggedInError - | InvalidFollowingIdError - | AsyncGenerator -> => { - const first = await getLinks(project, options); - if (!first.ok) return first.value; - - return async function* () { - yield first.value.pages; - let followingId = first.value.followingId; - - while (followingId) { - const result = await getLinks(project, { followingId, ...options }); - - // すでに認証は通っているので、ここでエラーになるはずがない - if (!result.ok) { - throw new Error("The authorization cannot be unavailable"); - } - yield result.value.pages; - followingId = result.value.followingId; +): AsyncGenerator< + Result< + SearchedTitle[], + | NotFoundError + | NotLoggedInError + | InvalidFollowingIdError + | NetworkError + | AbortError + | HTTPError + >, + void, + unknown +> { + let followingId: string | undefined; + do { + const result = await getLinks(project, { followingId, ...options }); + if (isErr(result)) { + yield result; + return; } - }(); -}; + const res = unwrapOk(result); + + yield createOk(res.pages); + followingId = res.followingId; + } while (followingId); +} /** 指定したprojectの全てのリンクデータを取得し、一つづつ返す * * @param project データを取得したいproject * @return 認証が通らなかったらエラーを、通ったらasync generatorを返す */ -export const readLinks = async ( +export async function* readLinks( project: string, options?: BaseOptions, -): Promise< - | NotFoundError - | NotLoggedInError - | InvalidFollowingIdError - | AsyncGenerator -> => { - const reader = await readLinksBulk(project, options); - if ("name" in reader) return reader; - - return async function* () { - for await (const titles of reader) { - for (const title of titles) { - yield title; - } +): AsyncGenerator< + Result< + SearchedTitle, + | NotFoundError + | NotLoggedInError + | InvalidFollowingIdError + | NetworkError + | AbortError + | HTTPError + >, + void, + unknown +> { + for await (const result of readLinksBulk(project, options)) { + if (isErr(result)) { + yield result; + return; } - }(); -}; + for (const page of unwrapOk(result)) { + yield createOk(page); + } + } +} diff --git a/rest/mod.ts b/rest/mod.ts index 849fefe..46e6960 100644 --- a/rest/mod.ts +++ b/rest/mod.ts @@ -12,7 +12,7 @@ export * from "./getTweetInfo.ts"; export * from "./getGyazoToken.ts"; export * from "./auth.ts"; export * from "./util.ts"; -export * from "./error.ts"; +export * from "./parseHTTPError.ts"; export * from "./getCodeBlocks.ts"; export * from "./getCodeBlock.ts"; export * from "./uploadToGCS.ts"; diff --git a/rest/page-data.ts b/rest/page-data.ts index 21a0224..cddea9b 100644 --- a/rest/page-data.ts +++ b/rest/page-data.ts @@ -1,19 +1,25 @@ +import { + createOk, + isErr, + mapAsyncForResult, + mapErrAsyncForResult, + orElseAsyncForResult, + type Result, + unwrapOk, +} from "option-t/plain_result"; +import { toResultOkFromMaybe } from "option-t/maybe"; import type { - ErrorLike, ExportedData, ImportedData, NotFoundError, NotLoggedInError, NotPrivilegeError, -} from "../deps/scrapbox-rest.ts"; +} from "@cosense/types/rest"; import { cookie, getCSRFToken } from "./auth.ts"; -import { makeError } from "./error.ts"; -import { - type BaseOptions, - type ExtendedOptions, - type Result, - setDefaults, -} from "./util.ts"; +import { parseHTTPError } from "./parseHTTPError.ts"; +import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; +import type { AbortError, NetworkError } from "./robustFetch.ts"; +import { type BaseOptions, type ExtendedOptions, setDefaults } from "./util.ts"; /** projectにページをインポートする * * @param project - インポート先のprojectの名前 @@ -24,11 +30,9 @@ export const importPages = async ( data: ImportedData, init: ExtendedOptions, ): Promise< - Result + Result > => { - if (data.pages.length === 0) { - return { ok: true, value: "No pages to import." }; - } + if (data.pages.length === 0) return createOk("No pages to import."); const { sid, hostName, fetch, csrf } = setDefaults(init ?? {}); const formData = new FormData(); @@ -39,6 +43,13 @@ export const importPages = async ( }), ); formData.append("name", "undefined"); + + const csrfResult = await orElseAsyncForResult( + toResultOkFromMaybe(csrf), + () => getCSRFToken(init), + ); + if (isErr(csrfResult)) return csrfResult; + const req = new Request( `https://${hostName}/api/page-data/import/${project}.json`, { @@ -46,19 +57,19 @@ export const importPages = async ( headers: { ...(sid ? { Cookie: cookie(sid) } : {}), Accept: "application/json, text/plain, */*", - "X-CSRF-TOKEN": csrf ?? await getCSRFToken(init), + "X-CSRF-TOKEN": unwrapOk(csrfResult), }, body: formData, }, ); const res = await fetch(req); - if (!res.ok) { - return makeError(res); - } + if (isErr(res)) return res; - const { message } = (await res.json()) as { message: string }; - return { ok: true, value: message }; + return mapAsyncForResult( + responseIntoResult(unwrapOk(res)), + async (res) => (await res.json()).message as string, + ); }; /** `exportPages`の認証情報 */ @@ -76,7 +87,12 @@ export const exportPages = async ( ): Promise< Result< ExportedData, - NotFoundError | NotPrivilegeError | NotLoggedInError + | NotFoundError + | NotPrivilegeError + | NotLoggedInError + | NetworkError + | AbortError + | HTTPError > > => { const { sid, hostName, fetch, metadata } = setDefaults(init ?? {}); @@ -86,11 +102,18 @@ export const exportPages = async ( sid ? { headers: { Cookie: cookie(sid) } } : undefined, ); const res = await fetch(req); + if (isErr(res)) return res; - if (!res.ok) { - return makeError(res); - } - - const value = (await res.json()) as ExportedData; - return { ok: true, value }; + return mapAsyncForResult( + await mapErrAsyncForResult( + responseIntoResult(unwrapOk(res)), + async (error) => + (await parseHTTPError(error, [ + "NotFoundError", + "NotLoggedInError", + "NotPrivilegeError", + ])) ?? error, + ), + (res) => res.json() as Promise>, + ); }; diff --git a/rest/pages.test.ts b/rest/pages.test.ts index 3c7f1b9..bbec994 100644 --- a/rest/pages.test.ts +++ b/rest/pages.test.ts @@ -1,5 +1,5 @@ import { getPage, listPages } from "./pages.ts"; -import { assertSnapshot } from "../deps/testing.ts"; +import { assertSnapshot } from "@std/testing/snapshot"; Deno.test("getPage", async (t) => { await assertSnapshot( diff --git a/rest/pages.ts b/rest/pages.ts index c8921c0..1094851 100644 --- a/rest/pages.ts +++ b/rest/pages.ts @@ -5,11 +5,20 @@ import type { NotMemberError, Page, PageList, -} from "../deps/scrapbox-rest.ts"; +} from "@cosense/types/rest"; import { cookie } from "./auth.ts"; -import { makeError } from "./error.ts"; +import { parseHTTPError } from "./parseHTTPError.ts"; import { encodeTitleURI } from "../title.ts"; -import { type BaseOptions, type Result, setDefaults } from "./util.ts"; +import { type BaseOptions, setDefaults } from "./util.ts"; +import { + andThenAsyncForResult, + mapAsyncForResult, + mapErrAsyncForResult, + type Result, +} from "option-t/plain_result"; +import { unwrapOrForMaybe } from "option-t/maybe"; +import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; +import type { AbortError, NetworkError } from "./robustFetch.ts"; /** Options for `getPage()` */ export interface GetPageOption extends BaseOptions { @@ -35,32 +44,43 @@ const getPage_toRequest: GetPage["toRequest"] = ( for (const id of projects ?? []) { params.append("projects", id); } - const path = `https://${hostName}/api/pages/${project}/${ - encodeTitleURI(title) - }?${params.toString()}`; return new Request( - path, + `https://${hostName}/api/pages/${project}/${ + encodeTitleURI(title) + }?${params}`, sid ? { headers: { Cookie: cookie(sid) } } : undefined, ); }; -const getPage_fromResponse: GetPage["fromResponse"] = async (res) => { - if (!res.ok) { - if (res.status === 414) { - return { - ok: false, - value: { +const getPage_fromResponse: GetPage["fromResponse"] = async (res) => + mapErrAsyncForResult( + await mapAsyncForResult( + responseIntoResult(res), + (res) => res.json() as Promise, + ), + async ( + error, + ) => { + if (error.response.status === 414) { + return { name: "TooLongURIError", message: "project ids may be too much.", - }, - }; - } - return makeError(res); - } - const value = (await res.json()) as Page; - return { ok: true, value }; -}; + }; + } + + return unwrapOrForMaybe< + NotFoundError | NotLoggedInError | NotMemberError | HTTPError + >( + await parseHTTPError(error, [ + "NotFoundError", + "NotLoggedInError", + "NotMemberError", + ]), + error, + ); + }, + ); export interface GetPage { /** /api/pages/:project/:title の要求を組み立てる @@ -84,14 +104,24 @@ export interface GetPage { fromResponse: (res: Response) => Promise< Result< Page, - NotFoundError | NotLoggedInError | NotMemberError | TooLongURIError + | NotFoundError + | NotLoggedInError + | NotMemberError + | TooLongURIError + | HTTPError > >; (project: string, title: string, options?: GetPageOption): Promise< Result< Page, - NotFoundError | NotLoggedInError | NotMemberError | TooLongURIError + | NotFoundError + | NotLoggedInError + | NotMemberError + | TooLongURIError + | HTTPError + | NetworkError + | AbortError > >; } @@ -106,12 +136,23 @@ export const getPage: GetPage = async ( project, title, options, -) => { - const { fetch } = setDefaults(options ?? {}); - const req = getPage_toRequest(project, title, options); - const res = await fetch(req); - return await getPage_fromResponse(res); -}; +) => + andThenAsyncForResult< + Response, + Page, + | NotFoundError + | NotLoggedInError + | NotMemberError + | TooLongURIError + | HTTPError + | NetworkError + | AbortError + >( + await setDefaults(options ?? {}).fetch( + getPage_toRequest(project, title, options), + ), + (input) => getPage_fromResponse(input), + ); getPage.toRequest = getPage_toRequest; getPage.fromResponse = getPage_fromResponse; @@ -163,14 +204,19 @@ export interface ListPages { fromResponse: (res: Response) => Promise< Result< PageList, - NotFoundError | NotLoggedInError | NotMemberError + NotFoundError | NotLoggedInError | NotMemberError | HTTPError > >; (project: string, options?: ListPagesOption): Promise< Result< PageList, - NotFoundError | NotLoggedInError | NotMemberError + | NotFoundError + | NotLoggedInError + | NotMemberError + | NetworkError + | AbortError + | HTTPError > >; } @@ -183,21 +229,31 @@ const listPages_toRequest: ListPages["toRequest"] = (project, options) => { if (sort !== undefined) params.append("sort", sort); if (limit !== undefined) params.append("limit", `${limit}`); if (skip !== undefined) params.append("skip", `${skip}`); - const path = `https://${hostName}/api/pages/${project}?${params.toString()}`; return new Request( - path, + `https://${hostName}/api/pages/${project}?${params}`, sid ? { headers: { Cookie: cookie(sid) } } : undefined, ); }; -const listPages_fromResponse: ListPages["fromResponse"] = async (res) => { - if (!res.ok) { - return makeError(res); - } - const value = (await res.json()) as PageList; - return { ok: true, value }; -}; +const listPages_fromResponse: ListPages["fromResponse"] = async (res) => + mapErrAsyncForResult( + await mapAsyncForResult( + responseIntoResult(res), + (res) => res.json() as Promise, + ), + async (error) => + unwrapOrForMaybe< + NotFoundError | NotLoggedInError | NotMemberError | HTTPError + >( + await parseHTTPError(error, [ + "NotFoundError", + "NotLoggedInError", + "NotMemberError", + ]), + error, + ), + ); /** 指定したprojectのページを一覧する * @@ -207,11 +263,22 @@ const listPages_fromResponse: ListPages["fromResponse"] = async (res) => { export const listPages: ListPages = async ( project, options?, -) => { - const { fetch } = setDefaults(options ?? {}); - const res = await fetch(listPages_toRequest(project, options)); - return await listPages_fromResponse(res); -}; +) => + andThenAsyncForResult< + Response, + PageList, + | NotFoundError + | NotLoggedInError + | NotMemberError + | NetworkError + | AbortError + | HTTPError + >( + await setDefaults(options ?? {})?.fetch( + listPages_toRequest(project, options), + ), + listPages_fromResponse, + ); listPages.toRequest = listPages_toRequest; listPages.fromResponse = listPages_fromResponse; diff --git a/rest/parseHTTPError.ts b/rest/parseHTTPError.ts new file mode 100644 index 0000000..cec4fbf --- /dev/null +++ b/rest/parseHTTPError.ts @@ -0,0 +1,94 @@ +import type { + BadRequestError, + InvalidURLError, + NoQueryError, + NotFoundError, + NotLoggedInError, + NotMemberError, + NotPrivilegeError, + SessionError, +} from "@cosense/types/rest"; +import type { Maybe } from "option-t/maybe"; +import { + isArrayOf, + isLiteralOneOf, + isRecord, + isString, +} from "@core/unknownutil"; +import type { HTTPError } from "./responseIntoResult.ts"; + +export interface RESTfullAPIErrorMap { + BadRequestError: BadRequestError; + NotFoundError: NotFoundError; + NotLoggedInError: NotLoggedInError; + NotMemberError: NotMemberError; + SessionError: SessionError; + InvalidURLError: InvalidURLError; + NoQueryError: NoQueryError; + NotPrivilegeError: NotPrivilegeError; +} + +/** 失敗した要求からエラー情報を取り出す */ +export const parseHTTPError = async < + ErrorNames extends keyof RESTfullAPIErrorMap, +>( + error: HTTPError, + errorNames: ErrorNames[], +): Promise> => { + const res = error.response.clone(); + const isErrorNames = isLiteralOneOf(errorNames); + try { + const json: unknown = await res.json(); + if (!isRecord(json)) return; + if (res.status === 422) { + if (!isString(json.message)) return; + for ( + const name of [ + "NoQueryError", + "InvalidURLError", + ] as (keyof RESTfullAPIErrorMap)[] + ) { + if (!(errorNames as string[]).includes(name)) continue; + return { + name, + message: json.message, + } as unknown as RESTfullAPIErrorMap[ErrorNames]; + } + } + if (!isErrorNames(json.name)) return; + if (!isString(json.message)) return; + if (json.name === "NotLoggedInError") { + if (!isRecord(json.detals)) return; + if (!isString(json.detals.project)) return; + if (!isArrayOf(isLoginStrategies)(json.detals.loginStrategies)) return; + return { + name: json.name, + message: json.message, + details: { + project: json.detals.project, + loginStrategies: json.detals.loginStrategies, + }, + } as unknown as RESTfullAPIErrorMap[ErrorNames]; + } + return { + name: json.name, + message: json.message, + } as unknown as RESTfullAPIErrorMap[ErrorNames]; + } catch (e: unknown) { + if (e instanceof SyntaxError) return; + // JSONのparse error以外はそのまま投げる + throw e; + } +}; + +const isLoginStrategies = isLiteralOneOf( + [ + "google", + "github", + "microsoft", + "gyazo", + "email", + "saml", + "easy-trial", + ] as const, +); diff --git a/rest/profile.ts b/rest/profile.ts index 81de3cf..67e2996 100644 --- a/rest/profile.ts +++ b/rest/profile.ts @@ -1,23 +1,63 @@ -import type { GuestUser, MemberUser } from "../deps/scrapbox-rest.ts"; +import { + isErr, + mapAsyncForResult, + type Result, + unwrapOk, +} from "option-t/plain_result"; +import type { GuestUser, MemberUser } from "@cosense/types/rest"; import { cookie } from "./auth.ts"; +import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; +import type { AbortError, NetworkError } from "./robustFetch.ts"; import { type BaseOptions, setDefaults } from "./util.ts"; -import { UnexpectedResponseError } from "./error.ts"; - -/** get user profile - * - * @param init connect.sid etc. - */ -export const getProfile = async ( - init?: BaseOptions, -): Promise => { - const { sid, hostName, fetch } = setDefaults(init ?? {}); - const request = new Request( + +export interface GetProfile { + /** /api/users/me の要求を組み立てる + * + * @param init connect.sid etc. + * @return request + */ + toRequest: (init?: BaseOptions) => Request; + + /** get the user profile from the given response + * + * @param res response + * @return user profile + */ + fromResponse: ( + res: Response, + ) => Promise< + Result + >; + + (init?: BaseOptions): Promise< + Result + >; +} + +const getProfile_toRequest: GetProfile["toRequest"] = ( + init, +) => { + const { sid, hostName } = setDefaults(init ?? {}); + return new Request( `https://${hostName}/api/users/me`, sid ? { headers: { Cookie: cookie(sid) } } : undefined, ); - const response = await fetch(request); - if (!response.ok) { - throw new UnexpectedResponseError(response); - } - return (await response.json()) as MemberUser | GuestUser; }; + +const getProfile_fromResponse: GetProfile["fromResponse"] = (response) => + mapAsyncForResult( + responseIntoResult(response), + async (res) => (await res.json()) as MemberUser | GuestUser, + ); + +export const getProfile: GetProfile = async (init) => { + const { fetch, ...rest } = setDefaults(init ?? {}); + + const resResult = await fetch(getProfile_toRequest(rest)); + return isErr(resResult) + ? resResult + : getProfile_fromResponse(unwrapOk(resResult)); +}; + +getProfile.toRequest = getProfile_toRequest; +getProfile.fromResponse = getProfile_fromResponse; diff --git a/rest/project.test.ts b/rest/project.test.ts index 044fd81..3e51935 100644 --- a/rest/project.test.ts +++ b/rest/project.test.ts @@ -1,5 +1,5 @@ import { getProject, listProjects } from "./project.ts"; -import { assertSnapshot } from "../deps/testing.ts"; +import { assertSnapshot } from "@std/testing/snapshot"; Deno.test("getProject", async (t) => { await assertSnapshot( diff --git a/rest/project.ts b/rest/project.ts index 5faea8a..cf0feab 100644 --- a/rest/project.ts +++ b/rest/project.ts @@ -1,3 +1,10 @@ +import { + isErr, + mapAsyncForResult, + mapErrAsyncForResult, + type Result, + unwrapOk, +} from "option-t/plain_result"; import type { MemberProject, NotFoundError, @@ -6,10 +13,12 @@ import type { NotMemberProject, ProjectId, ProjectResponse, -} from "../deps/scrapbox-rest.ts"; +} from "@cosense/types/rest"; import { cookie } from "./auth.ts"; -import { makeError } from "./error.ts"; -import { type BaseOptions, type Result, setDefaults } from "./util.ts"; +import { parseHTTPError } from "./parseHTTPError.ts"; +import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; +import type { AbortError, NetworkError } from "./robustFetch.ts"; +import { type BaseOptions, setDefaults } from "./util.ts"; export interface GetProject { /** /api/project/:project の要求を組み立てる @@ -31,14 +40,24 @@ export interface GetProject { fromResponse: (res: Response) => Promise< Result< MemberProject | NotMemberProject, - NotFoundError | NotMemberError | NotLoggedInError + | NotFoundError + | NotMemberError + | NotLoggedInError + | NetworkError + | AbortError + | HTTPError > >; (project: string, options?: BaseOptions): Promise< Result< MemberProject | NotMemberProject, - NotFoundError | NotMemberError | NotLoggedInError + | NotFoundError + | NotMemberError + | NotLoggedInError + | NetworkError + | AbortError + | HTTPError > >; } @@ -52,14 +71,19 @@ const getProject_toRequest: GetProject["toRequest"] = (project, init) => { ); }; -const getProject_fromResponse: GetProject["fromResponse"] = async (res) => { - if (!res.ok) { - return makeError(res); - } - - const value = (await res.json()) as MemberProject | NotMemberProject; - return { ok: true, value }; -}; +const getProject_fromResponse: GetProject["fromResponse"] = async (res) => + mapAsyncForResult( + await mapErrAsyncForResult( + responseIntoResult(res), + async (error) => + (await parseHTTPError(error, [ + "NotFoundError", + "NotLoggedInError", + "NotMemberError", + ])) ?? error, + ), + (res) => res.json() as Promise, + ); /** get the project information * @@ -74,8 +98,9 @@ export const getProject: GetProject = async ( const req = getProject_toRequest(project, init); const res = await fetch(req); + if (isErr(res)) return res; - return getProject_fromResponse(res); + return getProject_fromResponse(unwrapOk(res)); }; getProject.toRequest = getProject_toRequest; @@ -100,12 +125,22 @@ export interface ListProjects { */ fromResponse: ( res: Response, - ) => Promise>; + ) => Promise< + Result< + ProjectResponse, + NotLoggedInError | NetworkError | AbortError | HTTPError + > + >; ( projectIds: ProjectId[], init?: BaseOptions, - ): Promise>; + ): Promise< + Result< + ProjectResponse, + NotLoggedInError | NetworkError | AbortError | HTTPError + > + >; } const ListProject_toRequest: ListProjects["toRequest"] = (projectIds, init) => { @@ -121,14 +156,15 @@ const ListProject_toRequest: ListProjects["toRequest"] = (projectIds, init) => { ); }; -const ListProject_fromResponse: ListProjects["fromResponse"] = async (res) => { - if (!res.ok) { - return makeError(res); - } - - const value = (await res.json()) as ProjectResponse; - return { ok: true, value }; -}; +const ListProject_fromResponse: ListProjects["fromResponse"] = async (res) => + mapAsyncForResult( + await mapErrAsyncForResult( + responseIntoResult(res), + async (error) => + (await parseHTTPError(error, ["NotLoggedInError"])) ?? error, + ), + (res) => res.json() as Promise, + ); /** list the projects' information * @@ -142,8 +178,9 @@ export const listProjects: ListProjects = async ( const { fetch } = setDefaults(init ?? {}); const res = await fetch(ListProject_toRequest(projectIds, init)); + if (isErr(res)) return res; - return ListProject_fromResponse(res); + return ListProject_fromResponse(unwrapOk(res)); }; listProjects.toRequest = ListProject_toRequest; diff --git a/rest/replaceLinks.ts b/rest/replaceLinks.ts index 3023be8..8ff2c36 100644 --- a/rest/replaceLinks.ts +++ b/rest/replaceLinks.ts @@ -1,11 +1,22 @@ +import { + isErr, + mapAsyncForResult, + mapErrAsyncForResult, + orElseAsyncForResult, + type Result, + unwrapOk, +} from "option-t/plain_result"; +import { toResultOkFromMaybe } from "option-t/maybe"; import type { NotFoundError, NotLoggedInError, NotMemberError, -} from "../deps/scrapbox-rest.ts"; +} from "@cosense/types/rest"; import { cookie, getCSRFToken } from "./auth.ts"; -import { makeError } from "./error.ts"; -import { type ExtendedOptions, type Result, setDefaults } from "./util.ts"; +import { parseHTTPError } from "./parseHTTPError.ts"; +import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; +import type { AbortError, NetworkError } from "./robustFetch.ts"; +import { type ExtendedOptions, setDefaults } from "./util.ts"; /** 指定したproject内の全てのリンクを書き換える * @@ -26,31 +37,52 @@ export const replaceLinks = async ( ): Promise< Result< number, - NotFoundError | NotLoggedInError | NotMemberError + | NotFoundError + | NotLoggedInError + | NotMemberError + | NetworkError + | AbortError + | HTTPError > > => { const { sid, hostName, fetch, csrf } = setDefaults(init ?? {}); + const csrfResult = await orElseAsyncForResult( + toResultOkFromMaybe(csrf), + () => getCSRFToken(init), + ); + if (isErr(csrfResult)) return csrfResult; + const req = new Request( `https://${hostName}/api/pages/${project}/replace/links`, { method: "POST", headers: { "Content-Type": "application/json;charset=utf-8", - "X-CSRF-TOKEN": csrf ?? await getCSRFToken(init), + "X-CSRF-TOKEN": unwrapOk(csrfResult), ...(sid ? { Cookie: cookie(sid) } : {}), }, body: JSON.stringify({ from, to }), }, ); - const res = await fetch(req); - - if (!res.ok) { - return makeError(res); - } + const resResult = await fetch(req); + if (isErr(resResult)) return resResult; - // 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") }; + return mapAsyncForResult( + await mapErrAsyncForResult( + responseIntoResult(unwrapOk(resResult)), + async (error) => + (await parseHTTPError(error, [ + "NotFoundError", + "NotLoggedInError", + "NotMemberError", + ])) ?? error, + ), + async (res) => { + // messageには"2 pages have been successfully updated!"というような文字列が入っているはず + const { message } = (await res.json()) as { message: string }; + return parseInt(message.match(/\d+/)?.[0] ?? "0"); + }, + ); }; diff --git a/rest/responseIntoResult.ts b/rest/responseIntoResult.ts new file mode 100644 index 0000000..280230d --- /dev/null +++ b/rest/responseIntoResult.ts @@ -0,0 +1,18 @@ +import { createErr, createOk, type Result } from "option-t/plain_result"; + +export interface HTTPError { + name: "HTTPError"; + message: string; + response: Response; +} + +export const responseIntoResult = ( + response: Response, +): Result => + !response.ok + ? createErr({ + name: "HTTPError", + message: `${response.status} ${response.statusText}`, + response, + }) + : createOk(response); diff --git a/rest/robustFetch.ts b/rest/robustFetch.ts new file mode 100644 index 0000000..b786343 --- /dev/null +++ b/rest/robustFetch.ts @@ -0,0 +1,48 @@ +import { createErr, createOk, type Result } from "option-t/plain_result"; + +export interface NetworkError { + name: "NetworkError"; + message: string; + request: Request; +} + +export interface AbortError { + name: "AbortError"; + message: string; + request: Request; +} + +export type RobustFetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise>; + +/** + * Performs a network request using the Fetch API. + * + * @param input - The resource URL or a `Request` object. + * @param init - An optional object containing request options. + * @returns A promise that resolves to a `Result` object containing either a `Response` or an error. + */ +export const robustFetch: RobustFetch = async (input, init) => { + const request = new Request(input, init); + try { + return createOk(await globalThis.fetch(request)); + } catch (e: unknown) { + if (e instanceof DOMException && e.name === "AbortError") { + return createErr({ + name: "AbortError", + message: e.message, + request, + }); + } + if (e instanceof TypeError) { + return createErr({ + name: "NetworkError", + message: e.message, + request, + }); + } + throw e; + } +}; diff --git a/rest/search.ts b/rest/search.ts index ff4bab6..205ea0e 100644 --- a/rest/search.ts +++ b/rest/search.ts @@ -1,3 +1,10 @@ +import { + isErr, + mapAsyncForResult, + mapErrAsyncForResult, + type Result, + unwrapOk, +} from "option-t/plain_result"; import type { NoQueryError, NotFoundError, @@ -5,10 +12,12 @@ import type { NotMemberError, ProjectSearchResult, SearchResult, -} from "../deps/scrapbox-rest.ts"; +} from "@cosense/types/rest"; import { cookie } from "./auth.ts"; -import { makeError } from "./error.ts"; -import { type BaseOptions, type Result, setDefaults } from "./util.ts"; +import { parseHTTPError } from "./parseHTTPError.ts"; +import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; +import type { AbortError, NetworkError } from "./robustFetch.ts"; +import { type BaseOptions, setDefaults } from "./util.ts"; /** search a project for pages * @@ -23,7 +32,13 @@ export const searchForPages = async ( ): Promise< Result< SearchResult, - NotFoundError | NotMemberError | NotLoggedInError | NoQueryError + | NotFoundError + | NotMemberError + | NotLoggedInError + | NoQueryError + | NetworkError + | AbortError + | HTTPError > > => { const { sid, hostName, fetch } = setDefaults(init ?? {}); @@ -36,22 +51,21 @@ export const searchForPages = async ( ); const res = await fetch(req); - - if (!res.ok) { - if (res.status === 422) { - return { - ok: false, - value: { - name: "NoQueryError", - message: (await res.json()).message, - }, - }; - } - return makeError(res); - } - - const value = (await res.json()) as SearchResult; - return { ok: true, value }; + if (isErr(res)) return res; + + return mapAsyncForResult( + await mapErrAsyncForResult( + responseIntoResult(unwrapOk(res)), + async (error) => + (await parseHTTPError(error, [ + "NotFoundError", + "NotLoggedInError", + "NotMemberError", + "NoQueryError", + ])) ?? error, + ), + (res) => res.json() as Promise, + ); }; /** search for joined projects @@ -65,7 +79,7 @@ export const searchForJoinedProjects = async ( ): Promise< Result< ProjectSearchResult, - NotLoggedInError | NoQueryError + NotLoggedInError | NoQueryError | NetworkError | AbortError | HTTPError > > => { const { sid, hostName, fetch } = setDefaults(init ?? {}); @@ -78,22 +92,19 @@ export const searchForJoinedProjects = async ( ); const res = await fetch(req); - - if (!res.ok) { - if (res.status === 422) { - return { - ok: false, - value: { - name: "NoQueryError", - message: (await res.json()).message, - }, - }; - } - return makeError(res); - } - - const value = (await res.json()) as ProjectSearchResult; - return { ok: true, value }; + if (isErr(res)) return res; + + return mapAsyncForResult( + await mapErrAsyncForResult( + responseIntoResult(unwrapOk(res)), + async (error) => + (await parseHTTPError(error, [ + "NotLoggedInError", + "NoQueryError", + ])) ?? error, + ), + (res) => res.json() as Promise, + ); }; /** search for watch list @@ -113,7 +124,7 @@ export const searchForWatchList = async ( ): Promise< Result< ProjectSearchResult, - NotLoggedInError | NoQueryError + NotLoggedInError | NoQueryError | NetworkError | AbortError | HTTPError > > => { const { sid, hostName, fetch } = setDefaults(init ?? {}); @@ -130,21 +141,17 @@ export const searchForWatchList = async ( ); const res = await fetch(req); - - if (!res.ok) { - if (res.status === 422) { - return { - ok: false, - value: { - // 本当はproject idが不正なときも422 errorになる - name: "NoQueryError", - message: (await res.json()).message, - }, - }; - } - return makeError(res); - } - - const value = (await res.json()) as ProjectSearchResult; - return { ok: true, value }; + if (isErr(res)) return res; + + return mapAsyncForResult( + await mapErrAsyncForResult( + responseIntoResult(unwrapOk(res)), + async (error) => + (await parseHTTPError(error, [ + "NotLoggedInError", + "NoQueryError", + ])) ?? error, + ), + (res) => res.json() as Promise, + ); }; diff --git a/rest/snapshot.ts b/rest/snapshot.ts index 1a9314b..47ced0e 100644 --- a/rest/snapshot.ts +++ b/rest/snapshot.ts @@ -5,10 +5,19 @@ import type { NotMemberError, PageSnapshotList, PageSnapshotResult, -} from "../deps/scrapbox-rest.ts"; +} from "@cosense/types/rest"; import { cookie } from "./auth.ts"; -import { type BaseOptions, type Result, setDefaults } from "./util.ts"; -import { makeError } from "./error.ts"; +import { type BaseOptions, setDefaults } from "./util.ts"; +import { parseHTTPError } from "./parseHTTPError.ts"; +import { + isErr, + mapAsyncForResult, + mapErrAsyncForResult, + type Result, + unwrapOk, +} from "option-t/plain_result"; +import type { AbortError, NetworkError } from "./robustFetch.ts"; +import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; /** 不正な`timestampId`を渡されたときに発生するエラー */ export interface InvalidPageSnapshotIdError extends ErrorLike { @@ -31,6 +40,9 @@ export const getSnapshot = async ( | NotLoggedInError | NotMemberError | InvalidPageSnapshotIdError + | NetworkError + | AbortError + | HTTPError > > => { const { sid, hostName, fetch } = setDefaults(options ?? {}); @@ -41,22 +53,25 @@ export const getSnapshot = async ( ); const res = await fetch(req); + if (isErr(res)) return res; - if (!res.ok) { - if (res.status === 422) { - return { - ok: false, - value: { - name: "InvalidPageSnapshotIdError", - message: await res.text(), - }, - }; - } - return makeError(res); - } - - const value = (await res.json()) as PageSnapshotResult; - return { ok: true, value }; + return mapAsyncForResult( + await mapErrAsyncForResult( + responseIntoResult(unwrapOk(res)), + async (error) => + error.response.status === 422 + ? { + name: "InvalidPageSnapshotIdError", + message: await error.response.text(), + } + : (await parseHTTPError(error, [ + "NotFoundError", + "NotLoggedInError", + "NotMemberError", + ])) ?? error, + ), + (res) => res.json() as Promise, + ); }; /** @@ -73,7 +88,15 @@ export const getTimestampIds = async ( pageId: string, options?: BaseOptions, ): Promise< - Result + Result< + PageSnapshotList, + | NotFoundError + | NotLoggedInError + | NotMemberError + | NetworkError + | AbortError + | HTTPError + > > => { const { sid, hostName, fetch } = setDefaults(options ?? {}); @@ -83,11 +106,18 @@ export const getTimestampIds = async ( ); const res = await fetch(req); + if (isErr(res)) return res; - if (!res.ok) { - return makeError(res); - } - - const value = (await res.json()) as PageSnapshotList; - return { ok: true, value }; + return mapAsyncForResult( + await mapErrAsyncForResult( + responseIntoResult(unwrapOk(res)), + async (error) => + (await parseHTTPError(error, [ + "NotFoundError", + "NotLoggedInError", + "NotMemberError", + ])) ?? error, + ), + (res) => res.json() as Promise, + ); }; diff --git a/rest/table.ts b/rest/table.ts index 2eb29c1..91e195f 100644 --- a/rest/table.ts +++ b/rest/table.ts @@ -2,11 +2,20 @@ import type { NotFoundError, NotLoggedInError, NotMemberError, -} from "../deps/scrapbox-rest.ts"; +} from "@cosense/types/rest"; import { cookie } from "./auth.ts"; -import { makeError } from "./error.ts"; import { encodeTitleURI } from "../title.ts"; -import { type BaseOptions, type Result, setDefaults } from "./util.ts"; +import { type BaseOptions, setDefaults } from "./util.ts"; +import { + isErr, + mapAsyncForResult, + mapErrAsyncForResult, + type Result, + unwrapOk, +} from "option-t/plain_result"; +import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; +import { parseHTTPError } from "./parseHTTPError.ts"; +import type { AbortError, NetworkError } from "./robustFetch.ts"; const getTable_toRequest: GetTable["toRequest"] = ( project, @@ -25,22 +34,24 @@ const getTable_toRequest: GetTable["toRequest"] = ( ); }; -const getTable_fromResponse: GetTable["fromResponse"] = async (res) => { - if (!res.ok) { - if (res.status === 404) { - // responseが空文字の時があるので、自前で組み立てる - return { - ok: false, - value: { - name: "NotFoundError", - message: "Table not found.", - }, - }; - } - return makeError(res); - } - return { ok: true, value: await res.text() }; -}; +const getTable_fromResponse: GetTable["fromResponse"] = async (res) => + mapAsyncForResult( + await mapErrAsyncForResult( + responseIntoResult(res), + async (error) => + error.response.status === 404 + ? { + // responseが空文字の時があるので、自前で組み立てる + name: "NotFoundError", + message: "Table not found.", + } + : (await parseHTTPError(error, [ + "NotLoggedInError", + "NotMemberError", + ])) ?? error, + ), + (res) => res.text(), + ); export interface GetTable { /** /api/table/:project/:title/:filename.csv の要求を組み立てる @@ -66,7 +77,12 @@ export interface GetTable { fromResponse: (res: Response) => Promise< Result< string, - NotFoundError | NotLoggedInError | NotMemberError + | NotFoundError + | NotLoggedInError + | NotMemberError + | NetworkError + | AbortError + | HTTPError > >; @@ -78,7 +94,12 @@ export interface GetTable { ): Promise< Result< string, - NotFoundError | NotLoggedInError | NotMemberError + | NotFoundError + | NotLoggedInError + | NotMemberError + | NetworkError + | AbortError + | HTTPError > >; } @@ -99,7 +120,8 @@ export const getTable: GetTable = async ( const { fetch } = setDefaults(options ?? {}); const req = getTable_toRequest(project, title, filename, options); const res = await fetch(req); - return await getTable_fromResponse(res); + if (isErr(res)) return res; + return await getTable_fromResponse(unwrapOk(res)); }; getTable.toRequest = getTable_toRequest; diff --git a/rest/uploadToGCS.ts b/rest/uploadToGCS.ts index b714fc8..98c287f 100644 --- a/rest/uploadToGCS.ts +++ b/rest/uploadToGCS.ts @@ -1,13 +1,20 @@ import { cookie, getCSRFToken } from "./auth.ts"; +import { type BaseOptions, type ExtendedOptions, setDefaults } from "./util.ts"; +import type { ErrorLike, NotFoundError } from "@cosense/types/rest"; +import { Md5 } from "@std/hash"; import { - type BaseOptions, - type ExtendedOptions, + createOk, + isErr, + mapAsyncForResult, + mapErrAsyncForResult, + mapForResult, + orElseAsyncForResult, type Result, - setDefaults, -} from "./util.ts"; -import { makeError, UnexpectedResponseError } from "./error.ts"; -import type { ErrorLike, NotFoundError } from "../deps/scrapbox-rest.ts"; -import { Md5 } from "../deps/hash.ts"; + unwrapOk, +} from "option-t/plain_result"; +import { toResultOkFromMaybe } from "option-t/maybe"; +import type { AbortError, NetworkError } from "./robustFetch.ts"; +import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; /** uploadしたファイルのメタデータ */ export interface GCSFile { @@ -29,15 +36,24 @@ export const uploadToGCS = async ( projectId: string, options?: ExtendedOptions, ): Promise< - Result + Result< + GCSFile, + | GCSError + | NotFoundError + | FileCapacityError + | NetworkError + | AbortError + | HTTPError + > > => { - const md5 = new Md5().update(await file.arrayBuffer()).toString(); + const md5 = `${new Md5().update(await file.arrayBuffer())}`; const res = await uploadRequest(file, projectId, md5, options); - if (!res.ok) return res; - if ("embedUrl" in res.value) return { ok: true, value: res.value }; - const res2 = await upload(res.value.signedUrl, file, options); - if (!res2.ok) return res2; - return verify(projectId, res.value.fileId, md5, options); + if (isErr(res)) return res; + const fileOrRequest = unwrapOk(res); + if ("embedUrl" in fileOrRequest) return createOk(fileOrRequest); + const result = await upload(fileOrRequest.signedUrl, file, options); + if (isErr(result)) return result; + return verify(projectId, fileOrRequest.fileId, md5, options); }; /** 容量を使い切ったときに発生するerror */ @@ -65,7 +81,12 @@ const uploadRequest = async ( projectId: string, md5: string, init?: ExtendedOptions, -): Promise> => { +): Promise< + Result< + GCSFile | UploadRequest, + FileCapacityError | NetworkError | AbortError | HTTPError + > +> => { const { sid, hostName, fetch, csrf } = setDefaults(init ?? {}); const body = { md5, @@ -73,7 +94,11 @@ const uploadRequest = async ( contentType: file.type, name: file.name, }; - const token = csrf ?? await getCSRFToken(); + const csrfResult = await orElseAsyncForResult( + toResultOkFromMaybe(csrf), + () => getCSRFToken(init), + ); + if (isErr(csrfResult)) return csrfResult; const req = new Request( `https://${hostName}/api/gcs/${projectId}/upload-request`, { @@ -81,16 +106,27 @@ const uploadRequest = async ( body: JSON.stringify(body), headers: { "Content-Type": "application/json;charset=utf-8", - "X-CSRF-TOKEN": token, + "X-CSRF-TOKEN": unwrapOk(csrfResult), ...(sid ? { Cookie: cookie(sid) } : {}), }, }, ); const res = await fetch(req); - if (!res.ok) { - return makeError(res); - } - return { ok: true, value: await res.json() }; + if (isErr(res)) return res; + + return mapAsyncForResult( + await mapErrAsyncForResult( + responseIntoResult(unwrapOk(res)), + async (error) => + error.response.status === 402 + ? { + name: "FileCapacityError", + message: (await error.response.json()).message, + } as FileCapacityError + : error, + ), + (res) => res.json(), + ); }; /** Google Cloud Storage XML APIのerror @@ -106,7 +142,9 @@ const upload = async ( signedUrl: string, file: File, init?: BaseOptions, -): Promise> => { +): Promise< + Result +> => { const { sid, fetch } = setDefaults(init ?? {}); const res = await fetch( signedUrl, @@ -119,19 +157,21 @@ const upload = async ( }, }, ); - if (!res.ok) { - if (res.headers.get("Content-Type")?.includes?.("/xml")) { - return { - ok: false, - value: { - name: "GCSError", - message: await res.text(), - }, - }; - } - throw new UnexpectedResponseError(res); - } - return { ok: true, value: undefined }; + if (isErr(res)) return res; + + return mapForResult( + await mapErrAsyncForResult( + responseIntoResult(unwrapOk(res)), + async (error) => + error.response.headers.get("Content-Type")?.includes?.("/xml") + ? { + name: "GCSError", + message: await error.response.text(), + } as GCSError + : error, + ), + () => undefined, + ); }; /** uploadしたファイルの整合性を確認する */ @@ -140,9 +180,15 @@ const verify = async ( fileId: string, md5: string, init?: ExtendedOptions, -): Promise> => { +): Promise< + Result +> => { const { sid, hostName, fetch, csrf } = setDefaults(init ?? {}); - const token = csrf ?? await getCSRFToken(); + const csrfResult = await orElseAsyncForResult( + toResultOkFromMaybe(csrf), + () => getCSRFToken(init), + ); + if (isErr(csrfResult)) return csrfResult; const req = new Request( `https://${hostName}/api/gcs/${projectId}/verify`, { @@ -150,25 +196,26 @@ const verify = async ( body: JSON.stringify({ md5, fileId }), headers: { "Content-Type": "application/json;charset=utf-8", - "X-CSRF-TOKEN": token, + "X-CSRF-TOKEN": unwrapOk(csrfResult), ...(sid ? { Cookie: cookie(sid) } : {}), }, }, ); + const res = await fetch(req); - if (!res.ok) { - try { - if (res.status === 404) { - return { - ok: false, - value: { name: "NotFoundError", message: (await res.json()).message }, - }; - } - } catch (_) { - throw new UnexpectedResponseError(res); - } - throw new UnexpectedResponseError(res); - } - const gcs = await res.json(); - return { ok: true, value: gcs }; + if (isErr(res)) return res; + + return mapAsyncForResult( + await mapErrAsyncForResult( + responseIntoResult(unwrapOk(res)), + async (error) => + error.response.status === 404 + ? { + name: "NotFoundError", + message: (await error.response.json()).message, + } as NotFoundError + : error, + ), + (res) => res.json(), + ); }; diff --git a/rest/util.ts b/rest/util.ts index 2cd2a2a..be673f3 100644 --- a/rest/util.ts +++ b/rest/util.ts @@ -1,14 +1,4 @@ -/** 正常値と異常値を格納する型 */ -export type Result = { ok: true; value: T } | { ok: false; value: E }; - -/** networkからdataをとってくる処理 - * - * interfaceは`fetch()`と同じ - */ -export type Fetch = ( - input: string | Request, - init?: RequestInit, -) => Promise; +import { type RobustFetch, robustFetch } from "./robustFetch.ts"; /** 全てのREST APIに共通するopitons */ export interface BaseOptions { @@ -22,7 +12,7 @@ export interface BaseOptions { * * @default fetch */ - fetch?: Fetch; + fetch?: RobustFetch; /** REST APIのdomain * @@ -45,7 +35,6 @@ export interface ExtendedOptions extends BaseOptions { export const setDefaults = ( options: T, ): Omit & Required> => { - const { fetch = globalThis.fetch, hostName = "scrapbox.io", ...rest } = - options; + const { fetch = robustFetch, hostName = "scrapbox.io", ...rest } = options; return { fetch, hostName, ...rest }; }; diff --git a/text.test.ts b/text.test.ts index bb97108..c3c544f 100644 --- a/text.test.ts +++ b/text.test.ts @@ -1,5 +1,5 @@ import { getIndentCount } from "./text.ts"; -import { assertStrictEquals } from "./deps/testing.ts"; +import { assertStrictEquals } from "@std/assert"; Deno.test("getIndentCount()", () => { assertStrictEquals(getIndentCount("sample text "), 0); diff --git a/text.ts b/text.ts index 450109c..69668e0 100644 --- a/text.ts +++ b/text.ts @@ -1,4 +1,4 @@ -import { isString } from "./is.ts"; +import { isString } from "@core/unknownutil"; /** インデント数を数える */ export const getIndentCount = (text: string): number => diff --git a/title.test.ts b/title.test.ts index be71ce0..135c791 100644 --- a/title.test.ts +++ b/title.test.ts @@ -4,7 +4,7 @@ import { toReadableTitleURI, toTitleLc, } from "./title.ts"; -import { assertStrictEquals } from "./deps/testing.ts"; +import { assertStrictEquals } from "@std/assert"; Deno.test("toTitleLc()", async (t) => { await t.step("` ` -> `_`", () => {