diff --git a/.gitignore b/.gitignore index 7b69f39..ca902fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ local_test/ +coverage/ +docs/ diff --git a/README.md b/README.md index 04a6de1..7163e23 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,132 @@ [![test](https://github.com/takker99/scrapbox-userscript-std/workflows/ci/badge.svg)](https://github.com/takker99/scrapbox-userscript-std/actions?query=workflow%3Aci) UNOFFICIAL standard module for Scrapbox UserScript + +## Getting Started + +This library serves as an unofficial standard library for developing Scrapbox +userscripts. It provides a comprehensive set of utilities for interacting with +Scrapbox's features, including REST API operations, browser interactions, and +common utilities. + +### Installation + +1. Bundler Configuration This library is distributed through JSR (JavaScript + Registry) and requires a bundler configuration. Follow these steps: + +a. Configure your bundler to use JSR: + +- For esbuild: Add JSR to your import map +- For other bundlers: Refer to your bundler's JSR integration documentation + +b. Import the library: + +```typescript +// Import commonly used functions +import { getPage } from "jsr:@cosense/std/rest"; +import { parseAbsoluteLink } from "jsr:@cosense/std"; + +// Import specific modules (recommended) +import { getLinks } from "jsr:@cosense/std/rest"; +import { press } from "jsr:@cosense/std/browser/dom"; +import { getLines } from "jsr:@cosense/std/browser/dom"; +``` + +2. Module Organization The library is organized into the following main modules: + +- `rest/`: API operations for Scrapbox REST endpoints + - Page operations + - Project management + - User authentication +- `browser/`: Browser-side operations + - DOM manipulation + - WebSocket communication + - Event handling +- Core utilities: + - `title`: Title parsing and formatting + - `parseAbsoluteLink`: External link analysis + - Additional helper functions + +## Examples + +### Basic Usage + +1. Retrieving Page Information + +```typescript +// Get page content and metadata +import { getPage } from "jsr:@cosense/std/rest"; + +const result = await getPage("projectName", "pageName"); +if (result.ok) { + const page = result.val; + console.log("Page title:", page.title); + console.log("Page content:", page.lines.map((line) => line.text)); + console.log("Page descriptions:", page.descriptions.join("\n")); +} +``` + +2. DOM Operations + +```typescript +// Interact with the current page's content +import { getLines, press } from "jsr:@cosense/std/browser/dom"; + +// Get all lines from the current page +const lines = getLines(); +console.log(lines.map((line) => line.text)); + +// Simulate keyboard input +await press("Enter"); // Add a new line +await press("Tab"); // Indent the line +``` + +3. External Link Analysis + +```typescript +// Parse external links (YouTube, Spotify, etc.) +import { parseAbsoluteLink } from "jsr:@cosense/std"; +import type { LinkNode } from "@progfay/scrapbox-parser"; + +// Create a link node with absolute path type +const link = { + type: "link" as const, + pathType: "absolute" as const, + href: "https://www.youtube.com/watch?v=xxxxx", + content: "", + raw: "[https://www.youtube.com/watch?v=xxxxx]", +} satisfies LinkNode & { pathType: "absolute" }; + +// Parse and handle different link types +const parsed = parseAbsoluteLink(link); +if (parsed?.type === "youtube") { + // Handle YouTube links + console.log("YouTube video ID:", parsed.href.split("v=")[1]); + const params = new URLSearchParams(parsed.href.split("?")[1]); + const start = params.get("t"); + if (start) { + console.log("Video timestamp:", start); + } +} else if (parsed?.type === "spotify") { + // Handle Spotify links + const match = parsed.href.match(/spotify\.com\/track\/([^?]+)/); + if (match) { + console.log("Spotify track ID:", match[1]); + } +} +``` + +### Important Notes + +- This library requires a bundler for use in userscripts +- Full TypeScript support with type definitions included +- Comprehensive error handling with type-safe responses +- For more examples and use cases, see the + [Examples](https://github.com/takker99/scrapbox-userscript-std/tree/main/examples) + directory + +### Additional Resources + +- [JSR Package Page](https://jsr.io/@cosense/std) +- [API Documentation](https://jsr.io/@cosense/std/doc) +- [GitHub Repository](https://github.com/takker99/scrapbox-userscript-std) diff --git a/browser/dom/_internal.ts b/browser/dom/_internal.ts index 66c6c95..b22257e 100644 --- a/browser/dom/_internal.ts +++ b/browser/dom/_internal.ts @@ -1,25 +1,31 @@ -/** 等値比較用に`AddEventListenerOptions`をencodeする */ +/** + * Encodes {@linkcode AddEventListenerOptions} into a number for equality comparison. + * This function converts the options object into a single number where each bit + * represents a specific option (capture, once, passive). + */ export const encode = ( options: AddEventListenerOptions | boolean | undefined, ): number => { if (options === undefined) return 0; if (typeof options === "boolean") return Number(options); - // 各フラグをビットにエンコードする + // Encode each flag into its corresponding bit position return ( (options.capture ? 1 : 0) | (options.once ? 2 : 0) | (options.passive ? 4 : 0) ); }; -/** 等値比較用にencodeした`AddEventListenerOptions`をdecodeする +/** + * Decodes a number back into {@linkcode AddEventListenerOptions} object. + * Each bit in the encoded number represents a specific option: * - * - `capture`: `0b001` - * - `once`: `0b010` - * - `passive`: `0b100` - * - `0`: `undefined` + * - `capture`: `0b001` (bit 0) + * - `once`: `0b010` (bit 1) + * - `passive`: `0b100` (bit 2) + * - `0`: returns `undefined` * - * @param encoded `AddEventListenerOptions`をencodeした値 - * @returns `AddEventListenerOptions`または`undefined` + * @param encoded The number containing encoded {@linkcode AddEventListenerOptions} flags + * @returns An {@linkcode AddEventListenerOptions} object or {@linkcode undefined} if encoded value is 0 */ export const decode = ( encoded: number, diff --git a/browser/dom/cache.ts b/browser/dom/cache.ts index 30fccfc..ab17173 100644 --- a/browser/dom/cache.ts +++ b/browser/dom/cache.ts @@ -1,10 +1,15 @@ -/** scrapbox.ioが管理しているcache storageから、最新のresponseを取得する +/** Retrieves the latest response from the cache storage managed by scrapbox.io * - * ほぼ https://scrapbox.io/daiiz/ScrapboxでのServiceWorkerとCacheの活用#5d2efaffadf4e70000651173 のパクリ + * This function searches through the cache storage in reverse chronological order + * to find the most recent cached response for a given request. * - * @param request このrequestに対応するresponseが欲しい - * @param options search paramsを無視したいときとかに使う - * @return cacheがあればそのresponseを、なければ`undefined`を返す + * > [!NOTE] + * > Implementation inspired by Scrapbox's ServiceWorker and Cache usage pattern. + * > For details, see the article "ServiceWorker and Cache Usage in Scrapbox" {@see https://scrapbox.io/daiiz/ScrapboxでのServiceWorkerとCacheの活用#5d2efaffadf4e70000651173} + * + * @param request - The {@linkcode Request} to find a cached response for + * @param options - {@linkcode CacheQueryOptions} (e.g., to ignore search params) + * @returns A {@linkcode Response} if found, otherwise {@linkcode undefined} */ export const findLatestCache = async ( request: Request, @@ -19,10 +24,10 @@ export const findLatestCache = async ( } }; -/** scrapbox.ioが管理しているREST API系のcache storageにresponseを保存する +/** Saves a response to the REST API cache storage managed by scrapbox.io * - * @param request このrequestに対応するresponseを保存する - * @param response 保存するresponse + * @param request The {@linkcode Request} to associate with the cached response + * @param response The {@linkcode Response} to cache */ export const saveApiCache = async ( request: Request, @@ -38,15 +43,16 @@ export const generateCacheName = (date: Date): string => `${date.getDate()}`.padStart(2, "0") }`; -/** prefetchを実行する - * - * prefetchしたデータは`"prefetch"`と`"api-yyyy-MM-dd"`に格納される +/** Executes prefetch operations for specified API URLs * - * `"prefetch"`に格納されたデータは、次回のリクエストで返却されたときに削除される + * Prefetched data is stored in two locations: + * 1. `"prefetch"` cache - temporary storage, cleared after first use + * 2. `"api-yyyy-MM-dd"` cache - date-based persistent storage * - * 回線が遅いときは例外を投げる + * > [!NOTE] + * > Throws an exception if the network connection is slow * - * @param urls prefetchしたいAPIのURLのリスト + * @param urls List of API URLs to prefetch */ export const prefetch = (urls: (string | URL)[]): Promise => postMessage({ @@ -54,11 +60,11 @@ export const prefetch = (urls: (string | URL)[]): Promise => body: { urls: urls.map((url) => url.toString()) }, }); -/** 指定したAPIのcacheの更新を依頼する +/** Requests a cache update for the specified API * - * 更新は10秒ごとに1つずつ実行される + * Updates are processed one at a time with a 10-second interval between each update * - * @param cacheしたいAPIのURL + * @param url The URL of the API to cache */ export const fetchApiCache = (url: string): Promise => postMessage({ title: "fetchApiCache", body: { url } }); diff --git a/browser/dom/caret.ts b/browser/dom/caret.ts index 0c09dc7..aa1a8a6 100644 --- a/browser/dom/caret.ts +++ b/browser/dom/caret.ts @@ -1,25 +1,30 @@ import { textInput } from "./dom.ts"; -/** editor上の位置情報 */ +/** Position information within the editor + * + * @see {@linkcode Range} for selection range information + */ export interface Position { - /** 行数 */ line: number; - /** 何文字目の後ろにいるか */ char: number; + /** Line number (1-based) */ line: number; + /** Character offset within the line (0-based) */ char: number; } -/** 選択範囲を表すデータ +/** Represents a text selection range in the editor + * + * When no text is selected, {@linkcode start} and {@linkcode end} positions are the same (cursor position) * - * 選択範囲がないときは、開始と終了が同じ位置になる + * @see {@linkcode Position} for position type details */ export interface Range { - /** 選択範囲の開始位置 */ start: Position; - /** 選択範囲の終了位置 */ end: Position; + /** Starting position of the selection */ start: Position; + /** Ending position of the selection */ end: Position; } -/** #text-inputを構築しているReact Componentに含まれるカーソルの情報 */ +/** Cursor information contained within the React Component that builds `#text-input` */ export interface CaretInfo { - /** カーソルの位置 */ position: Position; - /** 選択範囲中の文字列 */ selectedText: string; - /** 選択範囲の位置 */ selectionRange: Range; + /** Current cursor position */ position: Position; + /** Currently selected text */ selectedText: string; + /** Range of the current selection */ selectionRange: Range; } interface ReactFiber { @@ -32,10 +37,13 @@ interface ReactFiber { }; } -/** 現在のカーソルと選択範囲の位置情報を取得する +/** Retrieves the current cursor position and text selection information * - * @return カーソルと選択範囲の情報 - * @throws {Error} #text-inputとReact Componentの隠しpropertyが見つからなかった + * @returns A {@linkcode CaretPosition} containing cursor position and text selection information + * @throws {@linkcode Error} when: + * - `#text-input` element is not found + * - React Component's internal properties are not found + * @see {@linkcode CaretInfo} for return type details */ export const caret = (): CaretInfo => { const textarea = textInput(); @@ -51,7 +59,7 @@ export const caret = (): CaretInfo => { ); } - // @ts-ignore DOMを無理矢理objectとして扱っている + // @ts-ignore Forcefully treating DOM element as an object to access React internals return (textarea[ reactKey ] as ReactFiber).return.return.stateNode.props; diff --git a/browser/dom/click.ts b/browser/dom/click.ts index 43d3173..af68075 100644 --- a/browser/dom/click.ts +++ b/browser/dom/click.ts @@ -30,8 +30,8 @@ export const click = async ( element.dispatchEvent(new MouseEvent("mouseup", mouseOptions)); element.dispatchEvent(new MouseEvent("click", mouseOptions)); - // ScrapboxのReactの処理が終わるまで少し待つ - // 待ち時間は感覚で決めた + // Wait for Scrapbox's React event handlers to complete + // Note: 10ms delay is determined empirically to ensure reliable event processing await delay(10); }; @@ -72,7 +72,7 @@ export const holdDown = async ( element.dispatchEvent(new TouchEvent("touchend", mouseOptions)); element.dispatchEvent(new MouseEvent("click", mouseOptions)); - // ScrapboxのReactの処理が終わるまで少し待つ - // 待ち時間は感覚で決めた + // Wait for Scrapbox's React event handlers to complete + // Note: 10ms delay is determined empirically to ensure reliable event processing await delay(10); }; diff --git a/browser/dom/cursor.d.ts b/browser/dom/cursor.d.ts index fee9007..99fdc32 100644 --- a/browser/dom/cursor.d.ts +++ b/browser/dom/cursor.d.ts @@ -1,22 +1,33 @@ +/** @module cursor */ import { type BaseLine, BaseStore } from "@cosense/types/userscript"; import type { Position } from "./position.ts"; import type { Page } from "./page.d.ts"; +/** Options for setting cursor position + * @interface + */ export interface SetPositionOptions { - /** カーソルが画面外に移動したとき、カーソルが見える位置までページをスクロールするかどうか + /** Whether to auto-scroll the page when the cursor moves outside the viewport * - * @default true + * @default {true} + * @type {boolean} */ scrollInView?: boolean; - /** カーソル移動イベントの発生箇所? + /** Source of the cursor movement event * - * コード内だと、"mouse"が指定されていた場合があった。詳細は不明 + * `"mouse"` indicates the cursor was moved by mouse interaction + * @type {"mouse"} */ source?: "mouse"; } -/** カーソル操作クラス */ +/** Class for managing cursor operations in the Scrapbox editor + * + * @see {@linkcode Position} for cursor position type details + * @see {@linkcode Page} for page data type details + * @extends {@linkcode BaseStore}<{ source: "mouse" | undefined } | "focusTextInput" | "scroll" | undefined> + */ export declare class Cursor extends BaseStore< { source: "mouse" | undefined } | "focusTextInput" | "scroll" | undefined > { @@ -24,81 +35,101 @@ export declare class Cursor extends BaseStore< public startedWithTouch: boolean; - /** カーソルの位置を初期化し、editorからカーソルを外す */ + /** Reset cursor position and remove cursor focus from the editor */ clear(): void; - /** カーソルの位置を取得する */ + /** Get the current cursor position + * @returns A {@linkcode Position} containing: + * - Success: The current cursor coordinates and line information + * - Error: Never throws or returns an error + */ getPosition(): Position; - /** カーソルが表示されているか調べる */ + /** Check if the cursor is currently visible + * @returns A {@linkcode boolean} indicating: + * - Success: `true` if the cursor is visible, `false` otherwise + * - Error: Never throws or returns an error + */ getVisible(): boolean; - /** カーソルを指定した位置に動かす */ + /** Move the cursor to the specified position + * @param position - The target position to move the cursor to + * @param option - Optional settings for the cursor movement. See {@linkcode SetPositionOptions} + */ setPosition( position: Position, option?: SetPositionOptions, ): void; - /** popup menuを表示する */ + /** Show the editor's popup menu */ showEditPopupMenu(): void; - /** popup menuを消す */ + /** Hide the editor's popup menu */ hidePopupMenu(): void; - /** #text-inputにカーソルをfocusし、同時にカーソルを表示する + /** Focus the cursor on `#text-input` and make it visible * - * このとき、`event: "focusTextInput"`が発行される + * This action triggers the `event: "focusTextInput"` event */ focus(): void; - /** #text-inputにfocusがあたっているか返す + /** Check if `#text-input` has focus * - * `this.focusTextarea`と同値 + * Returns the same value as `this.focusTextarea` */ get hasFocus(): boolean; - /** #text-inputからfocusを外す。カーソルの表示状態は変えない */ + /** Remove focus from `#text-input` without changing cursor visibility */ blur(): void; - /** カーソルの位置が行や列の外に出ていた場合に、存在する行と列の中に納める */ + /** Adjust cursor position to stay within valid line and column boundaries */ fixPosition(): void; - /** カーソルが行頭にいてかつ表示されていたら`true` */ + /** Check if the cursor is at the start of a line + * @returns A {@linkcode boolean} indicating: + * - Success: `true` if the cursor is visible and at line start, `false` otherwise + * - Error: Never throws or returns an error + */ isAtLineHead(): boolean; - /** カーソルが行末にいてかつ表示されていたら`true` */ + /** Check if the cursor is at the end of a line + * @returns A {@linkcode boolean} indicating: + * - Success: `true` if the cursor is visible and at line end, `false` otherwise + * - Error: Never throws or returns an error + */ isAtLineTail(): boolean; - /** カーソルを表示する + /** Make the cursor visible * - * #text-inputのfocus状態は変えない + * Does not change the focus state of `#text-input` */ show(): void; - /** カーソルを非表示にする + /** Hide the cursor * - * touch deviceの場合は、#text-inputからfocusを外す + * On touch devices, this also removes focus from `#text-input` */ hide(): void; - /** カーソル操作コマンド + /** Cursor movement commands * + * @param action - The movement command to execute. Available commands: * | Command | Description | * | ------ | ----------- | - * | go-up | 1行上に動かす | - * | go-down | 1行下に動かす | - * | go-left | 1文字左に動かす | - * | go-right | 1文字右に動かす | - * | go-forward | Emacs key bindingsで使われているコマンド。go-rightとほぼ同じ | - * | go-backward | Emacs key bindingsで使われているコマンド。go-leftとほぼ同じ | - * | go-top | タイトル行の行頭に飛ぶ | - * | go-bottom | 最後の行の行末に飛ぶ | - * | go-word-head | 1単語右に動かす | - * | go-word-tail | 1単語左に動かす | - * | go-line-head | 行頭に飛ぶ | - * | go-line-tail | 行末に飛ぶ | - * | go-pagedown | 1ページ分下の行に飛ぶ | - * | go-pageup | 1ページ分上の行に飛ぶ | + * | go-up | Move cursor up one line | + * | go-down | Move cursor down one line | + * | go-left | Move cursor left one character | + * | go-right | Move cursor right one character | + * | go-forward | Move cursor forward (similar to go-right, used in Emacs key bindings) | + * | go-backward | Move cursor backward (similar to go-left, used in Emacs key bindings) | + * | go-top | Jump to the beginning of the title line | + * | go-bottom | Jump to the end of the last line | + * | go-word-head | Move cursor to the start of the next word | + * | go-word-tail | Move cursor to the end of the previous word | + * | go-line-head | Jump to the start of the current line | + * | go-line-tail | Jump to the end of the current line | + * | go-pagedown | Move cursor down one page | + * | go-pageup | Move cursor up one page | */ goByAction( action: @@ -118,10 +149,18 @@ export declare class Cursor extends BaseStore< | "go-pageup", ): void; - /** 現在のページ本文を取得する */ + /** Get the content of the current page + * @returns An array of {@linkcode BaseLine} objects containing: + * - Success: The current page's content as an array of line objects + * - Error: Never throws or returns an error + */ get lines(): BaseLine[]; - /** 現在のページデータを取得する */ + /** Get the current page data + * @returns A {@linkcode Page} object containing: + * - Success: The current page's metadata and content + * - Error: Never throws or returns an error + */ get page(): Page; private goUp(): void; @@ -130,32 +169,51 @@ export declare class Cursor extends BaseStore< private goPageDown(): void; private getNextLineHead(): void; private getPrevLineTail(): void; + /** Move cursor backward one character + * @param init - Optional configuration object + * @param init.scrollInView - Whether to scroll the view to keep cursor visible + */ private goBackward(init?: { scrollInView: boolean }): void; + + /** Move cursor forward one character + * @param init - Optional configuration object + * @param init.scrollInView - Whether to scroll the view to keep cursor visible + */ private goForward(init?: { scrollInView: boolean }): void; private goLeft(): void; private goRight(): void; - /** タイトルの先頭文字に飛ぶ */ + /** Jump to the first character of the title */ private goTop(): void; - /** 最後の行の末尾に飛ぶ */ + /** Jump to the end of the last line */ private goBottom(): void; private goWordHead(): void; + /** Get the position of the next word's start + * @returns A {@linkcode Position} containing: + * - Success: The coordinates and line information of the next word's start + * - Error: Never throws or returns an error + */ private getWordHead(): Position; private goWordTail(): void; + /** Get the position of the previous word's end + * @returns A {@linkcode Position} containing: + * - Success: The coordinates and line information of the previous word's end + * - Error: Never throws or returns an error + */ private getWordTail(): Position; - /** インデントの後ろに飛ぶ + /** Jump to the position after indentation * - * インデントの後ろかインデントの中にいるときは行頭に飛ぶ + * If cursor is already after or within indentation, jump to line start */ private goLineHead(): void; - /** 行末に飛ぶ */ + /** Jump to the end of the current line */ private goLineTail(): void; private sync(): void; private syncNow(): void; private updateTemporalHorizontalPoint(): number; - /** scrollされたときに発火される + /** Fired when the page is scrolled * - * このとき`event: "source"`が発行される + * Triggers the `event: "source"` event */ private emitScroll(): void; diff --git a/browser/dom/edit.ts b/browser/dom/edit.ts index 7237d43..a1a66b9 100644 --- a/browser/dom/edit.ts +++ b/browser/dom/edit.ts @@ -109,7 +109,7 @@ export const moveLines = (count: number): void => { upLines(-count); } }; -// to行目の後ろに移動させる +// Move selected lines to the position after the target line number export const moveLinesBefore = (from: number, to: number): void => { const count = to - from; if (count >= 0) { @@ -167,5 +167,5 @@ export const insertText = async (text: string): Promise => { const event = new InputEvent("input", { bubbles: true }); cursor.dispatchEvent(event); - await delay(1); // 待ち時間は感覚で決めた + await delay(1); // 1ms delay to ensure event processing completes }; diff --git a/browser/dom/extractCodeFiles.ts b/browser/dom/extractCodeFiles.ts index f5e1f11..fabac30 100644 --- a/browser/dom/extractCodeFiles.ts +++ b/browser/dom/extractCodeFiles.ts @@ -1,6 +1,6 @@ import type { Line } from "@cosense/types/userscript"; -/** 一つのソースコードを表す */ +/** Represents a single source code file with its code blocks */ export interface CodeFile { /** file name */ filename: string; @@ -12,33 +12,35 @@ export interface CodeFile { blocks: CodeBlock[]; } -/** 一つのコードブロックを表す */ +/** Represents a single code block within a source file */ export interface CodeBlock { - /** 開始行のID */ + /** ID of the first line in the code block */ startId: string; - /** 末尾の行のID */ + /** ID of the last line in the code block */ endId: string; - /** コードブロックの最終更新日時 */ + /** Last update timestamp of the code block */ updated: number; - /** .code-titleのindent数 */ + /** Indentation level of the .code-title element in Scrapbox */ indent: number; - /** ブロック中のコード + /** Lines of code within the block * - * .code-titleは含まない + * Excludes `.code-title` * - * 予めindentは削ってある + * Indentation is already removed from each line */ lines: string[]; } -/** `scrapbox.Page.lines`からcode blocksを取り出す +/** Extract code blocks from {@linkcode scrapbox.Page.lines} * - * @param lines ページの行 - * @return filenameをkeyにしたソースコードのMap + * @param lines - Page lines to process + * @returns A {@linkcode Map}<{@linkcode string}, {@linkcode string}> containing: + * - Key: The filename + * - Value: The source code content */ export const extractCodeFiles = ( lines: Iterable, @@ -54,7 +56,7 @@ export const extractCodeFiles = ( startId: line.id, endId: line.id, updated: line.updated, - // 本文ではなく、.code-titleのインデント数を登録する + // Register the indentation level of `.code-title`, not the content indent: rest.indent - 1, lines: [], }); diff --git a/browser/dom/getCachedLines.ts b/browser/dom/getCachedLines.ts index 2a0b1ba..a1637f8 100644 --- a/browser/dom/getCachedLines.ts +++ b/browser/dom/getCachedLines.ts @@ -10,11 +10,13 @@ let initialize: (() => void) | undefined = () => { initialize = undefined; }; -/** scrapbox.Page.linesをcacheして取得する +/** Get cached version of `{@linkcode https://jsr.io/@cosense/types/doc/userscript/~/Page.lines scrapbox.Page.lines}` * - * scrapbox.Page.linesの生成には時間がかかるので、実際に必要になるまで呼び出さないようにする + * This function caches the result of `{@linkcode https://jsr.io/@cosense/types/doc/userscript/~/Page.lines scrapbox.Page.lines}` to improve performance, + * as generating the lines array is computationally expensive. + * The cache is automatically invalidated when the page content changes. * - * @return `scrapbox.Page.lines`と同じ。常に最新のものが返される + * @returns Same as `{@linkcode https://jsr.io/@cosense/types/doc/userscript/~/Page.lines scrapbox.Page.lines}`. Always returns the latest data through cache management */ export const getCachedLines = (): readonly Line[] | null => { initialize?.(); diff --git a/browser/dom/motion.ts b/browser/dom/motion.ts index 98ffa51..f86c553 100644 --- a/browser/dom/motion.ts +++ b/browser/dom/motion.ts @@ -15,11 +15,11 @@ import { isHeightViewable } from "./isHeightViewable.ts"; import { range } from "../../range.ts"; /** @deprecated - * カーソル行の行末を長押ししてfocusを得る + * Long press at the end of cursor line to gain focus * - * mobile版scrapbox用 + * This function is specifically for mobile version of Scrapbox * - * @param [holding=1000] 長押しする時間(ミリ秒単位) + * @param [holding=1000] - Duration of long press in milliseconds */ export const focusEnd = async (holding = 1000): Promise => { const target = getLineDOM(caret().position.line) @@ -33,36 +33,36 @@ export const focusEnd = async (holding = 1000): Promise => { await holdDown(target, { X: right + 1, Y: top + height / 2, holding }); }; -/** カーソルを左に動かす +/** Move the cursor left using `ArrowLeft` key * - * @param [count=1] 動かす回数 + * @param [count=1] - Number of moves to perform */ export const moveLeft = (count = 1): void => { for (const _ of range(0, count)) { press("ArrowLeft"); } }; -/** カーソルを上に動かす +/** Move the cursor up using `ArrowUp` key * - * @param [count=1] 動かす回数 + * @param [count=1] - Number of moves to perform */ export const moveUp = (count = 1): void => { for (const _ of range(0, count)) { press("ArrowUp"); } }; -/** カーソルを下に動かす +/** Move the cursor down using `ArrowDown` key * - * @param [count=1] 動かす回数 + * @param [count=1] - Number of moves to perform */ export const moveDown = (count = 1): void => { for (const _ of range(0, count)) { press("ArrowDown"); } }; -/** カーソルを右に動かす +/** Move the cursor right using `ArrowRight` key * - * @param [count=1] 動かす回数 + * @param [count=1] - Number of moves to perform */ export const moveRight = (count = 1): void => { for (const _ of range(0, count)) { @@ -70,29 +70,29 @@ export const moveRight = (count = 1): void => { } }; -/** インデントを除いた行頭に移動する */ +/** Move to the start of line excluding indentation */ export const goHeadWithoutBlank = (): void => { press("End"); press("Home"); }; -/** 最後の非空白文字に移動する */ +/** Move to the last non-whitespace character */ export const goEndWithoutBlank = (): void => { press("End"); moveLeft( getText(caret().position.line)?.match?.(/(\s*)$/)?.[1]?.length ?? 0, ); }; -/** 行頭に移動する */ +/** Move to the start of line */ export const goHead = (): void => { press("Home"); press("Home"); }; -/** 行末に移動する */ +/** Move to the end of line */ export const goEnd = (): void => { press("End"); }; -/** 最初の行の行頭に移動する */ +/** Move to the start of the first line */ export const goHeadLine = async (): Promise => { const target = getHeadLineDOM(); if (!target) throw Error(".line:first-of-type can't be found."); @@ -103,13 +103,13 @@ export const goHeadLine = async (): Promise => { const { left, top } = charDOM.getBoundingClientRect(); await click(target, { X: left, Y: top }); }; -/** 最後の行の行末に移動する */ +/** Move to the end of the last line */ export const goLastLine = async (): Promise => { await _goLine(getTailLineDOM()); }; -/** 任意の行の行末に移動する +/** Move to the end of a specified line * - * @param value 移動したい行の行番号 or 行ID or 行のDOM + * @param value - Target line number, line ID, or {@linkcode HTMLElement} */ export const goLine = async ( value: string | number | HTMLElement | undefined, @@ -125,12 +125,12 @@ const _goLine = async (target: HTMLDivElement | undefined) => { await click(target, { X: right + 1, Y: top + height / 2 }); }; -/** 任意の文字に移動する +/** Move cursor to a specific character position * - * クリックで移動できない文字に移動しようとすると失敗するので注意 + * Note: This operation will fail if attempting to move to characters that cannot be clicked in the UI * - * @param line 移動したい文字がある行 - * @param pos 移動したい文字の列 + * @param line - Target line (can be line number, line ID, or line DOM element) + * @param pos - Character position (column) in the target line */ export const goChar = async ( line: string | number | HTMLElement, @@ -148,9 +148,9 @@ export const goChar = async ( await click(charDOM, { X: left, Y: top }); }; -/** 画面に収まる最大行数を計算する +/** Calculate the maximum number of lines that can fit in the viewport * - * 行の高さは最後の行を基準とする + * Uses the height of the last line as a reference for calculation */ const getVisibleLineCount = (): number => { const clientHeight = getTailLineDOM()?.clientHeight; @@ -160,9 +160,9 @@ const getVisibleLineCount = (): number => { return Math.round(globalThis.innerHeight / clientHeight); }; -/** 半ページ上にスクロールする +/** Scroll half a page up * - * @param [count=1] スクロール回数 + * @param [count=1] - Number of scroll operations to perform */ export const scrollHalfUp = async (count = 1): Promise => { const lineNo = getLineNo(caret().position.line); @@ -174,9 +174,9 @@ export const scrollHalfUp = async (count = 1): Promise => { ); await goLine(Math.max(index, 0)); }; -/** 半ページ下にスクロールする +/** Scroll half a page down * - * @param [count=1] スクロール回数 + * @param [count=1] - Number of scroll operations to perform */ export const scrollHalfDown = async (count = 1): Promise => { const lineNo = getLineNo(caret().position.line); @@ -188,18 +188,18 @@ export const scrollHalfDown = async (count = 1): Promise => { ); await goLine(Math.min(index, getLineCount() - 1)); }; -/** 1ページ上にスクロールする +/** Scroll one page up using `PageUp` key * - * @param [count=1] スクロール回数 + * @param [count=1] - Number of scroll operations to perform */ export const scrollUp = (count = 1): void => { for (const _ of range(0, count)) { press("PageUp"); } }; -/** 1ページ下にスクロールする +/** Scroll one page down using `PageDown` key * - * @param [count=1] スクロール回数 + * @param [count=1] - Number of scroll operations to perform */ export const scrollDown = (count = 1): void => { for (const _ of range(0, count)) { diff --git a/browser/dom/node.ts b/browser/dom/node.ts index 640385b..ca80e76 100644 --- a/browser/dom/node.ts +++ b/browser/dom/node.ts @@ -20,14 +20,14 @@ export const getLineId = ( ): string | undefined => { if (isUndefined(value)) return undefined; - // 行番号のとき + // When value is a line number if (isNumber(value)) return getBaseLine(value)?.id; - // 行IDのとき + // When value is a line ID if (isString(value)) return value.startsWith("L") ? value.slice(1) : value; - // 行のDOMだったとき + // When value is a line DOM element if (value.classList.contains("line")) return value.id.slice(1); - // 行の子要素だったとき + // When value is a child element of a line const line = value.closest(".line"); if (line) return line.id.slice(1); @@ -45,9 +45,9 @@ export const getLineNo = ( ): number | undefined => { if (isUndefined(value)) return undefined; - // 行番号のとき + // When value is a line number if (isNumber(value)) return value; - // 行ID or DOMのとき + // When value is a line ID or DOM element const id = getLineId(value); return id ? takeInternalLines().findIndex((line) => line.id === id) : -1; }; @@ -57,9 +57,9 @@ export const getLine = ( ): Line | undefined => { if (isUndefined(value)) return undefined; - // 行番号のとき + // When value is a line number if (isNumber(value)) return getLines()[value]; - // 行ID or DOMのとき + // When value is a line ID or DOM element const id = getLineId(value); return id ? getLines().find((line) => line.id === id) : undefined; }; @@ -69,9 +69,9 @@ export const getBaseLine = ( ): BaseLine | undefined => { if (isUndefined(value)) return undefined; - // 行番号のとき + // When value is a line number if (isNumber(value)) return takeInternalLines()[value]; - // 行ID or DOMのとき + // When value is a line ID or DOM element const id = getLineId(value); return id ? takeInternalLines().find((line) => line.id === id) : undefined; }; @@ -102,22 +102,22 @@ export const getText = ( ): string | undefined => { if (isUndefined(value)) return undefined; - // 数字と文字列は行として扱う + // Treat numbers and strings as line references if (isNumber(value) || isString(value)) return getBaseLine(value)?.text; if (!(value instanceof HTMLElement)) return; if (isLineDOM(value)) return getBaseLine(value)?.text; - // 文字のDOMだったとき + // When value is a character DOM element if (value.classList.contains("char-index")) { return value.textContent ?? undefined; } - // div.linesを含む(複数のdiv.lineを含む)場合は全ての文字列を返す + // When the element contains `div.lines` (which contains multiple div.line elements), return all text content concatenated if ( value.classList.contains("line") || value.getElementsByClassName("lines")?.[0] ) { return takeInternalLines().map(({ text }) => text).join("\n"); } - //中に含まれている文字の列番号を全て取得し、それに対応する文字列を返す + // Get all character indices contained within the element and return the corresponding text const chars = [] as number[]; const line = getBaseLine(value); if (isUndefined(line)) return; @@ -183,9 +183,9 @@ export const getIndentCount = ( if (isUndefined(text)) return undefined; return Text.getIndentCount(text); }; -/** 指定した行の配下にある行の数を返す +/** Get the number of indented lines under the specified line * - * @param value 指定したい行の行番号か行IDかDOM + * @param value Line reference (can be line number, line ID, or DOM element) */ export const getIndentLineCount = ( value?: number | string | T, diff --git a/browser/dom/open.ts b/browser/dom/open.ts index 0e7ed7d..6ed7e2e 100644 --- a/browser/dom/open.ts +++ b/browser/dom/open.ts @@ -10,30 +10,32 @@ export interface OpenOptions { /** line id */ id?: string; - /** ページに追記するテキスト */ + /** Text to append to the page content */ body?: string; - /** 新しいタブで開くかどうか + /** Whether to open the page in a new tab + * - `true`: open in a new tab + * - `false`: open in the same tab * - * @default 同じタブで開く + * @default {false} */ newTab?: boolean; - /** 同じタブでページを開く場合、ページを再読込するかどうか + /** Whether to reload the page when opening in the same tab * - * @default 同じprojectの場合は再読み込みせず、違うprojectの場合は再読込する + * Default value is `false` for same project (no reload) and `true` for different project (force reload) */ reload?: boolean; - /** リンク先へスクロールする機能を使うために必要な情報 */ + /** Context information required for the auto-scroll feature when navigating to linked content */ context?: Omit; } -/** ページを開く +/** Open a page * - * @param project 開くページのproject名 - * @param title 開くページのタイトル - * @param options + * @param project - Project name of the page to open + * @param title - Title of the page to open + * @param options - Configuration options for opening the page */ export const open = ( project: string, @@ -72,13 +74,13 @@ export const open = ( a.remove(); }; -/** 同じタブでページを開く +/** Open a page in the same tab * - * このとき、ページは再読み込みされない + * The page will not be reloaded when opened * - * @param project 開くページのproject名 - * @param title 開くページのタイトル - * @param [body] ページに追記するテキスト + * @param project - Project name of the page to open + * @param title - Title of the page to open + * @param [body] - Text to append to the page */ export const openInTheSameTab = ( project: string, diff --git a/browser/dom/page.d.ts b/browser/dom/page.d.ts index 10fe41f..801044b 100644 --- a/browser/dom/page.d.ts +++ b/browser/dom/page.d.ts @@ -2,15 +2,17 @@ import { BaseStore } from "@cosense/types/userscript"; import type { Page as PageData } from "@cosense/types/rest"; export interface SetPositionOptions { - /** カーソルが画面外に移動したとき、カーソルが見える位置までページをスクロールするかどうか + /** Whether to auto-scroll the page when the cursor moves outside the viewport + * When `true`, the page will automatically scroll to keep the cursor visible * - * @default true + * @default {true} */ scrollInView?: boolean; - /** カーソル移動イベントの発生箇所? + /** Source of the cursor movement event * - * コード内だと、"mouse"が指定されていた場合があった。詳細は不明 + * Can be set to `"mouse"` when the cursor movement is triggered by mouse interaction + * This parameter helps distinguish between different types of cursor movements */ source?: "mouse"; } @@ -31,9 +33,10 @@ export interface ApplySnapshotInit { export type PageWithCache = PageData & { cachedAt: number | undefined }; -/** Scrapboxのページデータを管理する内部クラス +/** Internal class for managing Scrapbox page data * - * 一部型定義は書きかけ + * > [!NOTE] + * > Some type definitions are still in progress and may be incomplete */ export declare class Page extends BaseStore< { source: "mouse" | undefined } | "focusTextInput" | "scroll" | undefined diff --git a/browser/dom/position.ts b/browser/dom/position.ts index 40de49a..d54678f 100644 --- a/browser/dom/position.ts +++ b/browser/dom/position.ts @@ -1,5 +1,9 @@ -/** editor上の位置情報 */ +/** Position information within the Scrapbox editor + * Represents the cursor or selection position using line and character coordinates + */ export interface Position { - /** 行数 */ line: number; - /** 何文字目の後ろにいるか */ char: number; + /** Line number (1-based index) */ line: number; + /** Character position within the line (0-based index) + * Represents the number of characters before the cursor position + */ char: number; } diff --git a/browser/dom/press.ts b/browser/dom/press.ts index 3f9bd07..6ee4765 100644 --- a/browser/dom/press.ts +++ b/browser/dom/press.ts @@ -9,13 +9,15 @@ export interface PressOptions { noModifiedKeys?: boolean; } -/** JavaScriptから任意のキー押下eventを発行する +/** Dispatches a keyboard event programmatically via JavaScript * - * Scrapboxにキー入力命令を送る際に使う。 - * この関数はキー入力eventで呼び出されたscrapboxのevent listenerの処理が終わるまで同期的にブロックされるようだ + * Used to send keyboard input commands to Scrapbox. + * > [!NOTE] + * > This function appears to block synchronously until Scrapbox's event listeners + * finish processing the keyboard event. * - * @param key 押したいキーの名前 - * @param pressOptions options + * @param key - The name of the key to simulate pressing + * @param pressOptions - Additional options for the key press (modifiers, etc.) */ export const press = ( key: KeyName, @@ -127,7 +129,7 @@ const KEYCODE_MAP = { F10: 122, F11: 123, F12: 124, - // 記号 + // Symbols and special characters ":": 186, "*": 186, ";": 187, @@ -148,5 +150,5 @@ const KEYCODE_MAP = { "}": 221, "^": 222, "~": 222, - "_": 226, // Shiftなしの226は\で、\と区別がつかない + "_": 226, // Note: Without Shift, keyCode 226 represents '\' and cannot be distinguished from the backslash key }; diff --git a/browser/dom/pushPageTransition.ts b/browser/dom/pushPageTransition.ts index 0a167f4..e412dfd 100644 --- a/browser/dom/pushPageTransition.ts +++ b/browser/dom/pushPageTransition.ts @@ -1,33 +1,37 @@ import { toTitleLc } from "../../title.ts"; -/** ページリンク */ +/** Represents a link to a Scrapbox page */ export interface Link { - /** リンク先のproject name */ + /** The project name of the linked page */ project: string; - /** リンク先のpage title */ + /** The title of the linked page */ title: string; } -/** ページから別のページに遷移するときの状態を表す */ +/** Represents the state of a page-to-page navigation + * Used to track navigation between two specific pages within Scrapbox + */ export interface PageTransitionContextLink { type: "page"; - /** 遷移元ページのリンク */ + /** Link to the source/origin page */ from: Link; - /** 遷移先ページのリンク */ + /** Link to the destination/target page */ to: Link; } -/** 全文検索結果から別のページに遷移するときの状態を表す */ +/** Represents the state when navigating from search results to a specific page + * Used to track navigation that originates from a full-text search + */ export interface PageTransitionContextQuery { type: "search"; - /** 全文検索での検索語句 */ + /** The search query used in the full-text search */ query: string; - /** 遷移先ページのリンク */ + /** Link to the destination/target page */ to: Link; } @@ -35,9 +39,12 @@ export type PageTransitionContext = | PageTransitionContextLink | PageTransitionContextQuery; -/** ページ遷移状態を登録し、次回のページ遷移時にリンク先へスクロールする +/** Registers the page transition state and enables automatic scrolling to the linked content + * This function stores navigation context in localStorage, which is used to determine + * where to scroll on the next page load. This is particularly useful for maintaining + * context when users navigate between related pages or from search results. * - * @param context 遷移状態 + * @param context The transition state containing source and destination information */ export const pushPageTransition = (context: PageTransitionContext): void => { const pageTransitionContext: Record = JSON.parse( diff --git a/browser/dom/selection.d.ts b/browser/dom/selection.d.ts index a8b6896..d5f241d 100644 --- a/browser/dom/selection.d.ts +++ b/browser/dom/selection.d.ts @@ -9,60 +9,77 @@ export interface Range { export declare class Selection extends BaseStore { constructor(); - /** 現在のページ本文を取得する */ + /** + * A class that manages text selection in Scrapbox pages. + * It handles selection ranges, provides utilities for text manipulation, + * and maintains the selection state across user interactions. + */ + + /** Get the current page content as an array of lines */ get lines(): BaseLine[]; - /** 現在の選択範囲を取得する + /** Get the current selection range * - * @param init 選択範囲の先頭位置がRange.startになるよう並び替えたいときは`init.normalizeOrder`を`true`にする - * @return 現在の選択範囲 + * @param init Set `init.normalizeOrder` to `true` to ensure Range.start is + * the beginning of the selection (useful for consistent text processing) + * @returns The current {@linkcode Range} object representing the selection */ getRange(init?: { normalizeOrder: boolean }): Range; - /** 選択範囲を変更する */ + /** Update the current selection range */ setRange(range: Range): void; - /** 選択を解除する */ + /** Clear the current selection */ clear(): void; - /** 選択範囲の先頭位置がrange.startになるよう並び替える + /** Normalize the selection range order to ensure start position comes before end + * + * @param range - The selection range to normalize + * @returns A normalized {@linkcode Range} with start position at the beginning * - * @param range 並び替えたい選択範囲 - * @return 並び替えた選択範囲 + * This is useful when you need consistent text processing regardless of + * whether the user selected text from top-to-bottom or bottom-to-top. */ normalizeOrder(range: Range): Range; - /** 選択範囲の文字列を取得する */ + /** Get the text content of the current selection */ getSelectedText(): string; - /** 選択範囲の描画上の高さを取得する */ + /** Get the visual height of the selection in pixels */ getSelectionsHeight(): number; - /** 選択範囲の右上のy座標を取得する */ + /** Get the Y-coordinate of the selection's top-right corner */ getSelectionTop(): number; - /** 全選択する */ + /** Select all content in the current page */ selectAll(): void; - /** 与えられた選択範囲が空かどうか判定する + /** Check if there is any active selection * - * defaultだと、このclassが持っている選択範囲を判定する + * @param range Optional range to check. If not provided, + * checks this class's current selection */ hasSelection(range?: Range): boolean; - /** 与えられた範囲が1行だけ選択しているかどうか判定する + /** Check if exactly one line is selected * - * defaultだと、このclassが持っている選択範囲を判定する + * @param range Optional range to check. If not provided, + * checks this class's current selection */ hasSingleLineSelection(range?: Range): boolean; - /** 与えられた範囲が2行以上選択しているかどうか判定する + /** Check if multiple lines are selected (2 or more) * - * defaultだと、このclassが持っている選択範囲を判定する + * @param range Optional range to check. If not provided, + * checks this class's current selection */ hasMultiLinesSelection(range?: Range): boolean; - /** 全選択しているかどうか */ + /** Check if all content in the current page is selected + * + * This is equivalent to checking if the selection spans + * from the beginning of the first line to the end of the last line + */ hasSelectionAll(): boolean; private fixPosition(position: Position): void; diff --git a/browser/dom/statusBar.ts b/browser/dom/statusBar.ts index c0d0ff2..ca4ee41 100644 --- a/browser/dom/statusBar.ts +++ b/browser/dom/statusBar.ts @@ -1,13 +1,22 @@ import { statusBar } from "./dom.ts"; export interface UseStatusBarResult { - /** 取得した.status-barの領域に情報を表示する */ + /** Display information in the acquired status bar section + * + * @param items - Array of items to display (text, icons, or groups) + */ render: (...items: Item[]) => void; - /** 取得した.statusb-barの領域を削除する */ + /** Remove the acquired status bar section and clean up resources */ dispose: () => void; } -/** .status-barの一区画を取得し、各種操作函数を返す */ +/** Get a section of the status bar and return functions to manipulate it + * + * The status bar is divided into sections, each managed independently. + * This hook creates a new section and provides methods to: + * - Display information (text and icons) in the section + * - Remove the section when it's no longer needed + */ export const useStatusBar = (): UseStatusBarResult => { const bar = statusBar(); if (!bar) throw new Error(`div.status-bar can't be found`); @@ -67,21 +76,33 @@ const makeItem = (child: string | Node) => { return span; }; -/** スピナーを作る */ +/** Create a loading spinner icon + * + * Creates a FontAwesome spinner icon wrapped in a status bar item. + * Use this to indicate loading or processing states. + */ const makeSpinner = () => { const i = document.createElement("i"); i.classList.add("fa", "fa-spinner"); return makeItem(i); }; -/** チェックマークを作る */ +/** Create a checkmark icon + * + * Creates a Kamon checkmark icon wrapped in a status bar item. + * Use this to indicate successful completion or confirmation. + */ const makeCheckCircle = () => { const i = document.createElement("i"); i.classList.add("kamon", "kamon-check-circle"); return makeItem(i); }; -/** 警告アイコンを作る */ +/** Create a warning icon + * + * Creates a FontAwesome warning triangle icon wrapped in a status bar item. + * Use this to indicate warnings, errors, or important notices. + */ const makeExclamationTriangle = () => { const i = document.createElement("i"); i.classList.add("fas", "fa-exclamation-triangle"); diff --git a/browser/dom/stores.ts b/browser/dom/stores.ts index c4c4603..5a76aef 100644 --- a/browser/dom/stores.ts +++ b/browser/dom/stores.ts @@ -3,6 +3,17 @@ import type { Cursor } from "./cursor.d.ts"; import type { Selection } from "./selection.d.ts"; export type { Cursor, Selection }; +/** Retrieve Scrapbox's internal cursor and selection stores from the DOM + * + * This function accesses React's internal fiber tree to obtain references to + * the Cursor and Selection store instances that Scrapbox uses to manage + * text input state. These stores provide APIs for: + * - {@linkcode Cursor}: Managing text cursor position and movement + * - {@linkcode Selection}: Handling text selection ranges and operations + * + * @throws {@linkcode Error} If text input element or stores cannot be found + * @returns Object containing {@linkcode CursorStore} and {@linkcode SelectionStore} instances + */ export const takeStores = (): { cursor: Cursor; selection: Selection } => { const textarea = textInput(); if (!textarea) { @@ -17,7 +28,9 @@ export const takeStores = (): { cursor: Cursor; selection: Selection } => { ); } - // @ts-ignore DOMを無理矢理objectとして扱っている + // @ts-ignore Treating DOM element as an object to access React's internal fiber tree. + // This is a hack to access Scrapbox's internal stores, but it's currently the only way + // to obtain references to the cursor and selection management instances. const stores = (textarea[ reactKey ] as ReactFiber).return.return.stateNode._stores as (Cursor | Selection)[]; @@ -38,6 +51,12 @@ export const takeStores = (): { cursor: Cursor; selection: Selection } => { return { cursor, selection }; }; +/** Internal React Fiber node structure + * + * This interface represents the minimal structure we need from React's + * internal fiber tree to access Scrapbox's store instances. Note that + * this is an implementation detail and might change with React updates. + */ interface ReactFiber { return: { return: { diff --git a/browser/dom/takeInternalLines.ts b/browser/dom/takeInternalLines.ts index 5877653..6eddf18 100644 --- a/browser/dom/takeInternalLines.ts +++ b/browser/dom/takeInternalLines.ts @@ -1,15 +1,25 @@ import { lines } from "./dom.ts"; import type { BaseLine } from "@cosense/types/userscript"; -/** Scrapbox内部の本文データの参照を取得する +/** Get a reference to Scrapbox's internal page content data * - * `scrapbox.Page.lines`はdeep cloneされてしまうので、performanceの問題が発生する場合がある + * This function provides direct access to the page content without deep cloning, + * unlike `{@linkcode https://jsr.io/@cosense/types/doc/userscript/~/Page.lines scrapbox.Page.lines}` which creates a deep copy. Use this when: + * - You need better performance by avoiding data cloning + * - You only need to read the raw line data * - * なるべくデータのcloneを発生させたくない場合にこちらを使う + * > [!IMPORTANT] + * > - This returns a direct reference to the internal data. While the type definition + * > marks it as readonly, the content can still be modified through JavaScript. + * > Be careful not to modify the data to avoid unexpected behavior. + * > - Unlike `{@linkcode https://jsr.io/@cosense/types/doc/userscript/~/Page.lines scrapbox.Page.lines}`, the returned data does not include parsed + * > syntax information (no syntax tree or parsed line components). * - * 注意 - * - 参照をそのまま返しているだけなので、中身を書き換えられてしまう。型定義で変更不能にはしてあるが、JS経由ならいくらでも操作できる - * - `scrapbox.Page.lines`とは違って構文解析情報が付与されない + * @returns A {@linkcode ReadonlyArray}<{@linkcode BaseLine}> containing: + * - Success: The page content as a readonly array of line objects + * - Error: May throw one of: + * - `Error` when div.lines element is not found + * - `Error` when React fiber property is missing */ export const takeInternalLines = (): readonly BaseLine[] => { const linesEl = lines(); @@ -25,11 +35,21 @@ export const takeInternalLines = (): readonly BaseLine[] => { ); } - // @ts-ignore DOMを無理矢理objectとして扱っている + // @ts-ignore Accessing DOM element as an object to reach React's internal data. + // This is necessary to get the raw line data from React's component props. return (linesEl[reactKey] as ReactFiber).return.stateNode.props .lines as const; }; +/** Internal React Fiber node structure for accessing line data + * + * This interface represents the minimal structure needed to access + * the lines data from React's component props. This is an implementation + * detail that depends on React's internal structure. + * + * @interface + * @internal + */ interface ReactFiber { return: { stateNode: { diff --git a/browser/dom/textInputEventListener.ts b/browser/dom/textInputEventListener.ts index 3f1c76f..488dc5e 100644 --- a/browser/dom/textInputEventListener.ts +++ b/browser/dom/textInputEventListener.ts @@ -3,9 +3,13 @@ import { textInput } from "./dom.ts"; import { decode, encode } from "./_internal.ts"; declare const scrapbox: Scrapbox; -/** - first key: event name - * - second key: listener - * - value: encoded options +/** Map structure for tracking event listeners and their options + * + * Structure: + * - First level: Maps event names to their listeners + * - Second level: Maps each listener to its set of encoded options + * - The encoded options allow tracking multiple registrations of the same + * listener with different options */ const listenerMap = /* @__PURE__ */ new Map< keyof HTMLElementEventMap, @@ -36,14 +40,18 @@ let reRegister: (() => void) | undefined = () => { reRegister = undefined; }; -/** `#text-input`に対してイベントリスナーを追加する +/** Add an event listener to the `#text-input` element with automatic re-registration * - * `#text-input`はページレイアウトが変わると削除されるため、登録したイベントリスナーの記憶と再登録をこの関数で行っている + * In Scrapbox, the `#text-input` element is recreated when the page layout changes. + * This function manages event listeners by: + * 1. Storing the listener and its options in a persistent map + * 2. Automatically re-registering all listeners when layout changes + * 3. Handling both regular and once-only event listeners * - * @param name event name - * @param listener event listener - * @param options event listener options - * @returns + * @param name - The event type to listen for (e.g., 'input', 'keydown') + * @param listener - The callback function to execute when the event occurs + * @param options - Standard addEventListener options or boolean for useCapture + * @returns {@linkcode void} */ export const addTextInputEventListener = ( name: K, @@ -65,7 +73,11 @@ export const addTextInputEventListener = ( new Map(); const encoded = encode(options); - /** 呼び出し時に、`listenerMap`からの登録も解除するwrapper listener */ + /** A wrapper listener that removes itself from the `listenerMap` when called + * + * This wrapper ensures proper cleanup of both the DOM event listener and our + * internal listener tracking when a 'once' listener is triggered. + */ const onceListener = function ( this: HTMLTextAreaElement, event: HTMLElementEventMap[K], diff --git a/browser/websocket/__snapshots__/findMetadata.test.ts.snap b/browser/websocket/__snapshots__/findMetadata.test.ts.snap index cd4b09e..cb19126 100644 --- a/browser/websocket/__snapshots__/findMetadata.test.ts.snap +++ b/browser/websocket/__snapshots__/findMetadata.test.ts.snap @@ -3,12 +3,12 @@ export const snapshot = {}; snapshot[`findMetadata() 1`] = ` [ [ - "ふつうの", - "リンク2", + "normal", + "link2", "hashtag", ], [ - "/help-jp/外部リンク", + "/help-en/external-link", ], [ "scrapbox", @@ -21,13 +21,13 @@ snapshot[`findMetadata() 1`] = ` "65e7f4413bc95600258481fb", ], [ - "助けてhelpfeel!!", + "Need help with setup!!", ], [ - "名前 [scrapbox.icon]", - "住所 [リンク2]を入れること", - "電話番号 #をつけてもリンクにならないよ", - "自分の強み 3個くらい列挙", + "Name [scrapbox.icon]", + "Address Add [link2] here", + "Phone Adding # won't create a link", + "Strengths List about 3 items", ], ] `; diff --git a/browser/websocket/_codeBlock.test.ts b/browser/websocket/_codeBlock.test.ts index 12fd9d6..0238dc4 100644 --- a/browser/websocket/_codeBlock.test.ts +++ b/browser/websocket/_codeBlock.test.ts @@ -2,16 +2,23 @@ import { assertEquals } from "@std/assert"; import { assertSnapshot } from "@std/testing/snapshot"; import { extractFromCodeTitle } from "./_codeBlock.ts"; +/** + * Tests for code block title parsing functionality + * + * These tests verify the parsing of code block titles in various formats: + * - Valid formats: code:filename.ext(param), code:filename(param), code:filename.ext + * - Invalid formats: trailing dots, incorrect prefixes, non-code blocks + */ Deno.test("extractFromCodeTitle()", async (t) => { await t.step("accurate titles", async (st) => { const titles = [ - "code:foo.extA(extB)", - " code:foo.extA(extB)", - " code: foo.extA (extB)", - " code: foo (extB) ", - " code: foo.extA ", - " code: foo ", - " code: .foo ", + "code:foo.extA(extB)", // Basic format: no spaces + " code:foo.extA(extB)", // Leading space before code: + " code: foo.extA (extB)", // Spaces around components + " code: foo (extB) ", // Extension omitted, has parameter + " code: foo.extA ", // Extension only, no parameter + " code: foo ", // Basic name only + " code: .foo ", // Leading dot in name ]; for (const title of titles) { await st.step(`"${title}"`, async (sst) => { @@ -22,9 +29,10 @@ Deno.test("extractFromCodeTitle()", async (t) => { await t.step("inaccurate titles", async (st) => { const nonTitles = [ - " code: foo. ", // コードブロックにはならないので`null`が正常 - "any:code: foo ", - " I'm not code block ", + " code: foo. ", // Invalid: Trailing dot without extension is not a valid code block format + // Returning `null` is expected as this format is invalid + "any:code: foo ", // Invalid: Must start with exactly "code:" prefix + " I'm not code block ", // Invalid: Not a code block format at all ]; for (const title of nonTitles) { await st.step(`"${title}"`, async () => { diff --git a/browser/websocket/_codeBlock.ts b/browser/websocket/_codeBlock.ts index 83d5db0..ea75aec 100644 --- a/browser/websocket/_codeBlock.ts +++ b/browser/websocket/_codeBlock.ts @@ -1,16 +1,31 @@ import type { TinyCodeBlock } from "../../rest/getCodeBlocks.ts"; -/** コードブロックのタイトル行の情報を保持しておくためのinterface */ +/** Interface for storing code block title line information + * + * In Scrapbox, code blocks start with a title line that defines: + * - The code's filename or language identifier + * - Optional language specification in parentheses + * - Indentation level for nested blocks + */ export interface CodeTitle { filename: string; lang: string; indent: number; } -/** コードブロックのタイトル行から各種プロパティを抽出する +/** Extract properties from a code block title line + * + * This function parses a line of text to determine if it's a valid code block title. + * Valid formats include: + * - `code:filename.ext` - Language determined by extension + * - `code:filename(lang)` - Explicit language specification + * - `code:lang` - Direct language specification without filename * - * @param lineText {string} 行テキスト - * @return `lineText`がコードタイトル行であれば`CodeTitle`を、そうでなければ`null`を返す + * @param lineText - The line text to parse + * @returns A {@linkcode CodeTitle} | {@linkcode null}: + * - Success: A {@linkcode CodeTitle} object containing filename and language info + * - Error: {@linkcode null} if the line is not a valid code block title + * and indentation level. */ export const extractFromCodeTitle = (lineText: string): CodeTitle | null => { const matched = lineText.match(/^(\s*)code:(.+?)(\(.+\)){0,1}\s*$/); @@ -23,7 +38,8 @@ export const extractFromCodeTitle = (lineText: string): CodeTitle | null => { // `code:ext` lang = filename; } else if (ext[1] === "") { - // `code:foo.`の形式はコードブロックとして成り立たないので排除する + // Reject "code:foo." format as it's invalid (trailing dot without extension) + // This ensures code blocks have either a valid extension or no extension at all return null; } else { // `code:foo.ext` @@ -39,7 +55,12 @@ export const extractFromCodeTitle = (lineText: string): CodeTitle | null => { }; }; -/** コードブロック本文のインデント数を計算する */ +/** Calculate the indentation level for code block content + * + * The content of a code block is indented one level deeper than its title line. + * This function determines the correct indentation by analyzing the title line's + * whitespace and adding one additional level. + */ export function countBodyIndent( codeBlock: Pick, ): number { diff --git a/browser/websocket/applyCommit.ts b/browser/websocket/applyCommit.ts index f091721..947eec3 100644 --- a/browser/websocket/applyCommit.ts +++ b/browser/websocket/applyCommit.ts @@ -3,19 +3,35 @@ import type { BaseLine } from "@cosense/types/rest"; import { getUnixTimeFromId } from "./id.ts"; export interface ApplyCommitProp { - /** changesの作成日時 + /** Timestamp for when the changes were created * - * UnixTimeか、UnixTimeを含んだidを渡す。 - * 何も指定されなかったら、実行時の時刻を用いる + * Can be provided as either: + * - A Unix timestamp (number) + * - An ID containing a Unix timestamp (string) + * If not specified, the current time will be used */ updated?: number | string; - /** user id */ userId: string; + /** The ID of the user making the changes + * + * This ID is used to: + * - Track who made each line modification + * - Associate changes with user accounts + * - Maintain edit history and attribution + */ + userId: string; } -/** メタデータを含んだ行にcommitsを適用する +/** Apply commits to lines with metadata + * + * This function processes a series of commits (changes) to modify lines in a Scrapbox page. + * Each commit can be one of: + * - Insert: Add a new line at a specific position or at the end + * - Update: Modify the text of an existing line + * - Delete: Remove a line * - * @param lines commitsを適用する行 - * @param changes 適用するcommits + * @param lines - The lines to apply commits to, each containing metadata (id, text, etc.) + * @param changes - The commits to apply, each specifying an operation and target line + * @param options - Configuration including userId and optional timestamp */ export const applyCommit = ( lines: readonly BaseLine[], diff --git a/browser/websocket/change.ts b/browser/websocket/change.ts index 7ffe7c4..875be66 100644 --- a/browser/websocket/change.ts +++ b/browser/websocket/change.ts @@ -48,15 +48,27 @@ export interface TitleChange { title: string; } export interface FilesChange { - /** file id */ + /** Array of file IDs + * + * These IDs reference files that have been uploaded to the page. + * Files can include images, documents, or other attachments. + */ files: string[]; } export interface HelpFeelsChange { - /** Helpfeel記法の先頭の`? `をとったもの */ + /** Array of Helpfeel entries without the leading "? " prefix + * + * Helpfeel is a Scrapbox notation for creating help/documentation entries. + * Example: "? How to use" becomes "How to use" in this array. + * These entries are used to build the page's help documentation. + */ helpfeels: string[]; } export interface infoboxDefinitionChange { - /** `table:infobox`または`table:cosense`の各行をtrimしたもの */ + /** Array of trimmed lines from infobox tables + * + * Contains lines from tables marked with either `table:infobox` or `table:cosense` + */ infoboxDefinition: string[]; } export interface PinChange { diff --git a/browser/websocket/deletePage.ts b/browser/websocket/deletePage.ts index 94de937..998fc07 100644 --- a/browser/websocket/deletePage.ts +++ b/browser/websocket/deletePage.ts @@ -3,11 +3,14 @@ import type { Result } from "option-t/plain_result"; export type DeletePageOptions = PushOptions; -/** 指定したページを削除する +/** Delete a specified page whose title is `title` from a given `project` * - * @param project 削除したいページのproject - * @param title 削除したいページのタイトル - * @param options + * @param project - The project containing the page to delete + * @param title - The title of the page to delete + * @param options - Additional options for the delete operation + * @returns A {@linkcode Promise} that resolves to a {@linkcode Result} containing: + * - Success: The page title that was deleted as a {@linkcode string} + * - Error: A {@linkcode PushError} describing what went wrong */ export const deletePage = ( project: string, diff --git a/browser/websocket/emit.ts b/browser/websocket/emit.ts index bc5c142..8049176 100644 --- a/browser/websocket/emit.ts +++ b/browser/websocket/emit.ts @@ -37,12 +37,17 @@ export interface EmitOptions { /** * Sends an event to the socket and returns a promise that resolves with the result. * - * @template EventName - The name of the event to emit. - * @param socket - The socket to emit the event on. - * @param event - The name of the event to emit. - * @param data - The data to send with the event. - * @param options - Optional options for the emit operation. - * @returns A promise that resolves with the result of the emit operation. + * @template EventName - The name of the event to emit + * @param socket - The {@linkcode ScrapboxSocket} to emit the event on + * @param event - The name of the event to emit + * @param data - The data to send with the event + * @param options - Optional {@linkcode EmitOptions} for the operation + * @returns A {@linkcode Promise}<{@linkcode Result}> containing: + * - Success: The response data from the server + * - Error: One of several possible errors: + * - {@linkcode TimeoutError}: Request timed out + * - {@linkcode SocketIOServerDisconnectError}: Server disconnected + * - {@linkcode UnexpectedRequestError}: Unexpected response format */ export const emit = ( socket: ScrapboxSocket, @@ -63,8 +68,12 @@ export const emit = ( return Promise.resolve(createOk(undefined)); } - // [socket.io-request](https://github.com/shokai/socket.io-request)で処理されているイベント - // 同様の実装をすればいい + // This event is processed using the socket.io-request protocol + // (see: https://github.com/shokai/socket.io-request) + // We implement a similar request-response pattern here: + // 1. Send event with payload + // 2. Wait for response with timeout + // 3. Handle success/error responses const { resolve, promise, reject } = Promise.withResolvers< Result< WrapperdEmitEvents[EventName]["res"], @@ -80,13 +89,14 @@ export const emit = ( clearTimeout(timeoutId); }; const onDisconnect = (reason: Socket.DisconnectReason) => { - // "commit"および"room:join"で"io client disconnect"が発生することはない + // "io client disconnect" should never occur during "commit" or "room:join" operations + // This is an unexpected state that indicates a client-side connection issue if (reason === "io client disconnect") { dispose(); reject(new Error("io client disconnect")); return; } - // 復帰不能なエラー + // Unrecoverable error state if (reason === "io server disconnect") { dispose(); resolve(createErr({ name: "SocketIOError" })); diff --git a/browser/websocket/findMetadata.test.ts b/browser/websocket/findMetadata.test.ts index 6cd12e7..30ae4c8 100644 --- a/browser/websocket/findMetadata.test.ts +++ b/browser/websocket/findMetadata.test.ts @@ -2,35 +2,49 @@ import { findMetadata, getHelpfeels } from "./findMetadata.ts"; import { assertEquals } from "@std/assert"; import { assertSnapshot } from "@std/testing/snapshot"; -const text = `てすと -[ふつうの]リンク - しかし\`これは[リンク]\`ではない +// Test data for metadata extraction from a Scrapbox page +// This sample includes various Scrapbox syntax elements: +// - Regular links: [link-text] +// - Code blocks (links inside should be ignored) +// - Helpfeel notation: lines starting with "?" +// - Infobox tables +// - Hashtags +// - Project internal links: [/project/page] +// - Image links +const text = `test page +[normal]link + but \`this [link]\` is not a link code:code - コードブロック中の[リンク]や画像[https://scrapbox.io/files/65f29c0c9045b5002522c8bb.svg]は無視される + Links [link] and images [https://scrapbox.io/files/65f29c0c9045b5002522c8bb.svg] in code blocks should be ignored - ? 助けてhelpfeel!! + ? Need help with setup!! table:infobox - 名前 [scrapbox.icon] - 住所 [リンク2]を入れること - 電話番号 #をつけてもリンクにならないよ - 自分の強み 3個くらい列挙 - -#hashtag もつけるといいぞ? -[/forum-jp]のようなリンクは対象外 - [/help-jp/]もだめ - [/icons/なるほど.icon][takker.icon] -[/help-jp/外部リンク] - -サムネを用意 + Name [scrapbox.icon] + Address Add [link2] here + Phone Adding # won't create a link + Strengths List about 3 items + +#hashtag is recommended +[/forum-en] links should be excluded + [/help-en/] too + [/icons/example.icon][takker.icon] +[/help-en/external-link] + +Prepare thumbnail [https://scrapbox.io/files/65f29c24974fd8002333b160.svg] [https://scrapbox.io/files/65e7f4413bc95600258481fb.svg https://scrapbox.io/files/65e7f82e03949c0024a367d0.svg]`; +// Test findMetadata function's ability to extract various metadata from a page Deno.test("findMetadata()", (t) => assertSnapshot(t, findMetadata(text))); -Deno.test("getHelpfeels()", () => + +// Test Helpfeel extraction (lines starting with "?") +// These are used for collecting questions and help requests in Scrapbox +Deno.test("getHelpfeels()", () => { assertEquals(getHelpfeels(text.split("\n").map((text) => ({ text }))), [ - "助けてhelpfeel!!", - ])); + "Need help with setup!!", + ]); +}); diff --git a/browser/websocket/findMetadata.ts b/browser/websocket/findMetadata.ts index 59fe676..21ca1b7 100644 --- a/browser/websocket/findMetadata.ts +++ b/browser/websocket/findMetadata.ts @@ -3,10 +3,20 @@ import type { BaseLine } from "@cosense/types/userscript"; import { toTitleLc } from "../../title.ts"; import { parseYoutube } from "../../parser/youtube.ts"; -/** テキストに含まれているメタデータを取り出す +/** Extract metadata from Scrapbox page text * - * @param text Scrapboxのテキスト - * @return 順に、links, projectLinks, icons, image, files, helpfeels, infoboxDefinition + * This function parses a Scrapbox page and extracts various types of metadata: + * - links: Regular page links and hashtags + * - projectLinks: Links to pages in other projects + * - icons: User icons and decorative icons + * - image: First image or YouTube thumbnail for page preview + * - files: Attached file IDs + * - helpfeels: Questions or help requests (lines starting with "?") + * - infoboxDefinition: Structured data from infobox tables + * + * @param text - Raw text content of a Scrapbox page + * @returns A tuple containing [links, projectLinks, icons, image, files, helpfeels, infoboxDefinition] + * where image can be null if no suitable preview image is found */ export const findMetadata = ( text: string, @@ -30,12 +40,14 @@ export const findMetadata = ( } }); - /** 重複判定用map + /** Map for detecting duplicate links while preserving link type information * - * bracket link とhashtagを区別できるようにしている - * - bracket linkならtrue + * This map stores lowercase page titles and tracks their link type: + * - true: Link is a bracket link [like this] + * - false: Link is a hashtag #like-this * - * linkの形状はbracket linkを優先している + * When the same page is referenced by both formats, + * we prioritize the bracket link format in the final output */ const linksLc = new Map(); const links = [] as string[]; @@ -174,7 +186,12 @@ export const findMetadata = ( const cutId = (link: string): string => link.replace(/#[a-f\d]{24,32}$/, ""); -/** テキストからHelpfeel記法のentryだけ取り出す */ +/** Extract Helpfeel entries from text + * + * Helpfeel is a Scrapbox notation for questions and help requests. + * Lines starting with "?" are considered Helpfeel entries and are + * used to collect questions and support requests within a project. + */ export const getHelpfeels = (lines: Pick[]): string[] => lines.flatMap(({ text }) => /^\s*\? .*$/.test(text) ? [text.trimStart().slice(2)] : [] diff --git a/browser/websocket/isSimpleCodeFile.ts b/browser/websocket/isSimpleCodeFile.ts index f85436f..3217dda 100644 --- a/browser/websocket/isSimpleCodeFile.ts +++ b/browser/websocket/isSimpleCodeFile.ts @@ -1,6 +1,21 @@ import type { SimpleCodeFile } from "./updateCodeFile.ts"; -/** objectがSimpleCodeFile型かどうかを判別する */ +/** Check if an object is a {@linkcode SimpleCodeFile} + * + * {@linkcode SimpleCodeFile} represents a code block in Scrapbox with: + * - filename: Name of the code file or block identifier + * - content: Code content as string or array of strings + * - lang: Optional programming language identifier + * + * This function performs runtime type checking to ensure: + * 1. Input is an object (not array or primitive) + * 2. filename is a string + * 3. content is either: + * - a string (single-line code) + * - an array of strings (multi-line code) + * - an empty array (empty code block) + * 4. lang is either undefined or a string + */ export function isSimpleCodeFile(obj: unknown): obj is SimpleCodeFile { if (Array.isArray(obj) || !(obj instanceof Object)) return false; const code = obj as SimpleCodeFile; diff --git a/browser/websocket/listen.ts b/browser/websocket/listen.ts index 7e8da89..583f0d2 100644 --- a/browser/websocket/listen.ts +++ b/browser/websocket/listen.ts @@ -26,11 +26,31 @@ export type ListenStreamError = | AbortError | HTTPError; -/** Streamを購読する +/** Subscribe to WebSocket events from Scrapbox * - * @param project 購読したいproject - * @param events 購読したいevent。配列で指定する - * @param options 使用したいSocketがあれば指定する + * This function sets up event listeners for Scrapbox's WebSocket events: + * - Uses socket.on() for continuous listening + * - Uses socket.once() for one-time events when options.once is true + * - Supports automatic cleanup with AbortSignal + * + * @param socket - ScrapboxSocket instance for WebSocket communication + * @param event - Event name to listen for (from {@linkcode ListenEvents} type) + * @param listener - Callback function to handle the event + * @param options - Optional configuration + * + * @example + * ```typescript + * import { connect } from "@cosense/std/browser/websocket"; + * import { unwrapOk } from "option-t/plain_result"; + * + * // Setup socket and controller + * const socket = unwrapOk(await connect()); + * + * // Listen for pages' changes in a specified project + * listen(socket, "projectUpdatesStream:commit", (data) => { + * console.log("Project updated:", data); + * }); + * ``` */ export const listen = ( socket: ScrapboxSocket, diff --git a/browser/websocket/makeChanges.ts b/browser/websocket/makeChanges.ts index 51f0ce4..9a7e1fd 100644 --- a/browser/websocket/makeChanges.ts +++ b/browser/websocket/makeChanges.ts @@ -9,27 +9,41 @@ export function* makeChanges( after: string[], userId: string, ): Generator { - // 改行文字が入るのを防ぐ + // Prevent newline characters from being included in the text + // This ensures consistent line handling across different platforms const after_ = after.flatMap((text) => text.split("\n")); - // 本文の差分を先に返す + + // First, yield changes in the main content + // Content changes must be processed before metadata to maintain consistency for (const change of diffToChanges(before.lines, after_, { userId })) { yield change; } - // titleの差分を入れる - // 空ページの場合もタイトル変更commitを入れる + // Handle title changes + // Note: We always include title change commits for new pages (`persistent === false`) + // to ensure proper page initialization if (before.lines[0].text !== after_[0] || !before.persistent) { yield { title: after_[0] }; } - // descriptionsの差分を入れる + // Process changes in page descriptions + // Descriptions are the first 5 lines after the title (lines 1-5) + // These lines provide a summary or additional context for the page const leftDescriptions = before.lines.slice(1, 6).map((line) => line.text); const rightDescriptions = after_.slice(1, 6); if (leftDescriptions.join("") !== rightDescriptions.join("")) { yield { descriptions: rightDescriptions }; } - // 各種メタデータの差分を入れる + // Process changes in various metadata + // Metadata includes: + // - links: References to other pages + // - projectLinks: Links to other Scrapbox projects + // - icons: Page icons or thumbnails + // - image: Main page image + // - files: Attached files + // - helpfeels: Questions or help requests (lines starting with "?") + // - infoboxDefinition: Structured data definitions const [ links, projectLinks, diff --git a/browser/websocket/patch.ts b/browser/websocket/patch.ts index 8645680..88c0dd0 100644 --- a/browser/websocket/patch.ts +++ b/browser/websocket/patch.ts @@ -9,21 +9,38 @@ import type { Socket } from "socket.io-client"; export type PatchOptions = PushOptions; export interface PatchMetadata extends Page { - /** 書き換えを再試行した回数 + /** Number of retry attempts for page modification * - * 初回は`0`で、再試行するたびに増える + * Starts at `0` for the first attempt and increments with each retry. + * This helps track and handle concurrent modification conflicts. */ attempts: number; } -/** ページ全体を書き換える +/** Modify an entire Scrapbox page by computing and sending only the differences * - * serverには書き換え前後の差分だけを送信する + * This function handles the entire page modification process: + * 1. Fetches current page content + * 2. Applies user-provided update function + * 3. Computes minimal changes needed + * 4. Handles errors (e.g., duplicate titles) + * 5. Retries on conflicts * - * @param project 書き換えたいページのproject - * @param title 書き換えたいページのタイトル - * @param update 書き換え後の本文を作成する函数。引数には現在の本文が渡される。空配列を返すとページが削除される。undefinedを返すと書き換えを中断する - * @param options 使用したいSocketがあれば指定する + * @param project - Project ID containing the target page + * @param title - Title of the page to modify + * @param update - Function to generate new content: + * - Input: Current page lines and metadata + * - Return values: + * - `string[]`: New page content + * - `undefined`: Abort modification + * - `[]`: Delete the page + * Can be async (returns `Promise`) + * @param options - Optional WebSocket configuration + * + * Special cases: + * - If update returns undefined: Operation is cancelled + * - If update returns []: Page is deleted + * - On duplicate title: Automatically suggests non-conflicting title */ export const patch = ( project: string, diff --git a/browser/websocket/pin.ts b/browser/websocket/pin.ts index 4c83029..9f47dbe 100644 --- a/browser/websocket/pin.ts +++ b/browser/websocket/pin.ts @@ -3,20 +3,33 @@ import type { Change } from "./change.ts"; import { push, type PushError, type PushOptions } from "./push.ts"; export interface PinOptions extends PushOptions { - /** ピン留め対象のページが存在しないときの振る舞いを変えるoption + /** Option to control behavior when the target page doesn't exist * - * -`true`: タイトルのみのページを作成してピン留めする - * - `false`: ピン留めしない + * - `true`: Create a new page with just the title and pin it + * - `false`: Do not pin (skip the operation) * - * @default false + * This is useful when you want to create and pin placeholder pages + * that will be filled with content later. + * + * @default {false} */ create?: boolean; } -/** 指定したページをピン留めする + +/** Pin a Scrapbox page to keep it at the top of the project * - * @param project ピン留めしたいページのproject - * @param title ピン留めしたいページのタイトル - * @param options 使用したいSocketがあれば指定する + * Pinned pages are sorted by their pin number, which is calculated + * based on the current timestamp to maintain a stable order. + * Higher pin numbers appear first in the list. + * + * @param project - Project containing the target page + * @param title - Title of the page to pin + * @param options - Optional settings: + * - socket: Custom WebSocket connection + * - create: Whether to create non-existent pages + * @returns A {@linkcode Promise} that resolves to a {@linkcode Result} containing: + * - Success: The title of the pinned page as a {@linkcode string} + * - Error: A {@linkcode PushError} describing what went wrong */ export const pin = ( project: string, @@ -27,12 +40,13 @@ export const pin = ( project, title, (page) => { - // 既にピン留めされている場合は何もしない + // Skip if already pinned or if page doesn't exist and create=false if ( page.pin > 0 || (!page.persistent && !(options?.create ?? false)) ) return []; - // @ts-ignore 多分ページ作成とピン留めを同時に行っても怒られない……はず - const changes: Change[] = [{ pin: pinNumber() }] as Change[]; + // Create page and pin it in a single operation + // @ts-ignore The server is expected to accept combined creation and pin operations + const changes: Change[] = [{ pin: pinNumber() }]; if (!page.persistent) changes.unshift({ title }); return changes; }, @@ -40,10 +54,16 @@ export const pin = ( ); export interface UnPinOptions extends PushOptions {} -/** 指定したページのピン留めを外す + +/** Unpin a Scrapbox page, removing it from the pinned list * - * @param project ピン留めを外したいページのproject - * @param title ピン留めを外したいページのタイトル + * This sets the page's pin number to `0`, which effectively unpins it. + * + * @param project - Project containing the target page + * @param title - Title of the page to unpin + * @returns A {@linkcode Promise} that resolves to a {@linkcode Result} containing: + * - Success: The title of the unpinned page as a {@linkcode string} + * - Error: A {@linkcode PushError} describing what went wrong */ export const unpin = ( project: string, @@ -54,10 +74,20 @@ export const unpin = ( project, title, (page) => - // 既にピンが外れているか、そもそも存在しないページの場合は何もしない + // Skip if already unpinned or if page doesn't exist page.pin == 0 || !page.persistent ? [] : [{ pin: 0 }], options, ); +/** Calculate a pin number for sorting pinned pages + * + * The pin number is calculated as: + * the {@linkcode Number.MAX_SAFE_INTEGER} - (current Unix timestamp in seconds) + * + * This ensures that: + * 1. More recently pinned pages appear lower in the pinned list + * 2. Pin numbers are unique and stable + * 3. There's enough room for future pins (the {@linkcode Number.MAX_SAFE_INTEGER} is very large) + */ export const pinNumber = (): number => Number.MAX_SAFE_INTEGER - Math.floor(Date.now() / 1000); diff --git a/browser/websocket/pull.ts b/browser/websocket/pull.ts index 6af9f72..44497c0 100644 --- a/browser/websocket/pull.ts +++ b/browser/websocket/pull.ts @@ -23,11 +23,27 @@ import type { HTTPError } from "../../rest/responseIntoResult.ts"; import type { AbortError, NetworkError } from "../../rest/robustFetch.ts"; import type { BaseOptions } from "../../rest/options.ts"; +/** Extended page metadata required for WebSocket operations + * + * This interface extends the basic {@linkcode Page} type with additional identifiers + * needed for real-time collaboration and page modifications. + */ export interface PushMetadata extends Page { + /** Unique identifier of the project containing the page */ projectId: string; + /** Unique identifier of the current user */ userId: string; } +/** Comprehensive error type for page data retrieval operations + * + * This union type includes all possible errors that may occur when + * fetching page data, including: + * - Authentication errors: {@linkcode NotLoggedInError} + * - Authorization errors: {@linkcode NotMemberError} + * - Resource errors: {@linkcode NotFoundError}, {@linkcode TooLongURIError} + * - Network errors: {@linkcode NetworkError}, {@linkcode AbortError}, {@linkcode HTTPError} + */ export type PullError = | NotFoundError | NotLoggedInError @@ -38,6 +54,29 @@ export type PullError = | NetworkError | AbortError; +/** Fetch page data along with required metadata for WebSocket operations + * + * This function performs three parallel operations: + * 1. Fetches the page content + * 2. Retrieves the current user's ID (with caching) + * 3. Retrieves the project ID (with caching) + * + * If any operation fails, the entire operation fails with appropriate error. + * + * @param project - Project containing the target page + * @param title - Title of the page to fetch + * @param options - Optional settings for the page request + * @returns A {@linkcode Result} containing: + * - Success: A {@linkcode PushMetadata} object with page data and required metadata + * - Error: A {@linkcode PullError} which could be one of: + * - {@linkcode NotFoundError}: Page not found + * - {@linkcode NotLoggedInError}: User not authenticated + * - {@linkcode NotMemberError}: User not authorized + * - {@linkcode TooLongURIError}: Request URI too long + * - {@linkcode HTTPError}: General HTTP error + * - {@linkcode NetworkError}: Network connectivity issue + * - {@linkcode AbortError}: Request aborted + */ export const pull = async ( project: string, title: string, @@ -57,10 +96,25 @@ export const pull = async ( userId: unwrapOk(userRes), }); }; -// TODO: 編集不可なページはStream購読だけ提供する +// TODO: For read-only pages, provide stream subscription only -/** cached user ID */ +/** Cached user ID to avoid redundant profile API calls */ let userId: string | undefined; + +/** Get the current user's ID with caching + * + * This function caches the user ID in memory to reduce API calls. + * The cache is invalidated when the page is reloaded. + * + * @param init - Optional base request options + * @returns A {@linkcode Result} containing: + * - Success: The user ID as a {@linkcode string} + * - Error: One of the following: + * - {@linkcode NotLoggedInError}: User not authenticated + * - {@linkcode NetworkError}: Network connectivity issue + * - {@linkcode AbortError}: Request aborted + * - {@linkcode HTTPError}: General HTTP error + */ const getUserId = async (init?: BaseOptions): Promise< Result< string, @@ -83,8 +137,26 @@ const getUserId = async (init?: BaseOptions): Promise< }); }; -/** cached pairs of project name and project id */ +/** Cache mapping project names to their unique IDs */ const projectMap = new Map(); + +/** Get a project's ID with caching + * + * This function maintains a cache of project IDs to reduce API calls. + * The cache is invalidated when the page is reloaded. + * + * @param project - Name of the project + * @param options - Optional base request options + * @returns A {@linkcode Result} containing: + * - Success: The project ID as a {@linkcode string} + * - Error: One of the following: + * - {@linkcode NotFoundError}: Project not found + * - {@linkcode NotLoggedInError}: User not authenticated + * - {@linkcode NotMemberError}: User not authorized + * - {@linkcode NetworkError}: Network connectivity issue + * - {@linkcode AbortError}: Request aborted + * - {@linkcode HTTPError}: General HTTP error + */ export const getProjectId = async ( project: string, options?: BaseOptions, diff --git a/browser/websocket/push.ts b/browser/websocket/push.ts index 45d4938..ab32561 100644 --- a/browser/websocket/push.ts +++ b/browser/websocket/push.ts @@ -29,32 +29,70 @@ import type { UnexpectedRequestError, } from "./error.ts"; +/** Configuration options for the push operation */ export interface PushOptions { - /** 外部からSocketを指定したいときに使う */ + /** Optional Socket instance for external WebSocket connection control + * + * This allows providing an existing Socket instance instead of creating + * a new one. Useful for reusing connections or custom Socket configurations. + */ socket?: Socket; - /** pushの再試行回数 + /** Maximum number of push retry attempts * - * これを上回ったら、`RetryError`を返す + * When this limit is exceeded, the operation fails with a {@linkcode RetryError}. + * Each retry occurs when there's a conflict ({@linkcode NotFastForwardError}) or + * duplicate title issue, allowing the client to resolve the conflict + * by fetching the latest page state and recreating the changes. */ maxAttempts?: number; } +/** Error returned when push retry attempts are exhausted + * + * This error indicates that the maximum number of retry attempts was + * reached without successfully applying the changes, usually due to + * concurrent modifications or persistent conflicts. + */ export interface RetryError { name: "RetryError"; message: string; + /** Number of attempts made before giving up */ attempts: number; } +/** Extended page metadata required for WebSocket operations + * + * This interface extends the basic Page type with additional identifiers + * needed for real-time collaboration and page modifications. + */ export interface PushMetadata extends Page { + /** Unique identifier of the project containing the page */ projectId: string; + /** Unique identifier of the current user */ userId: string; } +/** Error for unexpected conditions during push operations + * + * This error type is used when the push operation encounters an + * unexpected state or receives an invalid response. + */ export interface UnexpectedError extends ErrorLike { name: "UnexpectedError"; } +/** Comprehensive error type for push operations + * + * This union type includes all possible errors that may occur during + * a push operation, including: + * - Operation errors ({@linkcode RetryError}, {@linkcode UnexpectedError}) + * - WebSocket errors ({@linkcode SocketIOServerDisconnectError}, {@linkcode UnexpectedRequestError}) + * - Authentication errors ({@linkcode NotLoggedInError}) + * - Authorization errors ({@linkcode NotMemberError}) + * - Resource errors ({@linkcode NotFoundError}, {@linkcode TooLongURIError}) + * - Network errors ({@linkcode NetworkError}, {@linkcode AbortError}, {@linkcode HTTPError}) + */ export type PushError = | RetryError | SocketIOServerDisconnectError @@ -68,16 +106,20 @@ export type PushError = | NetworkError | AbortError; -/** - * pushしたいcommitを作る関数 +/** Function type for creating commits to be pushed * - * {@linkcode push} で使う + * This handler is called by {@linkcode push} to generate the changes + * that should be applied to the page. It may be called multiple times + * if conflicts occur, allowing the changes to be recreated based on + * the latest page state. * - * @param page ページのメタデータ - * @param attempts 何回目の試行か - * @param prev 前回のcommitの変更 - * @param reason 再試行した場合、その理由が渡される - * @returns commits + * @param page - Current page metadata including latest commit ID + * @param attempts - Current attempt number (starts from 1) + * @param prev - Changes from the previous attempt (empty on first try) + * @param reason - If retrying, explains why the previous attempt failed: + * - `"NotFastForwardError"`: Concurrent modification detected + * - `"DuplicateTitleError"`: Page title already exists + * @returns Array of changes to apply, or empty array to cancel the push */ export type CommitMakeHandler = ( page: PushMetadata, @@ -90,15 +132,26 @@ export type CommitMakeHandler = ( | [DeletePageChange] | [PinChange]; -/** 特定のページのcommitをpushする +/** Push changes to a specific page using WebSocket + * + * This function implements a robust page modification mechanism with: + * - Automatic conflict resolution through retries + * - Concurrent modification detection + * - WebSocket connection management + * - Error recovery strategies * - * serverからpush errorが返ってきた場合、エラーに応じてpushを再試行する + * The function will retry the push operation if server-side conflicts + * occur, allowing the client to fetch the latest page state and + * recreate the changes accordingly. * - * @param project project name - * @param title page title - * @param makeCommit pushしたいcommitを作る関数。空配列を返すとpushを中断する - * @param options - * @retrun 成功 or キャンセルのときは`commitId`を返す。再試行回数が上限に達したときは`RetryError`を返す + * @param project - Name of the project containing the page + * @param title - Title of the page to modify + * @param makeCommit - Function that generates the changes to apply. + * Return empty array to cancel the operation. + * @param options - Optional configuration for push behavior + * @returns On success/cancel: new commit ID + * On max retries: {@linkcode RetryError} + * On other errors: Various error types (see {@linkcode PushError}) */ export const push = async ( project: string, @@ -123,65 +176,89 @@ export const push = async ( let changes: Change[] | [DeletePageChange] | [PinChange] = []; let reason: "NotFastForwardError" | "DuplicateTitleError" | undefined; - // loop for create Diff + // Outer loop: handles diff creation and conflict resolution + // This loop continues until either: + // 1. Changes are successfully pushed + // 2. Operation is cancelled (empty changes) + // 3. Maximum attempts are reached while ( options?.maxAttempts === undefined || attempts < options.maxAttempts ) { + // Generate changes based on current page state + // If retrying, previous changes and failure reason are provided const pending = makeCommit(metadata, attempts, changes, reason); changes = pending instanceof Promise ? await pending : pending; attempts++; + + // Empty changes indicate operation cancellation if (changes.length === 0) return createOk(metadata.commitId); + // Prepare commit data for WebSocket transmission const data: PageCommit = { - kind: "page", - projectId: metadata.projectId, - pageId: metadata.id, - parentId: metadata.commitId, - userId: metadata.userId, - changes, - cursor: null, - freeze: true, + kind: "page", // Indicates page modification + projectId: metadata.projectId, // Project scope + pageId: metadata.id, // Target page + parentId: metadata.commitId, // Base commit for change + userId: metadata.userId, // Change author + changes, // Actual modifications + cursor: null, // No cursor position + freeze: true, // Prevent concurrent edits }; - // loop for push changes + // Inner loop: handles WebSocket communication and error recovery + // This loop continues until either: + // 1. Changes are successfully pushed + // 2. Fatal error occurs + // 3. Conflict requires regenerating changes while (true) { const result = await emit(socket, "commit", data); if (isOk(result)) { + // Update local commit ID on successful push metadata.commitId = unwrapOk(result).commitId; - // success return createOk(metadata.commitId); } + const error = unwrapErr(result); const name = error.name; + + // Fatal errors: connection or protocol issues if ( name === "SocketIOServerDisconnectError" || name === "UnexpectedRequestError" ) { return createErr(error); } + + // Temporary errors: retry after delay if (name === "TimeoutError" || name === "SocketIOError") { - await delay(3000); - // go back to the push loop - continue; + await delay(3000); // Wait 3 seconds before retry + continue; // Retry push with same changes } + + // Conflict error: page was modified by another user if (name === "NotFastForwardError") { - await delay(1000); + await delay(1000); // Brief delay to avoid rapid retries + // Fetch latest page state const pullResult = await pull(project, title); if (isErr(pullResult)) return pullResult; metadata = unwrapOk(pullResult); } + + // Store error for next attempt and regenerate changes reason = name; - // go back to the diff loop - break; + break; // Exit push loop, retry with new changes } } + + // All retry attempts exhausted return createErr({ name: "RetryError", attempts, - // from https://github.com/denoland/deno_std/blob/0.223.0/async/retry.ts#L23 + // Error message format from [Deno standard library](https://github.com/denoland/deno_std/blob/0.223.0/async/retry.ts#L23) message: `Retrying exceeded the maxAttempts (${attempts}).`, }); } finally { + // Clean up WebSocket connection if we created it if (!options?.socket) await disconnect(socket); } }; diff --git a/browser/websocket/socket.ts b/browser/websocket/socket.ts index c4c38c4..34cbfff 100644 --- a/browser/websocket/socket.ts +++ b/browser/websocket/socket.ts @@ -8,8 +8,8 @@ export type ScrapboxSocket = Socket; /** connect to websocket * - * @param socket - The socket to be connected. If not provided, a new socket will be created - * @returns A promise that resolves to a socket if connected successfully, or an error if failed + * @param socket - The {@linkcode Socket} to be connected. If not provided, a new socket will be created + * @returns A {@linkcode Promise}<{@linkcode Socket}> that resolves to a {@linkcode Socket} if connected successfully, or an {@linkcode Error} if failed */ export const connect = (socket?: ScrapboxSocket): Promise< Result diff --git a/browser/websocket/updateCodeBlock.ts b/browser/websocket/updateCodeBlock.ts index c788a94..2489f7d 100644 --- a/browser/websocket/updateCodeBlock.ts +++ b/browser/websocket/updateCodeBlock.ts @@ -8,27 +8,58 @@ import { countBodyIndent, extractFromCodeTitle } from "./_codeBlock.ts"; import { push, type PushError, type PushOptions } from "./push.ts"; import type { Result } from "option-t/plain_result"; +/** Configuration options for code block updates + * + * Extends {@linkcode PushOptions} to include debugging capabilities while + * maintaining all WebSocket connection and retry settings. + */ export interface UpdateCodeBlockOptions extends PushOptions { - /** `true`でデバッグ出力ON */ + /** Enable debug output when `true` + * + * When enabled, logs detailed information about: + * - Original code block state + * - New code content + * - Generated change commits + * + * @default {false} + */ debug?: boolean; } -/** コードブロックの中身を更新する +/** Update the content of a code block in a Scrapbox page + * + * This function handles the complete process of updating a code block: + * 1. Content modification with proper indentation + * 2. Diff generation for minimal changes + * 3. Optional filename/language updates + * 4. {@linkcode WebSocket}-based synchronization * - * newCodeにSimpleCodeFileオブジェクトを渡すと、そのオブジェクトに添ってコードブロックのファイル名も書き換えます - * (文字列や文字列配列を渡した場合は書き換えません)。 + * When provided with a {@linkcode SimpleCodeFile} object, this function will also + * update the code block's filename and language settings. {@linkcode string} or + * {@linkcode string}[] inputs will only modify the content while preserving + * the existing filename and language. * - * @param newCode 更新後のコードブロック - * @param target 更新対象のコードブロック - * @param project 更新対象のコードブロックが存在するプロジェクト名 + * @param newCode - New content for the code block: + * - {@linkcode string}: Single-line content + * - {@linkcode string}[]: Multi-line content + * - {@linkcode SimpleCodeFile}: Content with metadata (filename, language) + * @param target - Existing code block to update, including its current state and page information + * @param options - Optional configuration for debugging and WebSocket connection management + * @returns A {@linkcode Promise}<{@linkcode Result}<{@linkcode string}, {@linkcode PushError}>> containing: + * - Success: The new commit ID string + * - Error: One of several possible errors: + * - {@linkcode PushError}: WebSocket connection or synchronization error */ export const updateCodeBlock = ( newCode: string | string[] | SimpleCodeFile, target: TinyCodeBlock, options?: UpdateCodeBlockOptions, ): Promise> => { + // Extract and normalize the new code content const newCodeBody = getCodeBody(newCode); + // Calculate the indentation level of the existing code block const bodyIndent = countBodyIndent(target); + // Remove indentation from old code for accurate diff generation const oldCodeWithoutIndent: BaseLine[] = target.bodyLines.map((e) => { return { ...e, text: e.text.slice(bodyIndent) }; }); @@ -37,17 +68,27 @@ export const updateCodeBlock = ( target.pageInfo.projectName, target.pageInfo.pageTitle, (page) => { + // Generate minimal changes between old and new code + // The diffGenerator creates a sequence of {@linkcode InsertChange}/{@linkcode UpdateChange}/{@linkcode DeleteChange} + // operations that transform the old code into the new code const diffGenerator = diffToChanges( - oldCodeWithoutIndent, - newCodeBody, - page, + oldCodeWithoutIndent, // Original code without indentation + newCodeBody, // New code content + page, // Page metadata for line IDs ); + + // Process the {@linkcode DeleteChange}/{@linkcode InsertChange}/{@linkcode UpdateChange} operations + // to restore proper indentation and handle special cases like end-of-block insertions const commits = [...fixCommits([...diffGenerator], target)]; + + // If we're updating from a SimpleCodeFile, check if the + // title (filename/language) needs to be updated as well if (isSimpleCodeFile(newCode)) { const titleCommit = makeTitleChangeCommit(newCode, target); if (titleCommit) commits.push(titleCommit); } + // Debug output to help diagnose update issues if (options?.debug) { console.log("%cvvv original code block vvv", "color: limegreen;"); console.log(target); @@ -63,37 +104,69 @@ export const updateCodeBlock = ( ); }; -/** コード本文のテキストを取得する */ +/** Extract the actual code content from various input formats + * + * Handles different input types uniformly by converting them into + * an array of code lines. + * + * @param code - The input code in one of several formats: + * - {@linkcode SimpleCodeFile}: Content with metadata + * - {@linkcode string}[]: Array of code lines + * - {@linkcode string}: Single string to split into lines + * @returns An array of {@linkcode string} containing the code lines + */ const getCodeBody = (code: string | string[] | SimpleCodeFile): string[] => { const content = isSimpleCodeFile(code) ? code.content : code; if (Array.isArray(content)) return content; return content.split("\n"); }; -/** insertコミットの行IDとtextのインデントを修正する */ +/** Adjust line IDs and indentation in change commits + * + * This generator processes each change commit to ensure: + * 1. Proper indentation is maintained for all code lines + * 2. Line IDs are correctly assigned for insertions + * 3. Special handling for end-of-block insertions + * + * The function preserves the original block's indentation style + * while applying changes, ensuring consistent code formatting. + * + * @param commits - Array of {@linkcode DeleteChange}, {@linkcode InsertChange}, or {@linkcode UpdateChange} operations + * @param target - The {@linkcode TinyCodeBlock} to modify + * @returns A {@linkcode Generator} yielding either: + * - {@linkcode DeleteChange}: Remove lines from the code block + * - {@linkcode InsertChange}: Add new lines with proper indentation + * - {@linkcode UpdateChange}: Modify existing lines with indentation + */ function* fixCommits( commits: readonly (DeleteChange | InsertChange | UpdateChange)[], target: TinyCodeBlock, ): Generator { + // Get reference to the line after the code block for end insertions const { nextLine } = target; + // Calculate the indentation string based on the block's current style const indent = " ".repeat(countBodyIndent(target)); + + // Process each change commit to ensure proper formatting for (const commit of commits) { + // Delete operations don't need indentation adjustment if ("_delete" in commit) { yield commit; - } else if ( - "_update" in commit - ) { + } // Update operations need their text indented + else if ("_update" in commit) { yield { ...commit, lines: { ...commit.lines, - text: indent + commit.lines.text, + text: indent + commit.lines.text, // Add block's indentation }, }; - } else if ( - commit._insert != "_end" || - nextLine === null + } // Handle insert operations based on their position + else if ( + commit._insert != "_end" || // Not an end insertion + nextLine === null // No next line exists ) { + // Regular insertion - just add indentation yield { ...commit, lines: { @@ -102,8 +175,9 @@ function* fixCommits( }, }; } else { + // End insertion - use nextLine's ID and add indentation yield { - _insert: nextLine.id, + _insert: nextLine.id, // Insert before the next line lines: { ...commit.lines, text: indent + commit.lines.text, @@ -113,7 +187,26 @@ function* fixCommits( } } -/** コードタイトルが違う場合は書き換える */ +/** Generate a commit to update the code block's title + * + * Creates an update commit for the title line when the filename + * or language settings differ from the current block. The title + * format follows the pattern: + * - Basic: `filename.ext` + * - With language: `filename.ext(language)` + * + * The function is smart enough to: + * 1. Preserve existing title if no changes needed + * 2. Handle files without extensions + * 3. Only show language tag when it differs from the extension + * 4. Maintain proper indentation in the title line + * + * @param code - {@linkcode SimpleCodeFile} containing filename and optional language settings + * @param target - Existing code block with title line information + * @returns A {@linkcode Result}<{@linkcode UpdateChange} | null> containing: + * - Success: An {@linkcode UpdateChange} for updating the title line + * - Error: `null` if no changes are needed + */ const makeTitleChangeCommit = ( code: SimpleCodeFile, target: Pick, diff --git a/browser/websocket/updateCodeFile.ts b/browser/websocket/updateCodeFile.ts index 0d55346..384bf91 100644 --- a/browser/websocket/updateCodeFile.ts +++ b/browser/websocket/updateCodeFile.ts @@ -7,47 +7,117 @@ import { countBodyIndent } from "./_codeBlock.ts"; import { push, type PushError, type PushOptions } from "./push.ts"; import type { Result } from "option-t/plain_result"; -/** コードブロックの上書きに使う情報のinterface */ +/** Interface for specifying code block content and metadata for updates + * + * This interface is used when you want to update or create a code block in a Scrapbox page. + * It contains all necessary information about the code block, including its filename, + * content, and optional language specification for syntax highlighting. + */ export interface SimpleCodeFile { - /** ファイル名 */ + /** The filename to be displayed in the code block's title + * This will appear as `code:{filename}` in the Scrapbox page + */ filename: string; - /** コードブロックの中身(文字列のみ) */ + /** The actual content of the code block + * Can be provided either as a single string (will be split by newlines) + * or as an array of strings (each element represents one line) + */ content: string | string[]; - /** コードブロック内の強調表示に使う言語名(省略時はfilenameに含まれる拡張子を使用する) */ + /** Optional language name for syntax highlighting + * If omitted, the file extension from the filename will be used + * Example: for "main.py", Python highlighting will be used automatically + */ lang?: string; } -/** updateCodeFile()に使われているオプション */ +/** Configuration options for {@linkcode updateCodeFile} function + * + * These options control how code blocks are created, updated, and formatted + * in the Scrapbox page. They extend the standard PushOptions with additional + * settings specific to code block management. + */ export interface UpdateCodeFileOptions extends PushOptions { /** - * 指定したファイルが存在しなかった時、新しいコードブロックをページのどの位置に配置するか + * Specifies where to place a new code block when the target file doesn't exist + * + * - `"notInsert"` (default): Take no action if the file doesn't exist + * - `"top"`: Insert at the top of the page (immediately after the title line) + * - `"bottom"`: Insert at the bottom of the page + * + * This option is particularly useful when you want to ensure code blocks + * are created in a consistent location across multiple pages. * - * - `"notInsert"`(既定):存在しなかった場合は何もしない - * - `"top"`:ページ上部(タイトル行の真下) - * - `"bottom"`:ページ下部 + * @default {"notInsert"} */ insertPositionIfNotExist?: "top" | "bottom" | "notInsert"; - /** `true`の場合、コードブロック作成時に空行承り太郎(ページ末尾に必ず空行を設ける機能)を有効する(既定は`true`) */ + /** Controls automatic empty line insertion at the end of the page + * + * When `true` (default), automatically adds an empty line after the code block + * This helps maintain consistent page formatting and improves readability by: + * - Ensuring visual separation between content blocks + * - Making it easier to add new content after the code block + * - Maintaining consistent spacing across all pages + * + * @default {true} + */ isInsertEmptyLineInTail?: boolean; - /** `true`でデバッグ出力ON */ + /** Enable debug output for troubleshooting + * + * When `true`, logs detailed information about the update process: + * - Original code block content and structure + * - New code being inserted or updated + * - Generated commit operations + * + * Useful for understanding how the code block is being modified + * and diagnosing any unexpected behavior. + */ debug?: boolean; } -/** REST API経由で取得できるようなコードファイルの中身をまるごと書き換える +/** Update or create code blocks in a Scrapbox page via REST API * - * ファイルが存在していなかった場合、既定では何も書き換えない \ + * This function provides a comprehensive way to manage code blocks in Scrapbox pages. + * It can handle various scenarios including: + * - Updating existing code blocks + * - Creating new code blocks (when configured via options) + * - Handling multiple code blocks with the same name + * - Preserving indentation and block structure * - * 対象と同じ名前のコードブロックが同じページの複数の行にまたがっていた場合も全て書き換える \ - * その際、書き換え後のコードをそれぞれのコードブロックへ分散させるが、それっぽく分けるだけで見た目などは保証しないので注意 + * ## Key Features: + * 1. Safe by default: Does nothing if the target file doesn't exist (configurable) + * 2. Handles multiple blocks: Can update all code blocks with the same name + * 3. Maintains structure: Preserves indentation and block formatting + * 4. Smart distribution: When updating multiple blocks, distributes content logically * - * @param codeFile 書き換え後のコードファイルの中身 - * @param project 書き換えたいページのプロジェクト名(Project urlの設定で使われている方) - * @param title 書き換えたいページのタイトル - * @param options その他の設定 + * ## Important Notes: + * - When multiple code blocks with the same name exist, the new content will be + * distributed across them. While the function attempts to maintain a logical + * distribution, the exact visual layout is not guaranteed. + * - The function uses diff generation to create minimal changes, helping to + * preserve the page's history and avoid unnecessary updates. + * + * @param codeFile - New content and metadata for the code file + * @param project - Project name as used in the project URL settings + * @param title - Title of the page to update + * @param options - Additional configuration options (see {@linkcode UpdateCodeFileOptions}) + * + * @example + * ```typescript + * await updateCodeFile( + * { + * filename: "example.ts", + * content: "console.log('Hello');", + * lang: "typescript" + * }, + * "myproject", + * "MyPage", + * { insertPositionIfNotExist: "bottom" } + * ); + * ``` */ export const updateCodeFile = ( codeFile: SimpleCodeFile, @@ -55,7 +125,7 @@ export const updateCodeFile = ( title: string, options?: UpdateCodeFileOptions, ): Promise> => { - /** optionsの既定値はこの中に入れる */ + /** Set default values for options here */ const defaultOptions: Required< Omit > = { @@ -96,8 +166,17 @@ export const updateCodeFile = ( ); }; -/** TinyCodeBlocksの配列からコード本文をフラットな配列に格納して返す \ - * その際、コードブロックの左側に存在していたインデントは削除する +/** Convert an array of {@linkcode TinyCodeBlock}s into a flat array of code lines + * + * This helper function processes multiple code blocks and: + * 1. Combines all code block contents into a single array + * 2. Removes leading indentation from each line + * 3. Preserves line IDs and other metadata + * + * The resulting flat array is used for efficient diff generation + * when comparing old and new code content. Removing indentation + * ensures accurate content comparison regardless of the block's + * position in the page. */ const flatCodeBodies = (codeBlocks: readonly TinyCodeBlock[]): BaseLine[] => { return codeBlocks.map((block) => { @@ -108,7 +187,20 @@ const flatCodeBodies = (codeBlocks: readonly TinyCodeBlock[]): BaseLine[] => { }).flat(); }; -/** コードブロックの差分からコミットデータを作成する */ +/** Generate commit operations from code block differences + * + * This function analyzes the differences between old and new code content + * to create a sequence of commit operations that will transform the old + * content into the new content. It handles: + * + * 1. Line additions (with proper indentation) + * 2. Line deletions + * 3. Line modifications + * 4. Empty line management + * + * The function maintains proper indentation for each code block and + * ensures consistent formatting across the entire page. + */ function* makeCommits( _codeBlocks: readonly TinyCodeBlock[], codeFile: SimpleCodeFile, @@ -133,13 +225,13 @@ function* makeCommits( >[] = [..._codeBlocks]; const codeBodies = flatCodeBodies(_codeBlocks); if (codeBlocks.length <= 0) { - // ページ内にコードブロックが無かった場合は新しく作成 + // Create a new code block if none exists in the page if (insertPositionIfNotExist === "notInsert") return; const nextLine = insertPositionIfNotExist === "top" && lines.length > 1 ? lines[1] : null; const title = { - // コードブロックのタイトル行 + // Code block title line _insert: nextLine?.id ?? "_end", lines: { id: createNewLineId(userId), @@ -214,7 +306,7 @@ function* makeCommits( lineNo++; } if (isInsertBottom && isInsertEmptyLineInTail) { - // 空行承り太郎 + // Insert an empty line at the end for consistent page formatting yield { _insert: "_end", lines: { diff --git a/deno.jsonc b/deno.jsonc index f539401..f456699 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -2,15 +2,33 @@ "name": "@cosense/std", "version": "0.0.0", "tasks": { - "fix": "deno fmt && deno lint --fix && deno test --allow-read --doc --parallel --shuffle && deno publish --dry-run --allow-dirty", - "check": "deno fmt --check && deno lint && deno test --allow-read --doc --parallel --shuffle && deno publish --dry-run", - "coverage": "deno test --allow-read=./ --parallel --shuffle --coverage && deno coverage --html", + "fix": { + "command": "deno fmt && deno lint --fix && deno publish --dry-run --allow-dirty", + "dependencies": [ + "type-check", + "test" + ] + }, + "check": { + "command": "deno fmt --check && deno lint && deno publish --dry-run", + "dependencies": [ + "type-check", + "test" + ] + }, + "type-check": "deno check --remote **/*.ts", + "test": "deno test --allow-read=./ --doc --parallel --shuffle --no-check", + "coverage": "deno test --allow-read=./ --parallel --shuffle --coverage --no-check && deno coverage --html", + "doc": "deno doc --html mod.ts", // from https://github.com/jsr-core/unknownutil/blob/v4.2.2/deno.jsonc#L84-L85 "update": "deno outdated --update", "update:commit": "deno task -q update --commit --prefix deps: --pre-commit=fix" }, "imports": { + "@cosense/std/rest": "./rest/mod.ts", + "@cosense/std/browser/websocket": "./browser/websocket/mod.ts", "@core/unknownutil": "jsr:@core/unknownutil@^4.0.0", + "@cosense/types": "jsr:@cosense/types@^0.10.4", "@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", @@ -21,7 +39,7 @@ "@std/testing/snapshot": "jsr:@std/testing@1/snapshot", "@takker/md5": "jsr:@takker/md5@0.1", "@takker/onp": "./vendor/raw.githubusercontent.com/takker99/onp/0.0.1/mod.ts", - "option-t": "npm:option-t@^50.0.0", + "option-t": "npm:option-t@^51.0.0", "socket.io-client": "npm:socket.io-client@^4.7.5" }, "exports": { @@ -42,9 +60,26 @@ "deno.ns" ] }, + "exclude": [ + "coverage/", + "docs/" + ], "lint": { "exclude": [ "vendor/" ] + }, + "test": { + "exclude": [ + "README.md", + "./browser/websocket/listen.ts", + "./browser/websocket/updateCodeFile.ts", + "./rest/getCachedAt.ts", + "./rest/getCodeBlocks.ts", + "./rest/getGyazoToken.ts", + "./rest/getTweetInfo.ts", + "./rest/getWebPageTitle.ts", + "./rest/link.ts" + ] } } diff --git a/deno.lock b/deno.lock index a104694..d6da85b 100644 --- a/deno.lock +++ b/deno.lock @@ -2,67 +2,48 @@ "version": "4", "specifiers": { "jsr:@core/unknownutil@4": "4.3.0", - "jsr:@cosense/types@0.10": "0.10.1", - "jsr:@progfay/scrapbox-parser@9": "9.1.5", - "jsr:@std/assert@1": "1.0.7", - "jsr:@std/assert@^1.0.7": "1.0.7", - "jsr:@std/async@1": "1.0.8", - "jsr:@std/async@^1.0.8": "1.0.8", - "jsr:@std/bytes@^1.0.2": "1.0.2", - "jsr:@std/data-structures@^1.0.4": "1.0.4", - "jsr:@std/encoding@1": "1.0.5", - "jsr:@std/fs@^1.0.5": "1.0.5", + "jsr:@cosense/types@0.10": "0.10.4", + "jsr:@progfay/scrapbox-parser@9": "9.2.0", + "jsr:@std/assert@1": "1.0.10", + "jsr:@std/assert@^1.0.10": "1.0.10", + "jsr:@std/async@1": "1.0.9", + "jsr:@std/encoding@1": "1.0.6", + "jsr:@std/fs@^1.0.8": "1.0.8", "jsr:@std/internal@^1.0.5": "1.0.5", "jsr:@std/json@1": "1.0.1", - "jsr:@std/path@^1.0.7": "1.0.8", "jsr:@std/path@^1.0.8": "1.0.8", - "jsr:@std/streams@^1.0.7": "1.0.7", - "jsr:@std/testing@1": "1.0.4", - "jsr:@takker/gyazo@*": "0.3.0", + "jsr:@std/streams@^1.0.7": "1.0.8", + "jsr:@std/testing@1": "1.0.8", "jsr:@takker/md5@0.1": "0.1.0", - "npm:option-t@*": "50.0.0", - "npm:option-t@50": "50.0.0", - "npm:option-t@^49.1.0": "49.3.0", + "npm:option-t@51": "51.0.1", "npm:socket.io-client@^4.7.5": "4.8.1" }, "jsr": { "@core/unknownutil@4.3.0": { "integrity": "538a3687ffa81028e91d148818047df219663d0da671d906cecd165581ae55cc" }, - "@cosense/types@0.10.1": { - "integrity": "13d2488a02c7b0b035a265bc3299affbdab1ea5b607516379685965cd37b2058" + "@cosense/types@0.10.4": { + "integrity": "04423c152a525df848c067f9c6aa05409baadf9da15d8e4569e1bcedfa3c7624" }, - "@progfay/scrapbox-parser@9.1.5": { - "integrity": "729a086b6675dd4a216875757c918c6bbea329d6e35e410516a16bbd6c468369" + "@progfay/scrapbox-parser@9.2.0": { + "integrity": "82ebb95e72dd0ea44547fd48e2bcb479fd2275fc26c4515598f742e5aaf6e0e5" }, - "@std/assert@1.0.7": { - "integrity": "64ce9fac879e0b9f3042a89b3c3f8ccfc9c984391af19e2087513a79d73e28c3", + "@std/assert@1.0.10": { + "integrity": "59b5cbac5bd55459a19045d95cc7c2ff787b4f8527c0dd195078ff6f9481fbb3", "dependencies": [ "jsr:@std/internal" ] }, - "@std/async@1.0.6": { - "integrity": "6d262156dd35c4a72ee1a2f8679be40264f370cfb92e2e13d4eca2ae05e16f34" + "@std/async@1.0.9": { + "integrity": "c6472fd0623b3f3daae023cdf7ca5535e1b721dfbf376562c0c12b3fb4867f91" }, - "@std/async@1.0.7": { - "integrity": "f4fadc0124432e37cba11e8b3880164661a664de00a65118d976848f32f96290" + "@std/encoding@1.0.6": { + "integrity": "ca87122c196e8831737d9547acf001766618e78cd8c33920776c7f5885546069" }, - "@std/async@1.0.8": { - "integrity": "c057c5211a0f1d12e7dcd111ab430091301b8d64b4250052a79d277383bc3ba7" - }, - "@std/bytes@1.0.2": { - "integrity": "fbdee322bbd8c599a6af186a1603b3355e59a5fb1baa139f8f4c3c9a1b3e3d57" - }, - "@std/data-structures@1.0.4": { - "integrity": "fa0e20c11eb9ba673417450915c750a0001405a784e2a4e0c3725031681684a0" - }, - "@std/encoding@1.0.5": { - "integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04" - }, - "@std/fs@1.0.5": { - "integrity": "41806ad6823d0b5f275f9849a2640d87e4ef67c51ee1b8fb02426f55e02fd44e", + "@std/fs@1.0.8": { + "integrity": "161c721b6f9400b8100a851b6f4061431c538b204bb76c501d02c508995cffe0", "dependencies": [ - "jsr:@std/path@^1.0.7" + "jsr:@std/path" ] }, "@std/internal@1.0.5": { @@ -77,27 +58,16 @@ "@std/path@1.0.8": { "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" }, - "@std/streams@1.0.7": { - "integrity": "1a93917ca0c58c01b2bfb93647189229b1702677f169b6fb61ad6241cd2e499b", - "dependencies": [ - "jsr:@std/bytes" - ] + "@std/streams@1.0.8": { + "integrity": "b41332d93d2cf6a82fe4ac2153b930adf1a859392931e2a19d9fabfb6f154fb3" }, - "@std/testing@1.0.4": { - "integrity": "ca1368d720b183f572d40c469bb9faf09643ddd77b54f8b44d36ae6b94940576", + "@std/testing@1.0.8": { + "integrity": "ceef535808fb7568e91b0f8263599bd29b1c5603ffb0377227f00a8ca9fe42a2", "dependencies": [ - "jsr:@std/assert@^1.0.7", - "jsr:@std/async@^1.0.8", - "jsr:@std/data-structures", + "jsr:@std/assert@^1.0.10", "jsr:@std/fs", "jsr:@std/internal", - "jsr:@std/path@^1.0.8" - ] - }, - "@takker/gyazo@0.3.0": { - "integrity": "fb8d602e3d76ac95bc0dc648480ef5165e5e964ecf17a9daea8bda4c0aa0028a", - "dependencies": [ - "npm:option-t@^49.1.0" + "jsr:@std/path" ] }, "@takker/md5@0.1.0": { @@ -130,11 +100,8 @@ "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, - "option-t@49.3.0": { - "integrity": "sha512-MQFSbqNnjEzQahREx7r1tESmK2UctFK+zmwmnHpBHROJvoRGM9tDMWi53B6ePyFJyAiggRRV9cuXedkpLBeC8w==" - }, - "option-t@50.0.0": { - "integrity": "sha512-zHw9Et+SfAx3Xtl9LagyAjyyzC3pNONEinTAOmZN2IKL0dYa6dthjzwuSRueJ2gLkaTiinQjRDGo/1mKSl70hg==" + "option-t@51.0.1": { + "integrity": "sha512-/M5KCGp6SQS4luBoWE1r3G3BZbRPuPfedHpVwJa7UGj98nNqQ2HBuy0E4fGFa4GKzTP+hnxFIs+HKtAq6DNA+w==" }, "socket.io-client@4.8.1": { "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", @@ -163,6 +130,7 @@ "dependencies": [ "jsr:@core/unknownutil@4", "jsr:@cosense/types@0.10", + "jsr:@cosense/types@~0.10.4", "jsr:@progfay/scrapbox-parser@9", "jsr:@std/assert@1", "jsr:@std/async@1", @@ -170,7 +138,7 @@ "jsr:@std/json@1", "jsr:@std/testing@1", "jsr:@takker/md5@0.1", - "npm:option-t@50", + "npm:option-t@51", "npm:socket.io-client@^4.7.5" ] } diff --git a/parseAbsoluteLink.ts b/parseAbsoluteLink.ts index de83d44..d141ea1 100644 --- a/parseAbsoluteLink.ts +++ b/parseAbsoluteLink.ts @@ -18,7 +18,11 @@ export interface AbsoluteLinkNode { raw: string; } -/** Youtube埋め込み */ +/** YouTube Embed Node + * Represents a YouTube video embed with detailed information about the video + * and its URL parameters. Supports various YouTube URL formats including + * youtube.com, youtu.be, and YouTube Shorts. + */ export interface YoutubeNode { type: "youtube"; videoId: string; @@ -28,7 +32,11 @@ export interface YoutubeNode { raw: string; } -/** Youtube List埋め込み */ +/** YouTube Playlist Embed Node + * Represents a YouTube playlist embed. This type is specifically for + * playlist URLs that contain a list parameter, allowing for embedding + * entire playlists rather than single videos. + */ export interface YoutubeListNode { type: "youtube"; listId: string; @@ -38,7 +46,10 @@ export interface YoutubeListNode { raw: string; } -/** Vimeo埋め込み */ +/** Vimeo Embed Node + * Represents a Vimeo video embed. Extracts and stores the video ID + * from Vimeo URLs for proper embedding in Scrapbox pages. + */ export interface VimeoNode { type: "vimeo"; videoId: string; @@ -46,7 +57,11 @@ export interface VimeoNode { raw: string; } -/** Spotify埋め込み */ +/** Spotify Embed Node + * Represents various types of Spotify content embeds including tracks, + * artists, playlists, albums, episodes, and shows. Supports all major + * Spotify content types for rich media integration. + */ export interface SpotifyNode { type: "spotify"; videoId: string; @@ -55,7 +70,11 @@ export interface SpotifyNode { raw: string; } -/** Anchor FM埋め込み */ +/** Anchor FM Embed Node + * Represents an Anchor FM podcast episode embed. Extracts the episode ID + * from Anchor FM URLs to enable podcast episode playback directly within + * Scrapbox pages. + */ export interface AnchorFMNode { type: "anchor-fm"; videoId: string; @@ -63,14 +82,22 @@ export interface AnchorFMNode { raw: string; } -/** 動画埋め込み */ +/** Generic Video Embed Node + * Represents a direct video file embed (mp4 or webm formats). + * Used for embedding video files that aren't from specific platforms + * like YouTube or Vimeo. + */ export interface VideoNode { type: "video"; href: VideoURL; raw: string; } -/** 音声埋め込み */ +/** Generic Audio Embed Node + * Represents a direct audio file embed supporting common formats + * (mp3, ogg, wav, aac). Used for embedding audio content that + * isn't from specific platforms like Spotify. + */ export interface AudioNode { type: "audio"; content: string; @@ -78,10 +105,28 @@ export interface AudioNode { raw: string; } -/** scrapbox-parserで解析した外部リンク記法を、埋め込み形式ごとに細かく解析する +/** Parse external link syntax from scrapbox-parser into specific embed types + * + * This function analyzes external links that were initially parsed by + * scrapbox-parser and categorizes them into specific embed types based on + * their URLs. It supports various media platforms and file types: + * + * - YouTube videos and playlists + * - Vimeo videos + * - Spotify content (tracks, artists, playlists, etc.) + * - Anchor FM podcast episodes + * - Direct video files (mp4, webm) + * - Direct audio files (mp3, ogg, wav, aac) + * - Regular absolute links (fallback) + * + * The function automatically detects the appropriate embed type and returns + * a strongly-typed object containing all necessary information for rendering + * the embed in Scrapbox. * - * @param link scrapbox-parserで解析した外部リンク記法のobject - * @return 解析した記法のobject + * @param link - Link node object from scrapbox-parser with absolute path type + * @returns A {@linkcode ParsedLink} containing: + * - Success: Link object with specific embed type and metadata + * - Error: {@linkcode null} if parsing fails */ export const parseAbsoluteLink = ( link: LinkNode & { pathType: "absolute" }, diff --git a/parser/anchor-fm.ts b/parser/anchor-fm.ts index 17ab2bf..3599056 100644 --- a/parser/anchor-fm.ts +++ b/parser/anchor-fm.ts @@ -1,10 +1,18 @@ const AnchorFMRegExp = /https?:\/\/anchor\.fm\/[a-zA-Z\d_-]+\/episodes\/([a-zA-Z\d_-]+(?:\/[a-zA-Z\d_-]+)?)(?:\?[^\s]{0,100}|)/; -/** anchorFMのURLからIDを取得する +/** Extract the episode ID from an Anchor FM URL * - * @param url - * @return ID anchorFMのURLでなければ`undefined`を返す + * This function parses Anchor FM podcast episode URLs and extracts their unique + * episode identifiers. It supports various Anchor FM URL formats including: + * - https://anchor.fm/[show]/episodes/[episode-id] + * - https://anchor.fm/[show]/episodes/[episode-id]/[additional-path] + * - https://anchor.fm/[show]/episodes/[episode-id]?[query-params] + * + * @param url - The URL to parse, can be any string including non-Anchor FM URLs + * @returns A {@linkcode Result}<{@linkcode string}, {@linkcode undefined}> containing: + * - Success: The episode ID (e.g., "abc123" from "https://anchor.fm/show/episodes/abc123") + * - Error: {@linkcode undefined} if not a valid Anchor FM URL */ export const parseAnchorFM = (url: string): string | undefined => { const matches = url.match(AnchorFMRegExp); diff --git a/parser/spotify.test.ts b/parser/spotify.test.ts index 9a9b34e..5cf4095 100644 --- a/parser/spotify.test.ts +++ b/parser/spotify.test.ts @@ -1,7 +1,18 @@ import { parseSpotify } from "./spotify.ts"; import { assertSnapshot } from "@std/testing/snapshot"; +/** Tests for the parseSpotify function which extracts IDs from Spotify URLs + * These tests verify that the function correctly handles various Spotify URL formats + * and returns undefined for non-Spotify URLs + */ Deno.test("spotify links", async (t) => { + /** Test valid Spotify URLs for different content types + * - Track URLs: /track/{id} + * - Album URLs: /album/{id} + * - Episode URLs: /episode/{id} (podcasts) + * - Playlist URLs: /playlist/{id} + * Each URL may optionally include query parameters + */ await t.step("is", async (t) => { await assertSnapshot( t, @@ -25,6 +36,13 @@ Deno.test("spotify links", async (t) => { ); }); + /** Test invalid URLs and non-Spotify content + * Verifies that the function returns undefined for: + * - URLs from other services (e.g., Gyazo) + * - Plain text that looks like URLs + * - URLs with similar patterns but from different domains + * - Generic URLs + */ await t.step("is not", async (t) => { await assertSnapshot( t, diff --git a/parser/spotify.ts b/parser/spotify.ts index 3e8009d..2606d28 100644 --- a/parser/spotify.ts +++ b/parser/spotify.ts @@ -1,14 +1,40 @@ const spotifyRegExp = /https?:\/\/open\.spotify\.com\/(track|artist|playlist|album|episode|show)\/([a-zA-Z\d_-]+)(?:\?[^\s]{0,100}|)/; +/** Properties extracted from a Spotify URL + * @property videoId - The unique identifier for the Spotify content (track, artist, playlist, etc.) + * @property pathType - The type of content, which determines how the ID should be used: + * - "track": A single song or audio track + * - "artist": An artist's profile page + * - "playlist": A user-created collection of tracks + * - "album": A collection of tracks released as a single unit + * - "episode": A single podcast episode + * - "show": A podcast series + */ export interface SpotifyProps { videoId: string; pathType: "track" | "artist" | "playlist" | "album" | "episode" | "show"; } -/** SpotifyのURLを解析してaudio IDなどを取り出す +/** Parse a Spotify URL to extract content ID and type + * + * This function analyzes URLs from open.spotify.com and extracts both the content ID + * and the type of content. It supports various Spotify content types including: + * - Tracks (songs) + * - Artist profiles + * - Playlists + * - Albums + * - Podcast episodes + * - Podcast shows + * + * The function handles URLs in the format: + * https://open.spotify.com/{type}/{id}[?query_params] * - * @param url SpotifyのURL - * @return 解析結果 SpotifyのURLでなかったときは`undefined`を返す + * @param url - The URL to parse, can be any string including non-Spotify URLs + * @returns A {@linkcode Result}<{@linkcode SpotifyProps}, {@linkcode undefined}> containing: + * - Success: The content information with: + * - videoId: The unique content identifier + * - pathType: Content type ("track", "artist", "playlist", "album", "episode", or "show") + * - Error: {@linkcode undefined} if not a valid Spotify URL */ export const parseSpotify = (url: string): SpotifyProps | undefined => { const matches = url.match(spotifyRegExp); diff --git a/parser/vimeo.ts b/parser/vimeo.ts index aaa19e4..6446bea 100644 --- a/parser/vimeo.ts +++ b/parser/vimeo.ts @@ -1,9 +1,20 @@ const vimeoRegExp = /https?:\/\/vimeo\.com\/([0-9]+)/i; -/** vimeoのURLからvideo IDを取得する +/** Extract the video ID from a Vimeo URL * - * @param url - * @return video ID vimeoのURLでなければ`undefined`を返す + * This function parses Vimeo video URLs to extract their numeric video IDs. + * Vimeo uses a simple URL structure where each video has a unique numeric ID: + * https://vimeo.com/{video_id} + * + * For example: + * - https://vimeo.com/123456789 -> returns "123456789" + * - https://vimeo.com/groups/123 -> returns undefined (not a video URL) + * - https://vimeo.com/channels/123 -> returns undefined (not a video URL) + * + * @param url - The URL to parse, can be any string including non-Vimeo URLs + * @returns A {@linkcode Result}<{@linkcode string}, {@linkcode undefined}> containing: + * - Success: The numeric video ID if the URL matches the {@linkcode Vimeo} video pattern + * - Error: {@linkcode undefined} if not a valid Vimeo video URL */ export const parseVimeo = (url: string): string | undefined => { const matches = url.match(vimeoRegExp); diff --git a/parser/youtube.test.ts b/parser/youtube.test.ts index 184310a..6dab51c 100644 --- a/parser/youtube.test.ts +++ b/parser/youtube.test.ts @@ -1,7 +1,19 @@ import { parseYoutube } from "./youtube.ts"; import { assertSnapshot } from "@std/testing/snapshot"; +/** Test suite for YouTube URL parsing functionality + * This test suite verifies the parseYoutube function's ability to handle various + * YouTube URL formats and invalid inputs using snapshot testing. + */ Deno.test("youtube links", async (t) => { + /** Test valid YouTube URL formats + * Verifies parsing of: + * - Standard watch URLs (youtube.com/watch?v=...) + * - Playlist URLs (youtube.com/playlist?list=...) + * - Watch URLs within playlists + * - YouTube Music URLs (music.youtube.com) + * - Short URLs (youtu.be/...) + */ await t.step("is", async (t) => { await assertSnapshot( t, @@ -33,6 +45,13 @@ Deno.test("youtube links", async (t) => { ); }); + /** Test invalid URL formats + * Verifies that the function correctly returns undefined for: + * - URLs from other services (e.g., Gyazo) + * - Non-URL strings (including Japanese text) + * - Similar but invalid domains (e.g., "yourtube.com") + * - Generic URLs + */ await t.step("is not", async (t) => { await assertSnapshot( t, @@ -43,7 +62,7 @@ Deno.test("youtube links", async (t) => { await assertSnapshot( t, parseYoutube( - "ほげほげ", + "test_text", ), ); await assertSnapshot( diff --git a/parser/youtube.ts b/parser/youtube.ts index f17cb96..7b5d52d 100644 --- a/parser/youtube.ts +++ b/parser/youtube.ts @@ -1,13 +1,35 @@ // ported from https://github.com/takker99/ScrapBubble/blob/0.4.0/Page.tsx#L662 +/** Regular expressions for matching different YouTube URL formats */ +// Matches standard youtube.com/watch URLs (including music.youtube.com) const youtubeRegExp = /https?:\/\/(?:www\.|music\.|)youtube\.com\/watch/; +// Matches short youtu.be URLs with optional query parameters const youtubeDotBeRegExp = /https?:\/\/youtu\.be\/([a-zA-Z\d_-]+)(?:\?([^\s]{0,100})|)/; +// Matches YouTube Shorts URLs const youtubeShortRegExp = /https?:\/\/(?:www\.|)youtube\.com\/shorts\/([a-zA-Z\d_-]+)(?:\?([^\s]+)|)/; +// Matches playlist URLs (including music.youtube.com playlists) const youtubeListRegExp = /https?:\/\/(?:www\.|music\.|)youtube\.com\/playlist\?((?:[^\s]+&|)list=([a-zA-Z\d_-]+)(?:&[^\s]+|))/; +/** Properties extracted from a YouTube URL + * This type represents the parsed data from different types of YouTube URLs. + * It's a union type that handles both video-related URLs and playlist URLs. + * + * For video URLs (standard, short URLs, or youtu.be links): + * @property params - URL query parameters (e.g., timestamp, playlist reference) + * @property videoId - The unique identifier of the video + * @property pathType - The URL format type: + * - "com": Standard youtube.com/watch?v= format + * - "dotbe": Short youtu.be/ format + * - "short": YouTube Shorts format + * + * For playlist URLs: + * @property params - URL query parameters + * @property listId - The unique identifier of the playlist + * @property pathType - Always "list" for playlist URLs + */ export type YoutubeProps = { params: URLSearchParams; videoId: string; @@ -18,10 +40,33 @@ export type YoutubeProps = { pathType: "list"; }; -/** YoutubeのURLを解析してVideo IDなどを取り出す +/** Parse a YouTube URL to extract video/playlist ID and other properties + * + * This function handles various YouTube URL formats: + * 1. Standard video URLs: + * - https://www.youtube.com/watch?v={videoId} + * - https://music.youtube.com/watch?v={videoId} + * + * 2. Short URLs: + * - https://youtu.be/{videoId} + * - Can include optional query parameters + * + * 3. YouTube Shorts: + * - https://youtube.com/shorts/{videoId} + * - https://www.youtube.com/shorts/{videoId} + * + * 4. Playlist URLs: + * - https://youtube.com/playlist?list={listId} + * - https://music.youtube.com/playlist?list={listId} + * + * The function preserves all query parameters from the original URL. * - * @param url YoutubeのURL - * @return 解析結果 YoutubeのURLでなかったときは`undefined`を返す + * @param url - Any URL or string to parse + * @returns A {@linkcode Result}<{@linkcode YoutubeProps}, {@linkcode undefined}> containing: + * - Success: The extracted video/playlist information with: + * - For videos: videoId, params, and pathType ("com", "dotbe", or "short") + * - For playlists: listId, params, and pathType ("list") + * - Error: {@linkcode undefined} if not a valid YouTube URL */ export const parseYoutube = (url: string): YoutubeProps | undefined => { if (youtubeRegExp.test(url)) { diff --git a/rest/auth.ts b/rest/auth.ts index 65df74c..bf9ac04 100644 --- a/rest/auth.ts +++ b/rest/auth.ts @@ -4,15 +4,35 @@ import type { HTTPError } from "./responseIntoResult.ts"; import type { AbortError, NetworkError } from "./robustFetch.ts"; import type { ExtendedOptions } from "./options.ts"; -/** HTTP headerのCookieに入れる文字列を作る +/** Create a cookie string for HTTP headers * - * @param sid connect.sidに入っている文字列 + * This function creates a properly formatted cookie string for the `connect.sid` + * session identifier, which is used for authentication in Scrapbox. + * + * @param sid - The session ID string stored in `connect.sid` + * @returns A formatted {@linkcode string} in the format `"connect.sid={@linkcode sid}"` */ export const cookie = (sid: string): string => `connect.sid=${sid}`; -/** CSRF tokenを取得する +/** Retrieve the CSRF token for secure requests + * + * CSRF (Cross-Site Request Forgery) tokens are security measures that protect + * against unauthorized requests. This function retrieves the token either from: + * 1. `init.csrf` + * 2. `globalThis._csrf` + * 3. The user profile (if neither of the above is available) * - * @param init 認証情報など + * @param init - Optional {@linkcode ExtendedOptions} configuration including authentication details + * and CSRF token. If not provided, the function will attempt + * to get the token from other sources. + * @returns A {@linkcode Result}<{@linkcode string}, {@linkcode NetworkError} | {@linkcode AbortError} | {@linkcode HTTPError}> containing: + * - Success: The CSRF token as a {@linkcode string} + * - Error: A {@linkcode NetworkError}, {@linkcode AbortError}, or {@linkcode HTTPError} describing what went wrong + * - Success: The CSRF token string + * - Error: One of several possible errors: + * - {@linkcode NetworkError}: Network connectivity issues + * - {@linkcode AbortError}: Request was aborted + * - {@linkcode HTTPError}: Server response error */ export const getCSRFToken = async ( init?: ExtendedOptions, diff --git a/rest/getCachedAt.ts b/rest/getCachedAt.ts index 0dd5dbf..3fecc32 100644 --- a/rest/getCachedAt.ts +++ b/rest/getCachedAt.ts @@ -1,9 +1,28 @@ -/** ServiceWorkerにcacheされた日時を得る +/** Get the timestamp when a response was cached by the ServiceWorker * - * cacheされたResponseでなければ`undefined`を返す + * This function retrieves the timestamp when a Response was cached by the + * ServiceWorker, using a custom header `x-serviceworker-cached`. ServiceWorkers + * are web workers that act as proxy servers between web apps, the browser, + * and the network, enabling offline functionality and faster page loads. * - * @param res Response to check the chached date - * @return cached date (as UNIX timestamp) or `undefined` + * @param res - The Response object to check for cache information + * @returns + * - A number representing the UNIX timestamp (milliseconds since epoch) when + * the response was cached by the ServiceWorker + * - `undefined` if: + * 1. The response wasn't cached (no `x-serviceworker-cached` header) + * 2. The header value couldn't be parsed as a number + * + * @example + * ```typescript + * const response = await fetch('/api/data'); + * const cachedAt = getCachedAt(response); + * if (cachedAt) { + * console.log(`Data was cached at: ${new Date(cachedAt)}`); + * } else { + * console.log('This is a fresh response from the server'); + * } + * ``` */ export const getCachedAt = (res: Response): number | undefined => { const cachedAt = res.headers.get("x-serviceworker-cached"); diff --git a/rest/getCodeBlock.ts b/rest/getCodeBlock.ts index 42a25f4..fe9fc70 100644 --- a/rest/getCodeBlock.ts +++ b/rest/getCodeBlock.ts @@ -50,13 +50,13 @@ const getCodeBlock_fromResponse: GetCodeBlock["fromResponse"] = async (res) => ); export interface GetCodeBlock { - /** /api/code/:project/:title/:filename の要求を組み立てる + /** Build a request for `/api/code/:project/:title/:filename` * - * @param project 取得したいページのproject名 - * @param title 取得したいページのtitle 大文字小文字は問わない - * @param filename 取得したいコードブロックのファイル名 - * @param options オプション - * @return request + * @param project - Name of the project containing the target page + * @param title - Title of the target page (case-insensitive) + * @param filename - Name of the code block file to retrieve + * @param options - Configuration options + * @returns A {@linkcode Request} object for fetching code block content */ toRequest: ( project: string, @@ -65,10 +65,16 @@ export interface GetCodeBlock { options?: BaseOptions, ) => Request; - /** 帰ってきた応答からコードを取得する + /** Extract code from the response * - * @param res 応答 - * @return コード + * @param res - Response from the API + * @returns A {@linkcode Result}<{@linkcode string}, {@linkcode Error}> containing: + * - Success: The code block content as a string + * - Error: One of several possible errors: + * - {@linkcode NotFoundError}: Code block not found + * - {@linkcode NotLoggedInError}: Authentication required + * - {@linkcode NotMemberError}: User lacks access + * - {@linkcode HTTPError}: Other HTTP errors */ fromResponse: (res: Response) => Promise>; @@ -85,12 +91,12 @@ export type CodeBlockError = | NotMemberError | HTTPError; -/** 指定したコードブロック中のテキストを取得する +/** Retrieve text content from a specified code block * - * @param project 取得したいページのproject名 - * @param title 取得したいページのtitle 大文字小文字は問わない - * @param filename 取得したいコードブロックのファイル名 - * @param options オプション + * @param project Name of the project containing the target page + * @param title Title of the target page (case-insensitive) + * @param filename Name of the code block file to retrieve + * @param options Configuration options */ export const getCodeBlock: GetCodeBlock = /* @__PURE__ */ (() => { const fn: GetCodeBlock = async ( diff --git a/rest/getCodeBlocks.test.ts b/rest/getCodeBlocks.test.ts index f931e2a..1249b40 100644 --- a/rest/getCodeBlocks.test.ts +++ b/rest/getCodeBlocks.test.ts @@ -3,10 +3,17 @@ import { assertEquals } from "@std/assert"; import { assertSnapshot } from "@std/testing/snapshot"; import { getCodeBlocks } from "./getCodeBlocks.ts"; -// https://scrapbox.io/takker/コードブロック記法 +// Reference: https://scrapbox.io/takker/コードブロック記法 +// This test uses a sample page that demonstrates various code block syntax patterns +// in Scrapbox. The page contains examples of: +// - Named code blocks with file extensions +// - Anonymous code blocks with language hints +// - Indented code blocks +// - Code blocks with forced language highlighting +// - Literate programming style code blocks const project = "takker"; -const title = "コードブロック記法"; -const sample: BaseLine[] = [ +const title = "コードブロック記法"; // "Code Block Syntax" +const sample: BaseLine[] = [ // Sample page content demonstrating various code block formats { "id": "63b7aeeb5defe7001ddae116", "text": "コードブロック記法", @@ -228,10 +235,15 @@ const sample: BaseLine[] = [ ]; Deno.test("getCodeBlocks()", async (t) => { + // Test the basic functionality of getCodeBlocks + // This verifies that all code blocks are correctly extracted from the page await assertSnapshot( t, getCodeBlocks({ project, title, lines: sample }), ); + + // Test filtering code blocks by filename + // This ensures that we can retrieve specific code blocks by their filename await t.step("filename filter", async (st) => { const filename = "インデント.md"; const codeBlocks = getCodeBlocks({ project, title, lines: sample }, { @@ -244,6 +256,8 @@ Deno.test("getCodeBlocks()", async (t) => { await Promise.all(yet); await assertSnapshot(st, codeBlocks); }); + // Test filtering code blocks by programming language + // This verifies that we can find all code blocks of a specific language await t.step("language name filter", async (st) => { const lang = "py"; const codeBlocks = getCodeBlocks({ project, title, lines: sample }, { @@ -256,6 +270,8 @@ Deno.test("getCodeBlocks()", async (t) => { await Promise.all(yet); await assertSnapshot(st, codeBlocks); }); + // Test filtering code blocks by their title line ID + // This ensures we can find code blocks starting at a specific line in the page await t.step("title line ID filter", async (st) => { const titleLineId = "63b7b1261280f00000c9bc34"; const codeBlocks = getCodeBlocks({ project, title, lines: sample }, { diff --git a/rest/getCodeBlocks.ts b/rest/getCodeBlocks.ts index 06fd603..55ccd78 100644 --- a/rest/getCodeBlocks.ts +++ b/rest/getCodeBlocks.ts @@ -4,45 +4,101 @@ import { extractFromCodeTitle, } from "../browser/websocket/_codeBlock.ts"; -/** pull()から取れる情報で構成したコードブロックの最低限の情報 */ +/** Minimal information about a code block that can be extracted from pull() response + * + * This interface represents the essential structure of a code block in Scrapbox, + * containing only the information that can be reliably extracted from the page content. + */ export interface TinyCodeBlock { - /** ファイル名 */ + /** The filename specified in the code block title. + * For named code blocks, this is the actual filename (e.g., "example.js"). + * For anonymous code blocks, this is derived from the language hint (e.g., "py" becomes "code.py"). + */ filename: string; - /** コードブロック内の強調表示に使う言語名 */ + /** The programming language used for syntax highlighting. + * This is either explicitly specified in the code block title or + * inferred from the filename extension. + */ lang: string; - /** タイトル行 */ + /** The title line of the code block. + * This is the line containing the "code:" directive. + */ titleLine: BaseLine; - /** コードブロックの中身(タイトル行を含まない) */ + /** The content lines of the code block. + * These are all the indented lines following the title line, + * excluding the title line itself. + */ bodyLines: BaseLine[]; - /** コードブロックの真下の行(無ければ`null`) */ + /** The first non-code-block line after this code block. + * This is the first line that either: + * - Has less indentation than the code block + * - Is empty + * - Is null if this is the last line in the page + */ nextLine: BaseLine | null; - /** コードブロックが存在するページの情報 */ + /** Information about the page containing this code block */ pageInfo: { projectName: string; pageTitle: string }; } -/** `getCodeBlocks()`に渡すfilter */ +/** Filter options for getCodeBlocks() + * + * This interface allows you to filter code blocks by various criteria. + * All filters are optional and can be combined. When multiple filters + * are specified, they are combined with AND logic (all must match). + */ export interface GetCodeBlocksFilter { - /** ファイル名 */ + /** Filter by filename + * Only returns code blocks with exactly matching filename + */ filename?: string; - /** syntax highlightに使用されている言語名 */ + + /** Filter by programming language + * Only returns code blocks using this language for syntax highlighting + */ lang?: string; - /** タイトル行の行ID */ + + /** Filter by the ID of the title line + * Useful for finding a specific code block when you know its location + */ titleLineId?: string; } -/** 他のページ(または取得済みの行データ)のコードブロックを全て取得する +/** Extract all code blocks from a Scrapbox page + * + * This function processes the page content and identifies all code blocks, + * returning them as separate entities even if they share the same filename. + * Each code block is treated independently, allowing for multiple code blocks + * with the same name to exist in the same page. + * + * @example + * ```typescript + * import type { BaseLine } from "@cosense/types/rest"; + * import { getPage } from "@cosense/std/rest"; + * import { isErr, unwrapErr, unwrapOk } from "option-t/plain_result"; * - * ファイル単位ではなく、コードブロック単位で返り値を生成する \ - * そのため、同じページ内に同名のコードブロックが複数あったとしても、分けた状態で返す + * const result = await getPage("my-project", "My Page"); + * if(isErr(result)) { + * throw new Error(`Failed to get page: ${unwrapErr(result)}`); + * } + * const page = unwrapOk(result); * - * @param target 取得するページの情報(linesを渡せば内部のページ取得処理を省略する) - * @param filter 取得するコードブロックを絞り込むfilter - * @return コードブロックの配列 + * const codeBlocks = getCodeBlocks({ + * project: "my-project", + * title: page.title, + * lines: page.lines, + * }, { + * lang: "typescript" // optional: filter by language + * }); + * ``` + * + * @param target Information about the page to process, including its content lines + * @param filter Optional criteria to filter the returned code blocks + * @returns Array of {@linkcode CodeBlock} objects matching the filter criteria */ export const getCodeBlocks = ( target: { project: string; title: string; lines: BaseLine[] }, @@ -51,7 +107,7 @@ export const getCodeBlocks = ( const codeBlocks: TinyCodeBlock[] = []; let currentCode: CodeTitle & { - /** 読み取り中の行がコードブロックかどうか */ + /** Whether the current line is part of a code block */ isCodeBlock: boolean; } = { isCodeBlock: false, @@ -91,7 +147,7 @@ export const getCodeBlocks = ( return codeBlocks.filter((codeBlock) => isMatchFilter(codeBlock, filter)); }; -/** コードブロックのフィルターに合致しているか検証する */ +/** Verify if a code block matches the specified filter criteria */ const isMatchFilter = ( codeBlock: TinyCodeBlock, filter?: GetCodeBlocksFilter, @@ -102,11 +158,16 @@ const isMatchFilter = ( const equals = (a: unknown, b: unknown): boolean => !a || a === b; -/** 行テキストがコードブロックの一部であればそのテキストを、そうでなければnullを返す +/** Process a line of text to determine if it's part of a code block + * + * This function checks if a given line belongs to the current code block + * by comparing its indentation level with the code block's title indentation. + * A line is considered part of the code block if it has more indentation + * than the title line. * - * @param lineText {string} 行テキスト - * @param titleIndent {number} コードブロックのタイトル行のインデントの深さ - * @return `lineText`がコードブロックの一部であればそのテキストを、そうでなければ`null`を返す + * @param lineText The text content of the line to process + * @param titleIndent The indentation level (number of spaces) of the code block's title line + * @returns The processed {@linkcode string} if it's part of the code block, `null` otherwise */ const extractFromCodeBody = ( lineText: string, diff --git a/rest/getGyazoToken.ts b/rest/getGyazoToken.ts index b9bbac2..ca094ef 100644 --- a/rest/getGyazoToken.ts +++ b/rest/getGyazoToken.ts @@ -13,19 +13,55 @@ import { type BaseOptions, setDefaults } from "./options.ts"; import type { FetchError } from "./mod.ts"; export interface GetGyazoTokenOptions extends BaseOptions { - /** Gyazo Teamsのチーム名 + /** The team name for Gyazo Teams * - * Gyazo Teamsでuploadしたいときに使う + * Specify this parameter when you want to upload images to a Gyazo Teams workspace. + * If not provided, the image will be uploaded to your personal Gyazo account. + * + * @example + * ```typescript + * import { isErr, unwrapErr, unwrapOk } from "option-t/plain_result"; + * + * const result = await getGyazoToken({ gyazoTeamsName: "my-team" }); + * if (isErr(result)) { + * throw new Error(`Failed to get Gyazo token: ${unwrapErr(result)}`); + * } + * const token = unwrapOk(result); + * ``` */ gyazoTeamsName?: string; } export type GyazoTokenError = NotLoggedInError | HTTPError; -/** Gyazo OAuth uploadで使うaccess tokenを取得する +/** Retrieve an OAuth access token for uploading images to Gyazo + * + * This function obtains an OAuth access token that can be used to upload images + * to Gyazo or Gyazo Teams. The token is obtained through Scrapbox's API, which + * handles the OAuth flow with Gyazo. + * + * @param init - Optional configuration for the Gyazo token request, including: + * - gyazoTeamsName: Optional team name for Gyazo Teams workspace + * - sid: Optional session ID for authentication + * - hostName: Optional custom hostname (defaults to scrapbox.io) + * - fetch: Optional custom fetch implementation + * @returns A {@linkcode Result} containing: + * - Success: The access token string, or `undefined` if no token is available + * - Error: One of several possible errors: + * - {@linkcode NotLoggedInError}: User is not authenticated with Scrapbox + * - {@linkcode HTTPError}: Network or server-side error occurred + * - {@linkcode FetchError}: Network request failed + * + * @example + * ```typescript + * import { isErr, unwrapErr, unwrapOk } from "option-t/plain_result"; * - * @param init connect.sidなど - * @return access token + * const result = await getGyazoToken(); + * if (isErr(result)) { + * throw new Error(`Failed to get Gyazo token: ${unwrapErr(result)}`); + * } + * const token = unwrapOk(result); + * ``` */ export const getGyazoToken = async ( init?: GetGyazoTokenOptions, diff --git a/rest/getTweetInfo.ts b/rest/getTweetInfo.ts index 48408d9..1e2ee3e 100644 --- a/rest/getTweetInfo.ts +++ b/rest/getTweetInfo.ts @@ -23,11 +23,37 @@ export type TweetInfoError = | BadRequestError | HTTPError; -/** 指定したTweetの情報を取得する +/** Retrieve information about a specified Tweet * - * @param url 取得したいTweetのURL - * @param init connect.sidなど - * @return tweetの中身とか + * Fetches metadata and content information for a given Tweet URL through Scrapbox's + * Twitter embed API. This function handles authentication and CSRF token management + * automatically. + * + * @param url - The URL of the Tweet to fetch information for. Can be either a {@linkcode string} + * or {@linkcode URL} object. Should be a valid Twitter/X post URL. + * @param init - Optional {@linkcode RequestInit} configuration for customizing request behavior and authentication + * @returns A {@linkcode Result}<{@linkcode TweetInfo}, {@linkcode Error}> containing: + * - Success: {@linkcode TweetInfo} object with Tweet metadata + * - Error: One of several possible errors: + * - {@linkcode SessionError}: Authentication issues + * - {@linkcode InvalidURLError}: Malformed or invalid Tweet URL + * - {@linkcode BadRequestError}: API request issues + * - {@linkcode HTTPError}: Network or server errors + * + * @example + * ```typescript + * import { isErr, unwrapErr, unwrapOk } from "option-t/plain_result"; + * + * const result = await getTweetInfo("https://twitter.com/user/status/123456789"); + * if (isErr(result)) { + * throw new Error(`Failed to get Tweet info: ${unwrapErr(result)}`); + * } + * const tweetInfo = unwrapOk(result); + * console.log("Tweet text:", tweetInfo.description); + * ``` + * + * > [!NOTE] + * > The function includes a 3000ms timeout for the API request. */ export const getTweetInfo = async ( url: string | URL, diff --git a/rest/getWebPageTitle.ts b/rest/getWebPageTitle.ts index c523aff..15d832f 100644 --- a/rest/getWebPageTitle.ts +++ b/rest/getWebPageTitle.ts @@ -22,11 +22,36 @@ export type WebPageTitleError = | BadRequestError | HTTPError; -/** 指定したURLのweb pageのtitleをscrapboxのserver経由で取得する +/** Retrieve the title of a web page through Scrapbox's server * - * @param url 取得したいURL - * @param init connect.sidなど - * @return web pageのtilte + * This function fetches the title of a web page by making a request through + * Scrapbox's server. This approach helps handle various edge cases and + * authentication requirements that might be needed to access certain pages. + * + * @param url - The URL of the web page to fetch the title from. Can be either + * a {@linkcode string} or {@linkcode URL} object. + * @param init - Optional {@linkcode RequestInit} configuration for customizing the request behavior + * @returns A {@linkcode Result}<{@linkcode string}, {@linkcode Error}> containing: + * - Success: The page title as a string + * - Error: One of several possible errors: + * - {@linkcode SessionError}: Authentication issues + * - {@linkcode InvalidURLError}: Malformed or invalid URL + * - {@linkcode BadRequestError}: API request issues + * - {@linkcode HTTPError}: Network or server errors + * + * @example + * ```typescript + * import { isErr, unwrapErr, unwrapOk } from "option-t/plain_result"; + * + * const result = await getWebPageTitle("https://example.com"); + * if (isErr(result)) { + * throw new Error(`Failed to get page title: ${unwrapErr(result)}`); + * } + * console.log("Page title:", unwrapOk(result)); + * ``` + * + * > [!NOTE] + * > The function includes a 3000ms timeout for the API request. */ export const getWebPageTitle = async ( url: string | URL, diff --git a/rest/link.ts b/rest/link.ts index 49cbbd3..7898bc4 100644 --- a/rest/link.ts +++ b/rest/link.ts @@ -18,13 +18,13 @@ import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; import { type BaseOptions, setDefaults } from "./options.ts"; import type { FetchError } from "./mod.ts"; -/** 不正なfollowingIdを渡されたときに発生するエラー */ +/** Error that occurs when an invalid followingId is provided for pagination */ export interface InvalidFollowingIdError extends ErrorLike { name: "InvalidFollowingIdError"; } export interface GetLinksOptions extends BaseOptions { - /** 次のリンクリストを示すID */ + /** ID indicating the next list of links */ followingId?: string; } @@ -41,9 +41,21 @@ export type LinksError = /** Get the links of the specified project * - * @param project The project to get the data from - * @param options Options for the request - * @return a promise that resolves to the parsed data + * This function retrieves all links from a Scrapbox project, with optional + * pagination support through the followingId parameter. + * + * @param project - The project to get the data from + * @param options - Options for the request including pagination and authentication + * @returns A {@linkcode Result}<{@linkcode GetLinksResult}, {@linkcode LinksError} | {@linkcode FetchError}> containing: + * - Success: The link data with: + * - pages: Array of {@linkcode SearchedTitle} objects + * - followingId: ID for fetching the next page of results + * - Error: One of several possible errors: + * - {@linkcode NotFoundError}: Project not found + * - {@linkcode NotLoggedInError}: Authentication required + * - {@linkcode InvalidFollowingIdError}: Invalid pagination ID + * - {@linkcode HTTPError}: Network or server errors + * - {@linkcode FetchError}: Request failed */ export interface GetLinks { ( @@ -54,15 +66,17 @@ export interface GetLinks { /** Create a request to `GET /api/pages/:project/search/titles` * * @param project The project to get the data from - * @param options Options for the request - * @return The request object + * @param options - Options for the request + * @returns A {@linkcode Request} object for fetching link data */ toRequest: (project: string, options?: GetLinksOptions) => Request; /** Parse the response from `GET /api/pages/:project/search/titles` * - * @param response The response object - * @return a promise that resolves to the parsed data + * @param response - The response object + * @returns A {@linkcode Result}<{@linkcode unknown}, {@linkcode Error}> containing: + * - Success: The parsed link data + * - Error: {@linkcode Error} if parsing fails */ fromResponse: ( response: Response, @@ -102,9 +116,39 @@ const getLinks_fromResponse: GetLinks["fromResponse"] = async (response) => })), ); -/** 指定したprojectのリンクデータを取得する +/** Retrieve link data from a specified Scrapbox project + * + * This function fetches link data from a project, supporting pagination through + * the {@linkcode GetLinksOptions.followingId} parameter. It returns both the link data and the next + * followingId for subsequent requests. + * + * @param project The project to retrieve link data from + * @param options Configuration options + * @returns A {@linkcode Result}<{@linkcode GetLinksResult}, {@linkcode Error}> containing: + * - Success: {@linkcode GetLinksResult} with pages and next followingId + * - Error: One of several possible errors: + * - {@linkcode NotFoundError}: Project not found + * - {@linkcode NotLoggedInError}: Authentication required + * - {@linkcode InvalidFollowingIdError}: Invalid pagination ID + * - {@linkcode HTTPError}: Network or server errors + * + * @example + * ```typescript + * import { isErr, unwrapErr, unwrapOk } from "option-t/plain_result"; + * + * // Get first page of links + * const result = await getLinks("project-name"); + * if (isErr(result)) { + * throw new Error(`Failed to get links: ${unwrapErr(result)}`); + * } + * const { pages, followingId } = unwrapOk(result); * - * @param project データを取得したいproject + * // Get next page if available + * if (followingId) { + * const nextResult = await getLinks("project-name", { followingId }); + * // Handle next page result... + * } + * ``` */ export const getLinks: GetLinks = /* @__PURE__ */ (() => { const fn: GetLinks = async (project, options) => { @@ -120,12 +164,28 @@ export const getLinks: GetLinks = /* @__PURE__ */ (() => { return fn; })(); -/** 指定したprojectの全てのリンクデータを取得する +/** Retrieve all link data from a specified project in bulk * - * responseで返ってきたリンクデータの塊ごとに返す + * This async generator yields arrays of link data, automatically handling + * pagination. Each yield returns a batch of links as received from the API. * - * @param project データを取得したいproject - * @return 認証が通らなかったらエラーを、通ったらasync generatorを返す + * @param project The project to retrieve link data from + * @param options Configuration options + * @returns An {@linkcode AsyncGenerator}<{@linkcode Result}<{@linkcode SearchedTitle}[], {@linkcode Error}>> that yields either: + * - Success: Array of {@linkcode SearchedTitle} objects (batch of links) + * - Error: Error if authentication fails or other issues occur + * + * @example + * ```typescript + * import { isErr, unwrapErr, unwrapOk } from "option-t/plain_result"; + * + * for await (const result of readLinksBulk("project-name")) { + * if (isErr(result)) { + * throw new Error(`Failed to get links: ${unwrapErr(result)}`); + * } + * console.log(`Got ${unwrapOk(result).length} links`); + * } + * ``` */ export async function* readLinksBulk( project: string, @@ -149,10 +209,30 @@ export async function* readLinksBulk( } while (followingId); } -/** 指定したprojectの全てのリンクデータを取得し、一つづつ返す +/** Retrieve all link data from a specified project one by one + * + * This async generator yields individual link entries, automatically handling + * pagination. Unlike {@linkcode readLinksBulk}, this yields one {@linkcode SearchedTitle} at a time, + * making it ideal for processing links individually. + * + * @param project The project to retrieve link data from + * @param options Configuration options + * @returns An {@linkcode AsyncGenerator}<{@linkcode Result}<{@linkcode SearchedTitle}, {@linkcode Error}>> that yields either: + * - Success: Individual {@linkcode SearchedTitle} object (single link) + * - Error: Error if authentication fails or other issues occur + * + * @example + * ```typescript + * import { isErr, unwrapErr, unwrapOk } from "option-t/plain_result"; * - * @param project データを取得したいproject - * @return 認証が通らなかったらエラーを、通ったらasync generatorを返す + * for await (const result of readLinks("project-name")) { + * if (isErr(result)) { + * throw new Error(`Failed to get link: ${unwrapErr(result)}`); + * } + * // Single link entry + * console.log("Processing link:", unwrapOk(result).title); + * } + * ``` */ export async function* readLinks( project: string, diff --git a/rest/options.ts b/rest/options.ts index be673f3..5fee05e 100644 --- a/rest/options.ts +++ b/rest/options.ts @@ -1,28 +1,44 @@ import { type RobustFetch, robustFetch } from "./robustFetch.ts"; -/** 全てのREST APIに共通するopitons */ +/** Common options shared across all REST API endpoints + * + * These options configure authentication, network behavior, and host settings + * for all API requests in the library. + */ export interface BaseOptions { - /** connect.sid + /** Scrapbox session ID (connect.sid) * - * private projectのデータやscrapbox accountに紐付いたデータを取得する際に必要な認証情報 + * Authentication token required to access: + * - Private project data + * - User-specific data linked to Scrapbox accounts + * - Protected API endpoints */ sid?: string; - /** データの取得に使う処理 + /** Custom fetch implementation for making HTTP requests * - * @default fetch + * Allows overriding the default fetch behavior for testing + * or custom networking requirements. + * + * @default {globalThis.fetch} */ fetch?: RobustFetch; - /** REST APIのdomain + /** Domain for REST API endpoints * - * オンプレ版scrapboxなどだと、scrapbox.io以外のhost nameになるので、予め変えられるようにしておく + * Configurable host name for API requests. This allows using the library + * with self-hosted Scrapbox instances or other custom deployments that + * don't use the default scrapbox.io domain. * - * @default "scrapbox.io" + * @default {"scrapbox.io"} */ hostName?: string; } -/** BaseeOptionsにCSRF情報を入れたもの */ +/** Extended options including CSRF token configuration + * + * Extends BaseOptions with CSRF token support for endpoints + * that require CSRF protection. + */ export interface ExtendedOptions extends BaseOptions { /** CSRF token * @@ -31,7 +47,14 @@ export interface ExtendedOptions extends BaseOptions { csrf?: string; } -/** BaseOptionsの既定値を埋める */ +/** Set default values for {@linkcode BaseOptions} + * + * Ensures all required fields have appropriate default values while + * preserving any user-provided options. + * + * @param options - User-provided {@linkcode Options} to merge with defaults + * @returns {@linkcode Options} object with all required fields populated + */ export const setDefaults = ( options: T, ): Omit & Required> => { diff --git a/rest/page-data.ts b/rest/page-data.ts index 13d43e2..353dc85 100644 --- a/rest/page-data.ts +++ b/rest/page-data.ts @@ -25,10 +25,17 @@ import type { FetchError } from "./mod.ts"; export type ImportPagesError = HTTPError; -/** projectにページをインポートする +/** Import pages into a Scrapbox project * - * @param project - インポート先のprojectの名前 - * @param data - インポートするページデータ + * Imports multiple pages into a specified project. The pages are provided as a structured + * data object that follows the {@linkcode ImportedData} format. + * + * @param project - Name of the target project to import pages into + * @param data - Page data to import, following the {@linkcode ImportedData} format + * @param init - Optional {@linkcode ImportOptions} configuration for the import operation + * @returns A {@linkcode Result}<{@linkcode string}, {@linkcode Error}> containing: + * - Success: A success message + * - Error: An error message */ export const importPages = async ( project: string, @@ -80,14 +87,25 @@ export type ExportPagesError = | NotLoggedInError | HTTPError; -/** `exportPages`の認証情報 */ +/** Configuration options for the {@linkcode exportPages} function + * + * Extends {@linkcode BaseOptions} with metadata control for page exports. + */ export interface ExportInit extends BaseOptions { - /** whether to includes metadata */ metadata: withMetadata; + /** whether to includes metadata */ + metadata: withMetadata; } -/** projectの全ページをエクスポートする +/** Export all pages from a Scrapbox project + * + * Retrieves all pages from the specified project, optionally including metadata. + * Requires appropriate authentication for private projects. * - * @param project exportしたいproject + * @param project - Name of the project to export + * @param init - {@linkcode ExportOptions} configuration including metadata preference + * @returns A {@linkcode Result}<{@linkcode ExportedData}, {@linkcode Error}> containing: + * - Success: The exported data + * - Error: An error message */ export const exportPages = async ( project: string, diff --git a/rest/pages.test.ts b/rest/pages.test.ts index bbec994..dca7c9a 100644 --- a/rest/pages.test.ts +++ b/rest/pages.test.ts @@ -1,13 +1,16 @@ import { getPage, listPages } from "./pages.ts"; import { assertSnapshot } from "@std/testing/snapshot"; -Deno.test("getPage", async (t) => { +/** Test suite for page retrieval functionality */ +Deno.test("getPage", async (t) => { // Tests page fetching with various options + // Test fetching a page with rename following enabled await assertSnapshot( t, getPage.toRequest("takker", "テストページ", { followRename: true }), ); }); -Deno.test("listPages", async (t) => { +/** Test suite for page listing functionality */ +Deno.test("listPages", async (t) => { // Tests page listing with sorting options await assertSnapshot( t, listPages.toRequest("takker", { sort: "updated" }), diff --git a/rest/pages.ts b/rest/pages.ts index bc86592..6a3c662 100644 --- a/rest/pages.ts +++ b/rest/pages.ts @@ -20,7 +20,7 @@ import { unwrapOrForMaybe } from "option-t/maybe"; import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; import type { FetchError } from "./robustFetch.ts"; -/** Options for `getPage()` */ +/** Options for {@linkcode getPage} */ export interface GetPageOption extends BaseOptions { /** use `followRename` */ followRename?: boolean; @@ -81,12 +81,12 @@ const getPage_fromResponse: GetPage["fromResponse"] = async (res) => ); export interface GetPage { - /** /api/pages/:project/:title の要求を組み立てる + /** Constructs a request for the `/api/pages/:project/:title` endpoint * - * @param project 取得したいページのproject名 - * @param title 取得したいページのtitle 大文字小文字は問わない - * @param options オプション - * @return request + * @param project The project name containing the desired page + * @param title The page title to retrieve (case insensitive) + * @param options - Additional configuration options + * @returns A {@linkcode Request} object for fetching page data */ toRequest: ( project: string, @@ -94,10 +94,16 @@ export interface GetPage { options?: GetPageOption, ) => Request; - /** 帰ってきた応答からページのJSONデータを取得する + /** Extracts page JSON data from the API response * - * @param res 応答 - * @return ページのJSONデータ + * @param res - The response from the API + * @returns A {@linkcode Result}<{@linkcode unknown}, {@linkcode Error}> containing: + * - Success: The page data in JSON format + * - Error: One of several possible errors: + * - {@linkcode NotFoundError}: Page not found + * - {@linkcode NotLoggedInError}: Authentication required + * - {@linkcode NotMemberError}: User lacks access + * - {@linkcode HTTPError}: Other HTTP errors */ fromResponse: (res: Response) => Promise>; @@ -115,11 +121,11 @@ export type PageError = | TooLongURIError | HTTPError; -/** 指定したページのJSONデータを取得する +/** Retrieves JSON data for a specified page * - * @param project 取得したいページのproject名 - * @param title 取得したいページのtitle 大文字小文字は問わない - * @param options オプション + * @param project The project name containing the desired page + * @param title The page title to retrieve (case insensitive) + * @param options Additional configuration options for the request */ export const getPage: GetPage = /* @__PURE__ */ (() => { const fn: GetPage = async ( @@ -140,11 +146,11 @@ export const getPage: GetPage = /* @__PURE__ */ (() => { return fn; })(); -/** Options for `listPages()` */ +/** Options for {@linkcode listPages} */ export interface ListPagesOption extends BaseOptions { /** the sort of page list to return * - * @default "updated" + * @default {"updated"} */ sort?: | "updatedWithMe" @@ -157,32 +163,37 @@ export interface ListPagesOption extends BaseOptions { | "title"; /** the index getting page list from * - * @default 0 + * @default {0} */ skip?: number; /** threshold of the length of page list * - * @default 100 + * @default {100} */ limit?: number; } export interface ListPages { - /** /api/pages/:project の要求を組み立てる + /** Constructs a request for the `/api/pages/:project` endpoint * - * @param project 取得したいページのproject名 - * @param options オプション - * @return request + * @param project The project name to list pages from + * @param options - Additional configuration options (sorting, pagination, etc.) + * @returns A {@linkcode Request} object for fetching pages data */ toRequest: ( project: string, options?: ListPagesOption, ) => Request; - /** 帰ってきた応答からページのJSONデータを取得する + /** Extracts page list JSON data from the API response * - * @param res 応答 - * @return ページのJSONデータ + * @param res - The response from the API + * @returns A {@linkcode Result}<{@linkcode Page[]}, {@linkcode ListPagesError}> containing: + * - Success: Array of page data in JSON format + * - Error: One of several possible errors: + * - {@linkcode NotLoggedInError}: Authentication required + * - {@linkcode NotMemberError}: User lacks access + * - {@linkcode HTTPError}: Other HTTP errors */ fromResponse: (res: Response) => Promise>; @@ -230,10 +241,10 @@ const listPages_fromResponse: ListPages["fromResponse"] = async (res) => ), ); -/** 指定したprojectのページを一覧する +/** Lists pages from a specified project * - * @param project 一覧したいproject - * @param options オプション 取得範囲や並び順を決める + * @param project The project name to list pages from + * @param options Configuration options for pagination and sorting */ export const listPages: ListPages = /* @__PURE__ */ (() => { const fn: ListPages = async ( diff --git a/rest/parseHTTPError.ts b/rest/parseHTTPError.ts index 003ed9b..c2f8c88 100644 --- a/rest/parseHTTPError.ts +++ b/rest/parseHTTPError.ts @@ -27,7 +27,16 @@ export interface RESTfullAPIErrorMap { NotPrivilegeError: NotPrivilegeError; } -/** 失敗した要求からエラー情報を取り出す */ +/** + * Extracts error information from a failed HTTP request + * + * This function parses the response from a failed HTTP request to extract structured error information. + * It handles various error types including authentication, permission, and validation errors. + * + * @returns A {@linkcode Maybe} containing: + * - Success: The specific error type requested in `errorNames` + * - Error: {@linkcode null} if the error type doesn't match + */ export const parseHTTPError = async < ErrorNames extends keyof RESTfullAPIErrorMap, >( @@ -75,7 +84,7 @@ export const parseHTTPError = async < } as unknown as RESTfullAPIErrorMap[ErrorNames]; } catch (e: unknown) { if (e instanceof SyntaxError) return; - // JSONのparse error以外はそのまま投げる + // Re-throw all errors except JSON parse errors (SyntaxError) throw e; } }; diff --git a/rest/profile.ts b/rest/profile.ts index 6e29310..6f47aec 100644 --- a/rest/profile.ts +++ b/rest/profile.ts @@ -11,17 +11,24 @@ import type { FetchError } from "./robustFetch.ts"; import { type BaseOptions, setDefaults } from "./options.ts"; export interface GetProfile { - /** /api/users/me の要求を組み立てる + /** Constructs a request for the `/api/users/me endpoint` * - * @param init connect.sid etc. - * @return request + * This endpoint retrieves the current user's profile information, + * which can be either a {@linkcode MemberUser} or {@linkcode GuestUser} profile. + * + * @param init - Options including `connect.sid` (session ID) and other configuration + * @returns A {@linkcode Request} object for fetching user profile data */ toRequest: (init?: BaseOptions) => Request; /** get the user profile from the given response * - * @param res response - * @return user profile + * @param res - Response from the API + * @returns A {@linkcode Result}<{@linkcode UserProfile}, {@linkcode Error}> containing: + * - Success: The user's profile data + * - Error: One of several possible errors: + * - {@linkcode NotLoggedInError}: Authentication required + * - {@linkcode HTTPError}: Other HTTP errors */ fromResponse: ( res: Response, diff --git a/rest/project.ts b/rest/project.ts index 24de85d..963b93e 100644 --- a/rest/project.ts +++ b/rest/project.ts @@ -21,21 +21,33 @@ import type { FetchError } from "./robustFetch.ts"; import { type BaseOptions, setDefaults } from "./options.ts"; export interface GetProject { - /** /api/project/:project の要求を組み立てる + /** Constructs a request for the `/api/project/:project` endpoint * - * @param project project name to get - * @param init connect.sid etc. - * @return request + * This endpoint retrieves detailed information about a specific project, + * which can be either a {@linkcode MemberProject} or {@linkcode NotMemberProject} depending on the user's access level. + * + * @param project - The project name to retrieve information for + * @param init - Options including connect.sid (session ID) and other configuration + * @returns A {@linkcode Request} object for fetching project data */ toRequest: ( project: string, options?: BaseOptions, ) => Request; - /** 帰ってきた応答からprojectのJSONデータを取得する + /** Extracts project JSON data from the API response + * + * Processes the API response and extracts the project information. + * Handles various error cases including {@linkcode NotFoundError}, {@linkcode NotMemberError}, and {@linkcode NotLoggedInError}. * - * @param res 応答 - * @return projectのJSONデータ + * @param res - The API response object + * @returns A {@linkcode Result}<{@linkcode MemberProject} | {@linkcode NotMemberProject}, {@linkcode ProjectError}> containing: + * - Success: The project data with access level information + * - Error: One of several possible errors: + * - {@linkcode NotFoundError}: Project does not exist + * - {@linkcode NotMemberError}: User lacks access + * - {@linkcode NotLoggedInError}: Authentication required + * - {@linkcode HTTPError}: Other HTTP errors */ fromResponse: ( res: Response, @@ -78,10 +90,24 @@ const getProject_fromResponse: GetProject["fromResponse"] = async (res) => (res) => res.json() as Promise, ); -/** get the project information +/** Get detailed information about a Scrapbox project + * + * This function retrieves detailed information about a project, including its + * access level, settings, and metadata. The returned data type depends on + * whether the user has member access to the project. * - * @param project project name to get - * @param init connect.sid etc. + * @param project - Project name to retrieve information for + * @param init - Options including `connect.sid` for authentication + * @returns A {@linkcode Result}<{@linkcode MemberProject} | {@linkcode NotMemberProject}, {@linkcode ProjectError} | {@linkcode FetchError}> containing: + * - Success: Project information based on access level: + * - {@linkcode MemberProject}: Full project data for members + * - {@linkcode NotMemberProject}: Limited data for non-members + * - Error: One of several possible errors: + * - {@linkcode NotFoundError}: Project does not exist + * - {@linkcode NotMemberError}: User lacks access + * - {@linkcode NotLoggedInError}: Authentication required + * - {@linkcode HTTPError}: Server errors + * - {@linkcode FetchError}: Network errors */ export const getProject: GetProject = /* @__PURE__ */ (() => { const fn: GetProject = async ( @@ -104,21 +130,31 @@ export const getProject: GetProject = /* @__PURE__ */ (() => { })(); export interface ListProjects { - /** /api/project の要求を組み立てる + /** Constructs a request for the `/api/projects` endpoint * - * @param projectIds project ids. This must have more than 1 id - * @param init connect.sid etc. - * @return request + * This endpoint retrieves information for multiple projects in a single request. + * The endpoint requires at least one project ID to be provided. + * + * @param projectIds - Array of project IDs to retrieve information for (must contain at least one ID) + * @param init - Options including `connect.sid` (session ID) and other configuration + * @returns A {@linkcode Request} object for fetching multiple projects' data */ toRequest: ( projectIds: ProjectId[], init?: BaseOptions, ) => Request; - /** 帰ってきた応答からprojectのJSONデータを取得する + /** Extracts projects JSON data from the API response + * + * Processes the API response and extracts information for multiple projects. + * Handles authentication errors ({@linkcode NotLoggedInError}) and other HTTP errors. * - * @param res 応答 - * @return projectのJSONデータ + * @param res - The API response object + * @returns A {@linkcode Result}<{@linkcode ProjectData}, {@linkcode ProjectError}> containing: + * - Success: The project data + * - Error: One of several possible errors: + * - {@linkcode NotFoundError}: Project not found + * - {@linkcode HTTPError}: Other HTTP errors */ fromResponse: ( res: Response, @@ -154,10 +190,19 @@ const ListProject_fromResponse: ListProjects["fromResponse"] = async (res) => (res) => res.json() as Promise, ); -/** list the projects' information +/** List information for multiple Scrapbox projects + * + * This function retrieves information for multiple projects in a single request. + * At least one project ID must be provided. * - * @param projectIds project ids. This must have more than 1 id - * @param init connect.sid etc. + * @param projectIds - Array of project IDs to retrieve (must contain at least one ID) + * @param init - Options including `connect.sid` for authentication + * @returns A {@linkcode Result}<{@linkcode ProjectResponse}, {@linkcode ListProjectsError} | {@linkcode FetchError}> containing: + * - Success: Project data for all requested projects + * - Error: One of several possible errors: + * - {@linkcode NotLoggedInError}: Authentication required + * - {@linkcode HTTPError}: Server errors + * - {@linkcode FetchError}: Network errors */ export const listProjects: ListProjects = /* @__PURE__ */ (() => { const fn: ListProjects = async ( diff --git a/rest/replaceLinks.ts b/rest/replaceLinks.ts index b89e43e..ecbe5dd 100644 --- a/rest/replaceLinks.ts +++ b/rest/replaceLinks.ts @@ -22,16 +22,17 @@ export type ReplaceLinksError = | NotMemberError | HTTPError; -/** 指定したproject内の全てのリンクを書き換える +/** Replaces all links within the specified project * - * リンクと同一のタイトルは書き換わらないので注意 - * - タイトルも書き換えたいときは/browser/mod.tsの`patch()`などで書き換えること + * > [!IMPORTANT] + * > This function only replaces links, not page titles. + * > If you need to replace page titles as well, use {@linkcode patch} * - * @param project これで指定したproject内の全てのリンクが置換対象となる - * @param from 置換前のリンク - * @param to 置換後のリンク - * @param init connect.sidなど - * @return 置換されたリンクがあったページの数 + * @param project - The project name where all links will be replaced + * @param from - The original link text to be replaced + * @param to - The new link text to replace with + * @param init - Options including `connect.sid` (session ID) and other configuration + * @returns A {@linkcode number} indicating the count of pages where links were replaced */ export const replaceLinks = async ( project: string, @@ -71,7 +72,7 @@ export const replaceLinks = async ( ])) ?? error, ), async (res) => { - // messageには"2 pages have been successfully updated!"というような文字列が入っているはず + // message should contain a string like "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 index 280230d..8189704 100644 --- a/rest/responseIntoResult.ts +++ b/rest/responseIntoResult.ts @@ -1,11 +1,28 @@ import { createErr, createOk, type Result } from "option-t/plain_result"; +/** + * Represents an HTTP error response with status code and message. + * + * @property name - Always "HTTPError" to identify the error type + * @property message - A string containing the HTTP status code and status text + * @property response - The original {@linkcode Response} object that caused the error + */ export interface HTTPError { name: "HTTPError"; message: string; response: Response; } +/** + * Converts a {@linkcode Response} into a {@linkcode Result} type, handling HTTP errors. + * + * @param response - The {@linkcode Response} object to convert into a {@linkcode Result} + * @returns A {@linkcode Result}<{@linkcode Response}, {@linkcode HTTPError}> containing either: + * - Success: The original {@linkcode Response} if status is ok (2xx) + * - Error: A {@linkcode HTTPError} containing: + * - status code and status text as message + * - original {@linkcode Response} object for further processing + */ export const responseIntoResult = ( response: Response, ): Result => diff --git a/rest/robustFetch.ts b/rest/robustFetch.ts index e9b5305..bf371dd 100644 --- a/rest/robustFetch.ts +++ b/rest/robustFetch.ts @@ -17,9 +17,13 @@ export type FetchError = NetworkError | AbortError; /** * Represents a function that performs a network request using the Fetch API. * - * @param input - The resource URL or a {@linkcode Request} object. - * @param init - An optional object containing request options. - * @returns A promise that resolves to a {@linkcode Result} object containing either a {@linkcode Request} or an error. + * @param input - The resource URL (as {@linkcode string} or {@linkcode URL}), {@linkcode RequestInfo}, or a {@linkcode Request} object to fetch + * @param init - Optional {@linkcode RequestInit} configuration for the request including headers, method, body, etc. + * @returns A {@linkcode Result}<{@linkcode Response}, {@linkcode FetchError}> containing either: + * - Success: A {@linkcode Response} from the successful fetch operation + * - Error: One of several possible errors: + * - {@linkcode NetworkError}: Network connectivity or DNS resolution failed (from {@linkcode TypeError}) + * - {@linkcode AbortError}: Request was aborted before completion (from {@linkcode DOMException}) */ export type RobustFetch = ( input: RequestInfo | URL, @@ -29,9 +33,13 @@ export type RobustFetch = ( /** * A simple implementation of {@linkcode RobustFetch} that uses {@linkcode fetch}. * - * @param input - The resource URL or a {@linkcode Request} object. - * @param init - An optional object containing request options. - * @returns A promise that resolves to a {@linkcode Result} object containing either a {@linkcode Request} or an error. + * @param input - The resource URL (as {@linkcode string} or {@linkcode URL}), {@linkcode RequestInfo}, or a {@linkcode Request} object to fetch + * @param init - Optional {@linkcode RequestInit} configuration for the request including headers, method, body, etc. + * @returns A {@linkcode Result}<{@linkcode Response}, {@linkcode FetchError}> containing either: + * - Success: A {@linkcode Response} from the successful fetch operation + * - Error: One of several possible errors: + * - {@linkcode NetworkError}: Network connectivity or DNS resolution failed (from {@linkcode TypeError}) + * - {@linkcode AbortError}: Request was aborted before completion (from {@linkcode DOMException}) */ export const robustFetch: RobustFetch = async (input, init) => { const request = new Request(input, init); diff --git a/rest/search.ts b/rest/search.ts index 03c4ffc..4e64e31 100644 --- a/rest/search.ts +++ b/rest/search.ts @@ -26,11 +26,11 @@ export type SearchForPagesError = | NoQueryError | HTTPError; -/** search a project for pages +/** Search for pages within a specific project * - * @param query 検索語句 - * @param project 検索範囲とするprojectの名前 - * @param init connect.sid etc. + * @param query The search query string to match against pages + * @param project The name of the project to search within + * @param init Options including `connect.sid` (session ID) and other configuration */ export const searchForPages = async ( query: string, @@ -69,10 +69,10 @@ export type SearchForJoinedProjectsError = | NoQueryError | HTTPError; -/** search for joined projects +/** Search across all projects that the user has joined * - * @param query 検索語句 - * @param init connect.sid etc. + * @param query The search query string to match against projects + * @param init Options including `connect.sid` (session ID) and other configuration */ export const searchForJoinedProjects = async ( query: string, @@ -110,15 +110,19 @@ export const searchForJoinedProjects = async ( export type SearchForWatchListError = SearchForJoinedProjectsError; -/** search for watch list +/** Search within a list of watched projects * - * watch listと銘打っているが、実際には参加していないpublic projectならどれでも検索できる + * > [!NOTE] + * > Despite the name "watch list", this function can search any public project, + * > even those the user hasn't joined. * - * 参加しているprojectのidは指定しても無視されるだけ + * > [!NOTE] + * > If you include IDs of projects the user has already joined, + * > these IDs will be ignored in the search. * - * @param query 検索語句 - * @param projectIds 検索候補のprojectのidのリスト - * @param init connect.sid etc. + * @param query The search query string to match + * @param projectIds List of project IDs to search within (for non-joined public projects) + * @param init Options including `connect.sid` (session ID) and other configuration */ export const searchForWatchList = async ( query: string, diff --git a/rest/snapshot.ts b/rest/snapshot.ts index 8fdc70c..37bd752 100644 --- a/rest/snapshot.ts +++ b/rest/snapshot.ts @@ -19,11 +19,23 @@ import { import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; import type { FetchError } from "./mod.ts"; -/** 不正な`timestampId`を渡されたときに発生するエラー */ +/** Error that occurs when an invalid `timestampId` is provided to {@linkcode getSnapshot} + * + * Extends {@linkcode ErrorLike} with a specific error name for invalid snapshot IDs. + */ export interface InvalidPageSnapshotIdError extends ErrorLike { name: "InvalidPageSnapshotIdError"; } +/** Union type of all possible errors that can occur when retrieving a page snapshot + * + * Includes: + * - {@linkcode NotFoundError}: Page or project not found + * - {@linkcode NotLoggedInError}: User authentication required + * - {@linkcode NotMemberError}: User lacks project access + * - {@linkcode InvalidPageSnapshotIdError}: Invalid snapshot ID provided + * - {@linkcode HTTPError}: Server or network error + */ export type SnapshotError = | NotFoundError | NotLoggedInError @@ -31,9 +43,22 @@ export type SnapshotError = | InvalidPageSnapshotIdError | HTTPError; -/** get a page snapshot +/** Retrieve a specific version of a page from its snapshot history * - * @param options connect.sid etc. + * @param project - The name of the Scrapbox project containing the page + * @param pageId - The ID of the page to retrieve the snapshot for + * @param timestampId - The specific snapshot timestamp ID to retrieve + * @param options - Optional configuration including {@linkcode BaseOptions} like `connect.sid` + * @returns A {@linkcode Result}<{@linkcode PageSnapshotResult}, {@linkcode SnapshotError} | {@linkcode FetchError}> containing: + * - Success: A {@linkcode PageSnapshotResult} containing the page snapshot data + * - Error: One of several possible errors: + * - {@linkcode SnapshotError} or {@linkcode FetchError}: Network or API errors + * - {@linkcode NotFoundError}: Page or project not found + * - {@linkcode NotLoggedInError}: User authentication required + * - {@linkcode NotMemberError}: User lacks project access + * - {@linkcode InvalidPageSnapshotIdError}: Invalid timestamp ID + * - {@linkcode HTTPError}: Server or network error + * - {@linkcode FetchError}: Request failed */ export const getSnapshot = async ( project: string, @@ -70,6 +95,14 @@ export const getSnapshot = async ( ); }; +/** Union type of all possible errors that can occur when retrieving snapshot timestamp IDs + * + * Includes: + * - {@linkcode NotFoundError}: Page or project not found + * - {@linkcode NotLoggedInError}: User authentication required + * - {@linkcode NotMemberError}: User lacks project access + * - {@linkcode HTTPError}: Server or network error + */ export type SnapshotTimestampIdsError = | NotFoundError | NotLoggedInError @@ -79,11 +112,17 @@ export type SnapshotTimestampIdsError = /** * Retrieves the timestamp IDs for a specific page in a project. * - * @param project - The name of the project. - * @param pageId - The ID of the page. - * @param options - Optional configuration options. - * @returns A promise that resolves to a {@link Result} object containing the page snapshot list if successful, - * or an error if the request fails. + * @param project - The name of the Scrapbox project to retrieve snapshots from + * @param pageId - The ID of the page to retrieve snapshot history for + * @param options - Optional {@linkcode BaseOptions} configuration including authentication + * @returns A {@linkcode Result}<{@linkcode PageSnapshotList}, {@linkcode SnapshotTimestampIdsError} | {@linkcode FetchError}> containing: + * - Success: A {@linkcode PageSnapshotList} containing the page's snapshot history + * - Error: One of several possible errors: + * - {@linkcode NotFoundError}: Page or project not found + * - {@linkcode NotLoggedInError}: User authentication required + * - {@linkcode NotMemberError}: User lacks project access + * - {@linkcode HTTPError}: Server or network error + * - {@linkcode FetchError}: Request failed */ export const getTimestampIds = async ( project: string, diff --git a/rest/table.ts b/rest/table.ts index b013265..45dc7b6 100644 --- a/rest/table.ts +++ b/rest/table.ts @@ -41,7 +41,7 @@ const getTable_fromResponse: GetTable["fromResponse"] = async (res) => async (error) => error.response.status === 404 ? { - // responseが空文字の時があるので、自前で組み立てる + // Build error manually since response may be an empty string name: "NotFoundError", message: "Table not found.", } @@ -60,13 +60,13 @@ export type TableError = | HTTPError; export interface GetTable { - /** /api/table/:project/:title/:filename.csv の要求を組み立てる + /** Build a request for `/api/table/:project/:title/:filename.csv` endpoint * - * @param project 取得したいページのproject名 - * @param title 取得したいページのtitle 大文字小文字は問わない - * @param filename テーブルの名前 - * @param options オプション - * @return request + * @param project - Name of the project containing the target page + * @param title - Title of the page (case-insensitive) + * @param filename - Name of the table to retrieve + * @param options - Additional configuration options + * @returns A {@linkcode Request} object for fetching the table data */ toRequest: ( project: string, @@ -75,10 +75,16 @@ export interface GetTable { options?: BaseOptions, ) => Request; - /** 帰ってきた応答からページのJSONデータを取得する + /** Extract page JSON data from the response * - * @param res 応答 - * @return ページのJSONデータ + * @param res - Response from the server + * @returns A {@linkcode Result}<{@linkcode string}, {@linkcode TableError}> containing: + * - Success: The table data in CSV format + * - Error: One of several possible errors: + * - {@linkcode NotFoundError}: Table not found + * - {@linkcode NotLoggedInError}: Authentication required + * - {@linkcode NotMemberError}: User lacks access + * - {@linkcode HTTPError}: Other HTTP errors */ fromResponse: (res: Response) => Promise>; @@ -90,12 +96,23 @@ export interface GetTable { ): Promise>; } -/** 指定したテーブルをCSV形式で得る +/** Retrieve a specified table in CSV format from a Scrapbox page * - * @param project 取得したいページのproject名 - * @param title 取得したいページのtitle 大文字小文字は問わない - * @param filename テーブルの名前 - * @param options オプション + * This function fetches a table stored in a Scrapbox page and returns its contents + * in CSV format. The table must exist in the specified project and page. + * + * @param project - Name of the project containing the target page + * @param title - Title of the page (case-insensitive) + * @param filename - Name of the table to retrieve + * @param options - Additional configuration options including authentication + * @returns A {@linkcode Result}<{@linkcode string}, {@linkcode TableError} | {@linkcode FetchError}> containing: + * - Success: The table data in CSV format + * - Error: One of several possible errors: + * - {@linkcode NotFoundError}: Table not found + * - {@linkcode NotLoggedInError}: Authentication required + * - {@linkcode NotMemberError}: User lacks access + * - {@linkcode HTTPError}: Other HTTP errors + * - {@linkcode FetchError}: Network or request errors */ export const getTable: GetTable = /* @__PURE__ */ (() => { const fn: GetTable = async ( diff --git a/rest/uploadToGCS.ts b/rest/uploadToGCS.ts index 00d6f0a..29f48c9 100644 --- a/rest/uploadToGCS.ts +++ b/rest/uploadToGCS.ts @@ -21,12 +21,12 @@ import { toResultOkFromMaybe } from "option-t/maybe"; import type { FetchError } from "./robustFetch.ts"; import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; -/** uploadしたファイルのメタデータ */ +/** Metadata for the uploaded file */ export interface GCSFile { - /** uploadしたファイルのURL */ + /** URL of the uploaded file */ embedUrl: string; - /** uploadしたファイルの名前 */ + /** Original name of the uploaded file */ originalName: string; } @@ -36,11 +36,16 @@ export type UploadGCSError = | FileCapacityError | HTTPError; -/** 任意のファイルをscrapbox.ioにuploadする +/** Upload any file to scrapbox.io * - * @param file uploadしたいファイル - * @param projectId upload先projectのid - * @return 成功したら、ファイルのクラウド上のURLなどが返ってくる + * @param file File to upload + * @param projectId - ID of the target project + * @returns A {@linkcode Result}<{@linkcode UploadResponse}, {@linkcode Error}> containing: + * - Success: The file's cloud URL and metadata + * - Error: One of several possible errors: + * - {@linkcode NotLoggedInError}: Authentication required + * - {@linkcode NotMemberError}: User lacks access + * - {@linkcode HTTPError}: Other HTTP errors */ export const uploadToGCS = async ( file: File, @@ -57,25 +62,29 @@ export const uploadToGCS = async ( return verify(projectId, fileOrRequest.fileId, md5Hash, options); }; -/** 容量を使い切ったときに発生するerror */ +/** Error that occurs when storage capacity is exceeded */ export interface FileCapacityError extends ErrorLike { name: "FileCapacityError"; } interface UploadRequest { - /** upload先URL */ + /** Signed URL for uploading the file */ signedUrl: string; - /** uploadしたファイルに紐付けられる予定のfile id */ + /** File ID that will be associated with the uploaded file */ fileId: string; } -/** ファイルのuploadを要求する +/** Request file upload authorization * - * @param file uploadしたいファイル - * @param projectId upload先projectのid - * @param md5 uploadしたいファイルのMD5 hash (16進数) - * @return すでにuploadされていればファイルのURLを、まだの場合はupload先URLを返す + * @param file File to upload + * @param projectId ID of the target project + * @param md5 - MD5 hash of the file (hexadecimal) + * @returns A {@linkcode Result}<{@linkcode string}, {@linkcode Error}> containing: + * - Success: File URL (if already uploaded) or upload destination URL + * - Error: One of several possible errors: + * - {@linkcode NotLoggedInError}: Authentication required + * - {@linkcode HTTPError}: Other HTTP errors */ const uploadRequest = async ( file: File, @@ -127,15 +136,15 @@ const uploadRequest = async ( ); }; -/** Google Cloud Storage XML APIのerror +/** Google Cloud Storage XML API error * - * `message`には[この形式](https://cloud.google.com/storage/docs/xml-api/reference-status#http-status-and-error-codes)のXMLが入る + * The {@linkcode ErrorLike.message} field contains XML in [this format](https://cloud.google.com/storage/docs/xml-api/reference-status#http-status-and-error-codes) */ export interface GCSError extends ErrorLike { name: "GCSError"; } -/** ファイルをuploadする */ +/** Upload the file to storage */ const upload = async ( signedUrl: string, file: File, @@ -170,7 +179,7 @@ const upload = async ( ); }; -/** uploadしたファイルの整合性を確認する */ +/** Verify the integrity of the uploaded file */ const verify = async ( projectId: string, fileId: string, diff --git a/text.ts b/text.ts index 74a5ea4..4364700 100644 --- a/text.ts +++ b/text.ts @@ -1,13 +1,17 @@ import { isString } from "@core/unknownutil/is/string"; -/** インデント数を数える */ +/** Count the number of leading whitespace characters (indentation level) + * + * @param text - The input {@linkcode string} to analyze + * @returns The {@linkcode number} of leading whitespace characters + */ export const getIndentCount = (text: string): number => text.match(/^(\s*)/)?.[1]?.length ?? 0; -/** 指定した行の配下にある行の数を返す +/** Count the number of subsequent lines that are indented under the specified line * - * @param index 指定したい行の行番号 - * @param lines 行のリスト + * @param index - Line number of the target line + * @param lines - List of lines (can be strings or objects with text property) */ export const getIndentLineCount = ( index: number, diff --git a/title.ts b/title.ts index 5b6ca10..1bebb57 100644 --- a/title.ts +++ b/title.ts @@ -1,29 +1,28 @@ -/** 文字列をtitleLc形式に変換する +/** Convert a string to titleLc format * - * - ` ` -> `_` + * - Converts spaces (` `) to underscores (`_`) + * - Converts uppercase to lowercase * - * - 大文字 -> 小文字 + * Primarily used for comparing links for equality * - * リンクの等値比較をする際に主に使われる - * - * @param text 変換する文字列 - * @return 変換後の文字列 + * @param text - String to convert + * @returns A {@linkcode string} containing the converted text in titleLc format */ export const toTitleLc = (text: string): string => text.replaceAll(" ", "_").toLowerCase(); -/** `_`を半角スペースに変換する +/** Convert underscores (`_`) to single-byte spaces * - * @param text 変換する文字列 - * @return 変換後の文字列 + * @param text - String to convert + * @returns A {@linkcode string} with underscores converted to spaces */ export const revertTitleLc = (text: string): string => text.replaceAll("_", " "); -/** titleをURIで使える形式にEncodeする +/** Encode a title into a URI-safe format * - * @param title 変換するtitle - * @return 変換後の文字列 + * @param title - Title to encode + * @returns A {@linkcode string} containing the URI-safe encoded title */ export const encodeTitleURI = (title: string): string => { return [...title].map((char, index) => { @@ -41,10 +40,10 @@ export const encodeTitleURI = (title: string): string => { const noEncodeChars = '@$&+=:;",'; const noTailChars = ':;",'; -/** titleをできるだけpercent encodingせずにURIで使える形式にする +/** Convert a title to a URI-safe format while minimizing percent encoding * - * @param title 変換するtitle - * @return 変換後の文字列 + * @param title - Title to convert + * @returns A {@linkcode string} containing the URI-safe title with minimal percent encoding */ export const toReadableTitleURI = (title: string): string => { return title.replaceAll(" ", "_")