├── .github └── workflows │ ├── ci.yml │ ├── publish.yml │ └── udd.yml ├── .gitignore ├── LICENSE ├── README.md ├── api.ts ├── api ├── pages.ts ├── pages │ ├── project.ts │ └── project │ │ ├── replace.ts │ │ ├── replace │ │ └── links.ts │ │ ├── search.ts │ │ ├── search │ │ ├── query.ts │ │ └── titles.ts │ │ ├── title.ts │ │ └── title │ │ ├── icon.ts │ │ └── text.ts ├── projects.ts ├── projects │ └── project.ts ├── users.ts └── users │ └── me.ts ├── browser ├── dom │ ├── __snapshots__ │ │ └── extractCodeFiles.test.ts.snap │ ├── _internal.test.ts │ ├── _internal.ts │ ├── cache.ts │ ├── caret.ts │ ├── click.ts │ ├── cursor.d.ts │ ├── cursor.ts │ ├── dom.ts │ ├── edit.ts │ ├── ensure.ts │ ├── extractCodeFiles.test.ts │ ├── extractCodeFiles.ts │ ├── getCachedLines.ts │ ├── isHeightViewable.ts │ ├── mod.ts │ ├── motion.ts │ ├── node.ts │ ├── open.ts │ ├── page.d.ts │ ├── position.ts │ ├── press.ts │ ├── pushPageTransition.ts │ ├── sample-lines1.json │ ├── selection.d.ts │ ├── selection.ts │ ├── statusBar.ts │ ├── stores.ts │ ├── takeInternalLines.ts │ └── textInputEventListener.ts └── mod.ts ├── deno.jsonc ├── deno.lock ├── deps └── onp.ts ├── error.ts ├── json_compatible.ts ├── mod.ts ├── parseAbsoluteLink.ts ├── parser ├── __snapshots__ │ ├── anchor-fm.test.ts.snap │ ├── spotify.test.ts.snap │ ├── vimeo.test.ts.snap │ └── youtube.test.ts.snap ├── anchor-fm.test.ts ├── anchor-fm.ts ├── spotify.test.ts ├── spotify.ts ├── vimeo.ts ├── youtube.test.ts └── youtube.ts ├── rest ├── __snapshots__ │ ├── getCodeBlocks.test.ts.snap │ ├── pages.test.ts.snap │ └── project.test.ts.snap ├── auth.ts ├── getCachedAt.ts ├── getCodeBlock.ts ├── getCodeBlocks.test.ts ├── getCodeBlocks.ts ├── getGyazoToken.ts ├── getTweetInfo.ts ├── getWebPageTitle.ts ├── link.ts ├── mod.ts ├── options.ts ├── page-data.ts ├── pages.test.ts ├── pages.ts ├── parseHTTPError.ts ├── profile.ts ├── project.test.ts ├── project.ts ├── replaceLinks.ts ├── responseIntoResult.ts ├── robustFetch.ts ├── search.ts ├── snapshot.ts ├── table.ts └── uploadToGCS.ts ├── script.ts ├── targeted_response.ts ├── text.ts ├── title.ts ├── util.ts ├── vendor └── raw.githubusercontent.com │ └── takker99 │ └── onp │ └── 0.0.1 │ └── mod.ts └── websocket ├── __snapshots__ └── _codeBlock.test.ts.snap ├── _codeBlock.test.ts ├── _codeBlock.ts ├── applyCommit.ts ├── deletePage.ts ├── diffToChanges.test.ts ├── diffToChanges.ts ├── emit.ts ├── error.ts ├── getHelpfeels.ts ├── getPageMetadataFromLines.ts ├── id.ts ├── isSameArray.ts ├── isSimpleCodeFile.test.ts ├── isSimpleCodeFile.ts ├── listen.ts ├── makeChanges.ts ├── mod.ts ├── patch.ts ├── pin.ts ├── pull.ts ├── push.ts ├── socket.ts ├── suggestUnDupTitle.ts ├── updateCodeBlock.ts └── updateCodeFile.ts /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | env: 4 | DENO_VERSION: 2.x 5 | 6 | on: [push, pull_request] 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: denoland/setup-deno@v2 14 | with: 15 | deno-version: ${{ env.DENO_VERSION }} 16 | - name: Check fmt & lint & type check & test 17 | run: deno task check 18 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # cf. https://jsr.io/@core/unknownutil/3.18.1/.github/workflows/jsr.yml 2 | name: publish 3 | 4 | env: 5 | DENO_VERSION: 2.x 6 | 7 | on: 8 | push: 9 | tags: 10 | - "*" 11 | 12 | permissions: 13 | contents: read 14 | id-token: write 15 | 16 | jobs: 17 | publish: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: denoland/setup-deno@v2 22 | with: 23 | deno-version: ${{ env.DENO_VERSION }} 24 | - name: Publish on tag 25 | run: deno run --allow-env --allow-run=deno --allow-read --allow-write=deno.jsonc jsr:@david/publish-on-tag@0.1.4 26 | -------------------------------------------------------------------------------- /.github/workflows/udd.yml: -------------------------------------------------------------------------------- 1 | name: update 2 | 3 | env: 4 | DENO_VERSION: 2.x 5 | 6 | on: 7 | schedule: 8 | - cron: "0 0 * * *" 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: write 13 | pull-requests: write 14 | 15 | jobs: 16 | update: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: denoland/setup-deno@v2 21 | with: 22 | deno-version: ${{ env.DENO_VERSION }} 23 | - name: Update 24 | run: deno task update 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | local_test/ 2 | coverage/ 3 | docs/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 takker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # scrapbox-userscript-std 2 | 3 | [![JSR](https://jsr.io/badges/@cosense/std)](https://jsr.io/@cosense/std) 4 | [![test](https://github.com/takker99/scrapbox-userscript-std/workflows/ci/badge.svg)](https://github.com/takker99/scrapbox-userscript-std/actions?query=workflow%3Aci) 5 | 6 | UNOFFICIAL standard module for Scrapbox UserScript 7 | 8 | ## Getting Started 9 | 10 | This library serves as an unofficial standard library for developing Scrapbox 11 | userscripts. It provides a comprehensive set of utilities for interacting with 12 | Scrapbox's features, including REST API operations, browser interactions, and 13 | common utilities. 14 | 15 | ### Installation 16 | 17 | 1. Bundler Configuration This library is distributed through JSR (JavaScript 18 | Registry) and requires a bundler configuration. Follow these steps: 19 | 20 | a. Configure your bundler to use JSR: 21 | 22 | - For esbuild: Add JSR to your import map 23 | - For other bundlers: Refer to your bundler's JSR integration documentation 24 | 25 | b. Import the library: 26 | 27 | ```typescript 28 | // Import commonly used functions 29 | import { getPage } from "jsr:@cosense/std/rest"; 30 | import { parseAbsoluteLink } from "jsr:@cosense/std"; 31 | 32 | // Import specific modules (recommended) 33 | import { getLinks } from "jsr:@cosense/std/rest"; 34 | import { press } from "jsr:@cosense/std/browser/dom"; 35 | import { getLines } from "jsr:@cosense/std/browser/dom"; 36 | ``` 37 | 38 | 2. Module Organization The library is organized into the following main modules: 39 | 40 | - `rest/`: API operations for Scrapbox REST endpoints 41 | - Page operations 42 | - Project management 43 | - User authentication 44 | - `browser/`: Browser-side operations 45 | - DOM manipulation 46 | - WebSocket communication 47 | - Event handling 48 | - Core utilities: 49 | - `title`: Title parsing and formatting 50 | - `parseAbsoluteLink`: External link analysis 51 | - Additional helper functions 52 | 53 | ## Examples 54 | 55 | ### Basic Usage 56 | 57 | 1. Retrieving Page Information 58 | 59 | ```typescript 60 | // Get page content and metadata 61 | import { getPage } from "jsr:@cosense/std/rest"; 62 | 63 | const result = await getPage("projectName", "pageName"); 64 | if (result.ok) { 65 | const page = result.val; 66 | console.log("Page title:", page.title); 67 | console.log("Page content:", page.lines.map((line) => line.text)); 68 | console.log("Page descriptions:", page.descriptions.join("\n")); 69 | } 70 | ``` 71 | 72 | 2. DOM Operations 73 | 74 | ```typescript 75 | // Interact with the current page's content 76 | import { getLines, press } from "jsr:@cosense/std/browser/dom"; 77 | 78 | // Get all lines from the current page 79 | const lines = getLines(); 80 | console.log(lines.map((line) => line.text)); 81 | 82 | // Simulate keyboard input 83 | await press("Enter"); // Add a new line 84 | await press("Tab"); // Indent the line 85 | ``` 86 | 87 | 3. External Link Analysis 88 | 89 | ```typescript 90 | // Parse external links (YouTube, Spotify, etc.) 91 | import { parseAbsoluteLink } from "jsr:@cosense/std"; 92 | import type { LinkNode } from "@progfay/scrapbox-parser"; 93 | 94 | // Create a link node with absolute path type 95 | const link = { 96 | type: "link" as const, 97 | pathType: "absolute" as const, 98 | href: "https://www.youtube.com/watch?v=xxxxx", 99 | content: "", 100 | raw: "[https://www.youtube.com/watch?v=xxxxx]", 101 | } satisfies LinkNode & { pathType: "absolute" }; 102 | 103 | // Parse and handle different link types 104 | const parsed = parseAbsoluteLink(link); 105 | if (parsed?.type === "youtube") { 106 | // Handle YouTube links 107 | console.log("YouTube video ID:", parsed.href.split("v=")[1]); 108 | const params = new URLSearchParams(parsed.href.split("?")[1]); 109 | const start = params.get("t"); 110 | if (start) { 111 | console.log("Video timestamp:", start); 112 | } 113 | } else if (parsed?.type === "spotify") { 114 | // Handle Spotify links 115 | const match = parsed.href.match(/spotify\.com\/track\/([^?]+)/); 116 | if (match) { 117 | console.log("Spotify track ID:", match[1]); 118 | } 119 | } 120 | ``` 121 | 122 | ### Important Notes 123 | 124 | - This library requires a bundler for use in userscripts 125 | - Full TypeScript support with type definitions included 126 | - Comprehensive error handling with type-safe responses 127 | - For more examples and use cases, see the 128 | [Examples](https://github.com/takker99/scrapbox-userscript-std/tree/main/examples) 129 | directory 130 | 131 | ### Additional Resources 132 | 133 | - [JSR Package Page](https://jsr.io/@cosense/std) 134 | - [API Documentation](https://jsr.io/@cosense/std/doc) 135 | - [GitHub Repository](https://github.com/takker99/scrapbox-userscript-std) 136 | -------------------------------------------------------------------------------- /api.ts: -------------------------------------------------------------------------------- 1 | export * as pages from "./api/pages.ts"; 2 | export * as projects from "./api/projects.ts"; 3 | export * as users from "./api/users.ts"; 4 | export type { HTTPError, TypedError } from "./error.ts"; 5 | export type { BaseOptions, ExtendedOptions, OAuthOptions } from "./util.ts"; 6 | 7 | export { 8 | get as listPages, 9 | list as listPagesStream, 10 | type ListPagesOption, 11 | type ListPagesStreamOption, 12 | makeGetRequest as makeListPagesRequest, 13 | } from "./api/pages/project.ts"; 14 | export { 15 | makePostRequest as makeReplaceLinksRequest, 16 | post as replaceLinks, 17 | } from "./api/pages/project/replace/links.ts"; 18 | export { 19 | get as searchForPages, 20 | makeGetRequest as makeSearchForPagesRequest, 21 | } from "./api/pages/project/search/query.ts"; 22 | export { 23 | get as getLinks, 24 | type GetLinksOptions, 25 | list as readLinks, 26 | makeGetRequest as makeGetLinksRequest, 27 | } from "./api/pages/project/search/titles.ts"; 28 | export { 29 | get as getPage, 30 | type GetPageOption, 31 | makeGetRequest as makeGetPageRequest, 32 | } from "./api/pages/project/title.ts"; 33 | export { 34 | get as getText, 35 | type GetTextOption, 36 | makeGetRequest as makeGetTextRequest, 37 | } from "./api/pages/project/title/text.ts"; 38 | export { 39 | get as getIcon, 40 | type GetIconOption, 41 | makeGetRequest as makeGetIconRequest, 42 | } from "./api/pages/project/title/icon.ts"; 43 | export { 44 | get as getProject, 45 | makeGetRequest as makeGetProjectRequest, 46 | } from "./api/projects/project.ts"; 47 | export { 48 | get as getUser, 49 | makeGetRequest as makeGetUserRequest, 50 | } from "./api/users/me.ts"; 51 | -------------------------------------------------------------------------------- /api/pages.ts: -------------------------------------------------------------------------------- 1 | export * as project from "./pages/project.ts"; 2 | -------------------------------------------------------------------------------- /api/pages/project/replace.ts: -------------------------------------------------------------------------------- 1 | export * as links from "./replace/links.ts"; 2 | -------------------------------------------------------------------------------- /api/pages/project/replace/links.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | NotFoundError, 3 | NotLoggedInError, 4 | NotMemberError, 5 | } from "@cosense/types/rest"; 6 | import type { ResponseOfEndpoint } from "../../../../targeted_response.ts"; 7 | import { type ExtendedOptions, setDefaults } from "../../../../util.ts"; 8 | import { cookie } from "../../../../rest/auth.ts"; 9 | import { get } from "../../../users/me.ts"; 10 | 11 | /** Constructs a request for the `/api/pages/:project/replace/links` endpoint 12 | * 13 | * @experimental **UNSTABLE**: New API, yet to be vetted. 14 | * 15 | * @param project - The project name where all links will be replaced 16 | * @param from - The original link text to be replaced 17 | * @param to - The new link text to replace with 18 | * @param init - Additional configuration options 19 | * @returns A {@linkcode Request} object for replacing links in `project` 20 | */ 21 | export const makePostRequest = ( 22 | project: string, 23 | from: string, 24 | to: string, 25 | init?: ExtendedOptions, 26 | ): Request => { 27 | const { sid, baseURL, csrf } = setDefaults(init ?? {}); 28 | 29 | return new Request( 30 | `${baseURL}api/pages/${project}/replace/links`, 31 | { 32 | method: "POST", 33 | headers: { 34 | "Content-Type": "application/json;charset=utf-8", 35 | "X-CSRF-TOKEN": csrf ?? "", 36 | ...(sid ? { Cookie: cookie(sid) } : {}), 37 | }, 38 | body: JSON.stringify({ from, to }), 39 | }, 40 | ); 41 | }; 42 | 43 | /** Retrieves JSON data for a specified page 44 | * 45 | * @experimental **UNSTABLE**: New API, yet to be vetted. 46 | * 47 | * @param project - The project name where all links will be replaced 48 | * @param from - The original link text to be replaced 49 | * @param to - The new link text to replace with 50 | * @param init - Additional configuration options 51 | * @returns A {@linkcode Result}<{@linkcode unknown}, {@linkcode Error}> containing: 52 | * - Success: The page data in JSON format 53 | * - Error: One of several possible errors: 54 | * - {@linkcode NotFoundError}: Page not found 55 | * - {@linkcode NotLoggedInError}: Authentication required 56 | * - {@linkcode NotMemberError}: User lacks access 57 | */ 58 | export const post = async ( 59 | project: string, 60 | from: string, 61 | to: string, 62 | init?: ExtendedOptions, 63 | ): Promise< 64 | ResponseOfEndpoint<{ 65 | 200: string; 66 | 404: NotFoundError; 67 | 401: NotLoggedInError; 68 | 403: NotMemberError; 69 | }, R> 70 | > => { 71 | let { csrf, fetch, ...init2 } = setDefaults(init ?? {}); 72 | 73 | if (!csrf) { 74 | const res = await get(init2); 75 | if (!res.ok) return res; 76 | csrf = (await res.json()).csrfToken; 77 | } 78 | 79 | return fetch( 80 | makePostRequest(project, from, to, { csrf, ...init2 }), 81 | ) as Promise< 82 | ResponseOfEndpoint<{ 83 | 200: string; 84 | 404: NotFoundError; 85 | 401: NotLoggedInError; 86 | 403: NotMemberError; 87 | }, R> 88 | >; 89 | }; 90 | -------------------------------------------------------------------------------- /api/pages/project/search.ts: -------------------------------------------------------------------------------- 1 | export * as query from "./search/query.ts"; 2 | export * as titles from "./search/titles.ts"; 3 | -------------------------------------------------------------------------------- /api/pages/project/search/query.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | NotFoundError, 3 | NotLoggedInError, 4 | NotMemberError, 5 | SearchResult, 6 | } from "@cosense/types/rest"; 7 | import type { ResponseOfEndpoint } from "../../../../targeted_response.ts"; 8 | import { type BaseOptions, setDefaults } from "../../../../util.ts"; 9 | import { cookie } from "../../../../rest/auth.ts"; 10 | 11 | /** Constructs a request for the `/api/pages/:project/search/query` endpoint 12 | * 13 | * @experimental **UNSTABLE**: New API, yet to be vetted. 14 | * 15 | * @param project The name of the project to search within 16 | * @param query The search query string to match against pages 17 | * @param options - Additional configuration options 18 | * @returns A {@linkcode Request} object for fetching page data 19 | */ 20 | export const makeGetRequest = ( 21 | project: string, 22 | query: string, 23 | options?: BaseOptions, 24 | ): Request => { 25 | const { sid, baseURL } = setDefaults(options ?? {}); 26 | 27 | return new Request( 28 | `${baseURL}api/pages/${project}/search/query?q=${ 29 | encodeURIComponent(query) 30 | }`, 31 | sid ? { headers: { Cookie: cookie(sid) } } : undefined, 32 | ); 33 | }; 34 | 35 | /** Search for pages within a specific project 36 | * 37 | * @experimental **UNSTABLE**: New API, yet to be vetted. 38 | * 39 | * @param project The name of the project to search within 40 | * @param query The search query string to match against pages 41 | * @param options Additional configuration options for the request 42 | * @returns A {@linkcode Response} object containing the search results 43 | */ 44 | export const get = ( 45 | project: string, 46 | query: string, 47 | options?: BaseOptions, 48 | ): Promise< 49 | ResponseOfEndpoint<{ 50 | 200: SearchResult; 51 | 404: NotFoundError; 52 | 401: NotLoggedInError; 53 | 403: NotMemberError; 54 | 422: { message: string }; 55 | }, R> 56 | > => 57 | setDefaults(options ?? {}).fetch( 58 | makeGetRequest(project, query, options), 59 | ) as Promise< 60 | ResponseOfEndpoint<{ 61 | 200: SearchResult; 62 | 404: NotFoundError; 63 | 401: NotLoggedInError; 64 | 403: NotMemberError; 65 | 422: { message: string }; 66 | }, R> 67 | >; 68 | -------------------------------------------------------------------------------- /api/pages/project/search/titles.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | NotFoundError, 3 | NotLoggedInError, 4 | NotMemberError, 5 | SearchedTitle, 6 | } from "@cosense/types/rest"; 7 | import type { ResponseOfEndpoint } from "../../../../targeted_response.ts"; 8 | import { type BaseOptions, setDefaults } from "../../../../util.ts"; 9 | import { cookie } from "../../../../rest/auth.ts"; 10 | import { 11 | type HTTPError, 12 | makeError, 13 | makeHTTPError, 14 | type TypedError, 15 | } from "../../../../error.ts"; 16 | 17 | /** 18 | * Options for {@linkcode get} 19 | * 20 | * @experimental **UNSTABLE**: New API, yet to be vetted. 21 | */ 22 | export interface GetLinksOptions 23 | extends BaseOptions { 24 | /** ID indicating the next list of links */ 25 | followingId?: string; 26 | } 27 | 28 | /** Create a request to `GET /api/pages/:project/search/titles` 29 | * 30 | * @experimental **UNSTABLE**: New API, yet to be vetted. 31 | * 32 | * @param project The project to get the links from 33 | * @param options - Additional configuration options 34 | * @returns A {@linkcode Request} object for fetching link data 35 | */ 36 | export const makeGetRequest = ( 37 | project: string, 38 | options?: GetLinksOptions, 39 | ): Request => { 40 | const { sid, baseURL, followingId } = setDefaults(options ?? {}); 41 | 42 | return new Request( 43 | `${baseURL}api/pages/${project}/search/titles${ 44 | followingId ? `?followingId=${followingId}` : "" 45 | }`, 46 | sid ? { headers: { Cookie: cookie(sid) } } : undefined, 47 | ); 48 | }; 49 | 50 | /** Retrieve link data from a specified Scrapbox project 51 | * 52 | * @experimental **UNSTABLE**: New API, yet to be vetted. 53 | * 54 | * This function fetches link data from a project, supporting pagination through 55 | * the {@linkcode GetLinksOptions.followingId} parameter. It returns both the link data and the next 56 | * followingId for subsequent requests. 57 | * 58 | * @param project The project to retrieve link data from 59 | * @param options Additional configuration options for the request 60 | * @returns A {@linkcode Response} object containing the link data 61 | */ 62 | export const get = ( 63 | project: string, 64 | options?: GetLinksOptions, 65 | ): Promise< 66 | ResponseOfEndpoint<{ 67 | 200: SearchedTitle[]; 68 | 404: NotFoundError; 69 | 401: NotLoggedInError; 70 | 403: NotMemberError; 71 | 422: { message: string }; 72 | }, R> 73 | > => 74 | setDefaults(options ?? {}).fetch( 75 | makeGetRequest(project, options), 76 | ) as Promise< 77 | ResponseOfEndpoint<{ 78 | 200: SearchedTitle[]; 79 | 404: NotFoundError; 80 | 401: NotLoggedInError; 81 | 403: NotMemberError; 82 | 422: { message: string }; 83 | }, R> 84 | >; 85 | 86 | /** Retrieve all link data from a specified project one by one 87 | * 88 | * @experimental **UNSTABLE**: New API, yet to be vetted. 89 | * 90 | * @param project The project name to list pages from 91 | * @param options Additional configuration options for the request 92 | * @returns An async generator that yields each link data 93 | * @throws {TypedError<"NotLoggedInError" | "NotMemberError" | "NotFoundError" | "InvalidFollowingIdError"> | HTTPError} 94 | */ 95 | export async function* list( 96 | project: string, 97 | options?: GetLinksOptions, 98 | ): AsyncGenerator { 99 | let followingId = options?.followingId ?? ""; 100 | do { 101 | const response = await get(project, { ...options, followingId }); 102 | switch (response.status) { 103 | case 200: 104 | break; 105 | case 401: 106 | case 403: 107 | case 404: { 108 | const error = await response.json(); 109 | throw makeError(error.name, error.message) satisfies TypedError< 110 | "NotLoggedInError" | "NotMemberError" | "NotFoundError" 111 | >; 112 | } 113 | case 422: 114 | throw makeError( 115 | "InvalidFollowingIdError", 116 | (await response.json()).message, 117 | ) satisfies TypedError< 118 | "InvalidFollowingIdError" 119 | >; 120 | default: 121 | throw makeHTTPError(response) satisfies HTTPError; 122 | } 123 | const titles = await response.json(); 124 | yield* titles; 125 | followingId = response.headers.get("X-following-id") ?? ""; 126 | } while (followingId); 127 | } 128 | -------------------------------------------------------------------------------- /api/pages/project/title.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | NotFoundError, 3 | NotLoggedInError, 4 | NotMemberError, 5 | Page, 6 | } from "@cosense/types/rest"; 7 | import type { ResponseOfEndpoint } from "../../../targeted_response.ts"; 8 | import { type BaseOptions, setDefaults } from "../../../util.ts"; 9 | import { encodeTitleURI } from "../../../title.ts"; 10 | import { cookie } from "../../../rest/auth.ts"; 11 | 12 | /** 13 | * Options for {@linkcode getPage} 14 | * 15 | * @experimental **UNSTABLE**: New API, yet to be vetted. 16 | */ 17 | export interface GetPageOption 18 | extends BaseOptions { 19 | /** use `followRename` */ 20 | followRename?: boolean; 21 | 22 | /** project ids to get External links */ 23 | projects?: string[]; 24 | } 25 | 26 | /** Constructs a request for the `/api/pages/:project/:title` endpoint 27 | * 28 | * @experimental **UNSTABLE**: New API, yet to be vetted. 29 | * 30 | * @param project The project name containing the desired page 31 | * @param title The page title to retrieve (case insensitive) 32 | * @param options - Additional configuration options 33 | * @returns A {@linkcode Request} object for fetching page data 34 | */ 35 | export const makeGetRequest = ( 36 | project: string, 37 | title: string, 38 | options?: GetPageOption, 39 | ): Request => { 40 | const { sid, baseURL, followRename, projects } = setDefaults(options ?? {}); 41 | 42 | const params = new URLSearchParams([ 43 | ["followRename", `${followRename ?? true}`], 44 | ...(projects?.map?.((id) => ["projects", id]) ?? []), 45 | ]); 46 | 47 | return new Request( 48 | `${baseURL}api/pages/${project}/${encodeTitleURI(title)}?${params}`, 49 | sid ? { headers: { Cookie: cookie(sid) } } : undefined, 50 | ); 51 | }; 52 | 53 | /** Retrieves JSON data for a specified page 54 | * 55 | * @experimental **UNSTABLE**: New API, yet to be vetted. 56 | * 57 | * @param project The project name containing the desired page 58 | * @param title The page title to retrieve (case insensitive) 59 | * @param options Additional configuration options for the request 60 | * @returns A {@linkcode Result}<{@linkcode unknown}, {@linkcode Error}> containing: 61 | * - Success: The page data in JSON format 62 | * - Error: One of several possible errors: 63 | * - {@linkcode NotFoundError}: Page not found 64 | * - {@linkcode NotLoggedInError}: Authentication required 65 | * - {@linkcode NotMemberError}: User lacks access 66 | */ 67 | export const get = ( 68 | project: string, 69 | title: string, 70 | options?: GetPageOption, 71 | ): Promise< 72 | ResponseOfEndpoint<{ 73 | 200: Page; 74 | 404: NotFoundError; 75 | 401: NotLoggedInError; 76 | 403: NotMemberError; 77 | }, R> 78 | > => 79 | setDefaults(options ?? {}).fetch( 80 | makeGetRequest(project, title, options), 81 | ) as Promise< 82 | ResponseOfEndpoint<{ 83 | 200: Page; 84 | 404: NotFoundError; 85 | 401: NotLoggedInError; 86 | 403: NotMemberError; 87 | }, R> 88 | >; 89 | 90 | export * as text from "./title/text.ts"; 91 | export * as icon from "./title/icon.ts"; 92 | -------------------------------------------------------------------------------- /api/pages/project/title/icon.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | NotFoundError, 3 | NotLoggedInError, 4 | NotMemberError, 5 | } from "@cosense/types/rest"; 6 | import type { ResponseOfEndpoint } from "../../../../targeted_response.ts"; 7 | import { type BaseOptions, setDefaults } from "../../../../util.ts"; 8 | import { encodeTitleURI } from "../../../../title.ts"; 9 | import { cookie } from "../../../../rest/auth.ts"; 10 | 11 | /** 12 | * Options for {@linkcode get} 13 | * 14 | * @experimental **UNSTABLE**: New API, yet to be vetted. 15 | */ 16 | export interface GetIconOption 17 | extends BaseOptions { 18 | /** use `followRename` */ 19 | followRename?: boolean; 20 | } 21 | 22 | /** Constructs a request for the `/api/pages/:project/:title/icon` endpoint 23 | * 24 | * @experimental **UNSTABLE**: New API, yet to be vetted. 25 | * 26 | * @param project The project name containing the desired page 27 | * @param title The page title to retrieve (case insensitive) 28 | * @param options - Additional configuration options 29 | * @returns A {@linkcode Request} object for fetching page data 30 | */ 31 | export const makeGetRequest = ( 32 | project: string, 33 | title: string, 34 | options?: GetIconOption, 35 | ): Request => { 36 | const { sid, baseURL, followRename } = setDefaults(options ?? {}); 37 | 38 | return new Request( 39 | `${baseURL}api/pages/${project}/${ 40 | encodeTitleURI(title) 41 | }/icon?followRename=${followRename ?? true}`, 42 | sid ? { headers: { Cookie: cookie(sid) } } : undefined, 43 | ); 44 | }; 45 | 46 | /** Retrieves a specified page image 47 | * 48 | * @experimental **UNSTABLE**: New API, yet to be vetted. 49 | * 50 | * @param project The project name containing the desired page 51 | * @param title The page title to retrieve (case insensitive) 52 | * @param options Additional configuration options for the request 53 | * @returns A {@linkcode Response} object containing the page image 54 | */ 55 | export const get = ( 56 | project: string, 57 | title: string, 58 | options?: GetIconOption, 59 | ): Promise< 60 | ResponseOfEndpoint<{ 61 | 200: Blob; 62 | 404: NotFoundError; 63 | 401: NotLoggedInError; 64 | 403: NotMemberError; 65 | }, R> 66 | > => 67 | setDefaults(options ?? {}).fetch( 68 | makeGetRequest(project, title, options), 69 | ) as Promise< 70 | ResponseOfEndpoint<{ 71 | 200: Blob; 72 | 404: NotFoundError; 73 | 401: NotLoggedInError; 74 | 403: NotMemberError; 75 | }, R> 76 | >; 77 | -------------------------------------------------------------------------------- /api/pages/project/title/text.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | NotFoundError, 3 | NotLoggedInError, 4 | NotMemberError, 5 | } from "@cosense/types/rest"; 6 | import type { ResponseOfEndpoint } from "../../../../targeted_response.ts"; 7 | import { type BaseOptions, setDefaults } from "../../../../util.ts"; 8 | import { encodeTitleURI } from "../../../../title.ts"; 9 | import { cookie } from "../../../../rest/auth.ts"; 10 | 11 | /** 12 | * Options for {@linkcode get} 13 | * 14 | * @experimental **UNSTABLE**: New API, yet to be vetted. 15 | */ 16 | export interface GetTextOption 17 | extends BaseOptions { 18 | /** use `followRename` */ 19 | followRename?: boolean; 20 | } 21 | 22 | /** Constructs a request for the `/api/pages/:project/:title/text` endpoint 23 | * 24 | * @experimental **UNSTABLE**: New API, yet to be vetted. 25 | * 26 | * @param project The project name containing the desired page 27 | * @param title The page title to retrieve (case insensitive) 28 | * @param options - Additional configuration options 29 | * @returns A {@linkcode Request} object for fetching page data 30 | */ 31 | export const makeGetRequest = ( 32 | project: string, 33 | title: string, 34 | options?: GetTextOption, 35 | ): Request => { 36 | const { sid, baseURL, followRename } = setDefaults(options ?? {}); 37 | 38 | return new Request( 39 | `${baseURL}api/pages/${project}/${ 40 | encodeTitleURI(title) 41 | }/text?followRename=${followRename ?? true}`, 42 | sid ? { headers: { Cookie: cookie(sid) } } : undefined, 43 | ); 44 | }; 45 | 46 | /** Retrieves a specified page text 47 | * 48 | * @experimental **UNSTABLE**: New API, yet to be vetted. 49 | * 50 | * @param project The project name containing the desired page 51 | * @param title The page title to retrieve (case insensitive) 52 | * @param options Additional configuration options for the request 53 | * @returns A {@linkcode Response} object containing the page text 54 | */ 55 | export const get = ( 56 | project: string, 57 | title: string, 58 | options?: GetTextOption, 59 | ): Promise< 60 | ResponseOfEndpoint<{ 61 | 200: string; 62 | 404: NotFoundError; 63 | 401: NotLoggedInError; 64 | 403: NotMemberError; 65 | }, R> 66 | > => 67 | setDefaults(options ?? {}).fetch( 68 | makeGetRequest(project, title, options), 69 | ) as Promise< 70 | ResponseOfEndpoint<{ 71 | 200: string; 72 | 404: NotFoundError; 73 | 401: NotLoggedInError; 74 | 403: NotMemberError; 75 | }, R> 76 | >; 77 | -------------------------------------------------------------------------------- /api/projects.ts: -------------------------------------------------------------------------------- 1 | export * as project from "./projects/project.ts"; 2 | -------------------------------------------------------------------------------- /api/projects/project.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | MemberProject, 3 | NotFoundError, 4 | NotLoggedInError, 5 | NotMemberError, 6 | NotMemberProject, 7 | } from "@cosense/types/rest"; 8 | import type { ResponseOfEndpoint } from "../../targeted_response.ts"; 9 | import { type BaseOptions, setDefaults } from "../../util.ts"; 10 | import { cookie } from "../../rest/auth.ts"; 11 | 12 | /** Create a request to `GET /api/projects/:project` 13 | * 14 | * @experimental **UNSTABLE**: New API, yet to be vetted. 15 | * 16 | * @param project - Project name to retrieve information for 17 | * @param options - Additional configuration options 18 | * @returns A {@linkcode Request} object for fetching project data 19 | */ 20 | export const makeGetRequest = ( 21 | project: string, 22 | options?: BaseOptions, 23 | ): Request => { 24 | const { sid, baseURL } = setDefaults(options ?? {}); 25 | 26 | return new Request( 27 | `${baseURL}api/projects/${project}`, 28 | sid ? { headers: { Cookie: cookie(sid) } } : undefined, 29 | ); 30 | }; 31 | 32 | /** Get detailed information about a Scrapbox project 33 | * 34 | * This function retrieves detailed information about a project, including its 35 | * access level, settings, and metadata. The returned data type depends on 36 | * whether the user has member access to the project. 37 | * 38 | * @experimental **UNSTABLE**: New API, yet to be vetted. 39 | * 40 | * @param project - Project name to retrieve information for 41 | * @param options Additional configuration options for the request 42 | * @returns A {@linkcode Response} object containing the project data 43 | */ 44 | export const get = ( 45 | project: string, 46 | options?: BaseOptions, 47 | ): Promise< 48 | ResponseOfEndpoint<{ 49 | 200: MemberProject | NotMemberProject; 50 | 404: NotFoundError; 51 | 401: NotLoggedInError; 52 | 403: NotMemberError; 53 | }, R> 54 | > => 55 | setDefaults(options ?? {}).fetch( 56 | makeGetRequest(project, options), 57 | ) as Promise< 58 | ResponseOfEndpoint<{ 59 | 200: MemberProject | NotMemberProject; 60 | 404: NotFoundError; 61 | 401: NotLoggedInError; 62 | 403: NotMemberError; 63 | }, R> 64 | >; 65 | -------------------------------------------------------------------------------- /api/users.ts: -------------------------------------------------------------------------------- 1 | export * as me from "./users/me.ts"; 2 | -------------------------------------------------------------------------------- /api/users/me.ts: -------------------------------------------------------------------------------- 1 | import type { GuestUser, MemberUser } from "@cosense/types/rest"; 2 | import type { ResponseOfEndpoint } from "../../targeted_response.ts"; 3 | import { type BaseOptions, setDefaults } from "../../util.ts"; 4 | import { cookie } from "../../rest/auth.ts"; 5 | 6 | /** Constructs a request for the `/api/users/me endpoint` 7 | * 8 | * This endpoint retrieves the current user's profile information, 9 | * which can be either a {@linkcode MemberUser} or {@linkcode GuestUser} profile. 10 | * 11 | * @experimental **UNSTABLE**: New API, yet to be vetted. 12 | * 13 | * @param init - Options including `connect.sid` (session ID) and other configuration 14 | * @returns A {@linkcode Request} object for fetching user profile data 15 | */ 16 | export const makeGetRequest = ( 17 | init?: BaseOptions, 18 | ): Request => { 19 | const { sid, baseURL } = setDefaults(init ?? {}); 20 | return new Request( 21 | `${baseURL}api/users/me`, 22 | sid ? { headers: { Cookie: cookie(sid) } } : undefined, 23 | ); 24 | }; 25 | 26 | /** get the user profile 27 | * 28 | * @experimental **UNSTABLE**: New API, yet to be vetted. 29 | * 30 | * @param init - Options including `connect.sid` (session ID) and other configuration 31 | * @returns A {@linkcode Response} object containing the user profile data 32 | */ 33 | export const get = ( 34 | init?: BaseOptions, 35 | ): Promise< 36 | ResponseOfEndpoint<{ 200: MemberUser | GuestUser }, R> 37 | > => 38 | setDefaults(init ?? {}).fetch( 39 | makeGetRequest(init), 40 | ) as Promise>; 41 | -------------------------------------------------------------------------------- /browser/dom/__snapshots__/extractCodeFiles.test.ts.snap: -------------------------------------------------------------------------------- 1 | export const snapshot = {}; 2 | 3 | snapshot[`extractCodeFiles 1`] = ` 4 | Map(5) { 5 | "main.cpp" => { 6 | blocks: [ 7 | { 8 | endId: "63b7b1261280f00000c9bc36", 9 | indent: 2, 10 | lines: [ 11 | "#include ", 12 | "", 13 | ], 14 | startId: "63b7b1261280f00000c9bc34", 15 | updated: 1672982822, 16 | }, 17 | { 18 | endId: "63b7b1261280f00000c9bc3c", 19 | indent: 2, 20 | lines: [ 21 | "int main() {", 22 | ' std::cout << "Hello, C++" << "from scrapbox.io" << std::endl;', 23 | "}", 24 | "", 25 | ], 26 | startId: "63b7b1261280f00000c9bc38", 27 | updated: 1672982822, 28 | }, 29 | ], 30 | filename: "main.cpp", 31 | lang: "cpp", 32 | }, 33 | "py" => { 34 | blocks: [ 35 | { 36 | endId: "63b7b1261280f00000c9bc2a", 37 | indent: 0, 38 | lines: [ 39 | 'print("Hello World!")', 40 | ], 41 | startId: "63b7b1261280f00000c9bc29", 42 | updated: 1672982822, 43 | }, 44 | ], 45 | filename: "py", 46 | lang: "py", 47 | }, 48 | "python" => { 49 | blocks: [ 50 | { 51 | endId: "63b7b1261280f00000c9bc31", 52 | indent: 1, 53 | lines: [ 54 | \`console.log("I'm JavaScript");\`, 55 | ], 56 | startId: "63b7b1261280f00000c9bc30", 57 | updated: 1672982822, 58 | }, 59 | ], 60 | filename: "python", 61 | lang: "js", 62 | }, 63 | "インデント.md" => { 64 | blocks: [ 65 | { 66 | endId: "63b7b1261280f00000c9bc2e", 67 | indent: 1, 68 | lines: [ 69 | "- インデント", 70 | " - インデント", 71 | ], 72 | startId: "63b7b1261280f00000c9bc2c", 73 | updated: 1672982822, 74 | }, 75 | ], 76 | filename: "インデント.md", 77 | lang: "md", 78 | }, 79 | "コードブロック.py" => { 80 | blocks: [ 81 | { 82 | endId: "63b7b1261280f00000c9bc27", 83 | indent: 0, 84 | lines: [ 85 | 'print("Hello World!")', 86 | ], 87 | startId: "63b7b1261280f00000c9bc26", 88 | updated: 1672982822, 89 | }, 90 | ], 91 | filename: "コードブロック.py", 92 | lang: "py", 93 | }, 94 | } 95 | `; 96 | -------------------------------------------------------------------------------- /browser/dom/_internal.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert"; 2 | import { decode, encode } from "./_internal.ts"; 3 | 4 | Deno.test("encode()", async (t) => { 5 | await t.step("should return 0 when options is undefined", () => { 6 | const result = encode(undefined); 7 | assertEquals(result, 0); 8 | }); 9 | 10 | await t.step("should return 1 when options.capture is true", () => { 11 | const options = { capture: true }; 12 | const result = encode(options); 13 | assertEquals(result, 1); 14 | }); 15 | 16 | await t.step("should return 2 when options.once is true", () => { 17 | const options = { once: true }; 18 | const result = encode(options); 19 | assertEquals(result, 2); 20 | }); 21 | 22 | await t.step("should return 4 when options.passive is true", () => { 23 | const options = { passive: true }; 24 | const result = encode(options); 25 | assertEquals(result, 4); 26 | }); 27 | 28 | await t.step("should return 7 when all options are true", () => { 29 | const options = { capture: true, once: true, passive: true }; 30 | const result = encode(options); 31 | assertEquals(result, 7); 32 | }); 33 | 34 | await t.step("should return 0 when options is false", () => { 35 | const result = encode(false); 36 | assertEquals(result, 0); 37 | }); 38 | 39 | await t.step("should return 1 when options is true", () => { 40 | const result = encode(true); 41 | assertEquals(result, 1); 42 | }); 43 | }); 44 | Deno.test("decode()", async (t) => { 45 | await t.step("should return undefined when encoded is 0", () => { 46 | const result = decode(0); 47 | assertEquals(result, undefined); 48 | }); 49 | 50 | await t.step("should return options with capture when encoded is 1", () => { 51 | const encoded = 1; 52 | const result = decode(encoded); 53 | assertEquals(result, { capture: true }); 54 | }); 55 | 56 | await t.step("should return options with once when encoded is 2", () => { 57 | const encoded = 2; 58 | const result = decode(encoded); 59 | assertEquals(result, { once: true }); 60 | }); 61 | 62 | await t.step("should return options with passive when encoded is 4", () => { 63 | const encoded = 4; 64 | const result = decode(encoded); 65 | assertEquals(result, { passive: true }); 66 | }); 67 | 68 | await t.step("should return options with all flags when encoded is 7", () => { 69 | const encoded = 7; 70 | const result = decode(encoded); 71 | assertEquals(result, { capture: true, once: true, passive: true }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /browser/dom/_internal.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Encodes {@linkcode AddEventListenerOptions} into a number for equality comparison. 3 | * This function converts the options object into a single number where each bit 4 | * represents a specific option (capture, once, passive). 5 | */ 6 | export const encode = ( 7 | options: AddEventListenerOptions | boolean | undefined, 8 | ): number => { 9 | if (options === undefined) return 0; 10 | if (typeof options === "boolean") return Number(options); 11 | // Encode each flag into its corresponding bit position 12 | return ( 13 | (options.capture ? 1 : 0) | 14 | (options.once ? 2 : 0) | 15 | (options.passive ? 4 : 0) 16 | ); 17 | }; 18 | /** 19 | * Decodes a number back into {@linkcode AddEventListenerOptions} object. 20 | * Each bit in the encoded number represents a specific option: 21 | * 22 | * - `capture`: `0b001` (bit 0) 23 | * - `once`: `0b010` (bit 1) 24 | * - `passive`: `0b100` (bit 2) 25 | * - `0`: returns `undefined` 26 | * 27 | * @param encoded The number containing encoded {@linkcode AddEventListenerOptions} flags 28 | * @returns An {@linkcode AddEventListenerOptions} object or {@linkcode undefined} if encoded value is 0 29 | */ 30 | export const decode = ( 31 | encoded: number, 32 | ): AddEventListenerOptions | undefined => { 33 | if (encoded === 0) return; 34 | const options: AddEventListenerOptions = {}; 35 | if (encoded & 1) options.capture = true; 36 | if (encoded & 2) options.once = true; 37 | if (encoded & 4) options.passive = true; 38 | 39 | return options; 40 | }; 41 | -------------------------------------------------------------------------------- /browser/dom/cache.ts: -------------------------------------------------------------------------------- 1 | /** Retrieves the latest response from the cache storage managed by scrapbox.io 2 | * 3 | * This function searches through the cache storage in reverse chronological order 4 | * to find the most recent cached response for a given request. 5 | * 6 | * > [!NOTE] 7 | * > Implementation inspired by Scrapbox's ServiceWorker and Cache usage pattern. 8 | * > For details, see the article "ServiceWorker and Cache Usage in Scrapbox" {@see https://scrapbox.io/daiiz/ScrapboxでのServiceWorkerとCacheの活用#5d2efaffadf4e70000651173} 9 | * 10 | * @param request - The {@linkcode Request} to find a cached response for 11 | * @param options - {@linkcode CacheQueryOptions} (e.g., to ignore search params) 12 | * @returns A {@linkcode Response} if found, otherwise {@linkcode undefined} 13 | */ 14 | export const findLatestCache = async ( 15 | request: Request, 16 | options?: CacheQueryOptions, 17 | ): Promise => { 18 | const cacheNames = await globalThis.caches.keys(); 19 | 20 | for (const date of cacheNames.sort().reverse()) { 21 | const cache = await caches.open(date); 22 | const res = await cache.match(request, options); 23 | if (res) return res; 24 | } 25 | }; 26 | 27 | /** Saves a response to the REST API cache storage managed by scrapbox.io 28 | * 29 | * @param request The {@linkcode Request} to associate with the cached response 30 | * @param response The {@linkcode Response} to cache 31 | */ 32 | export const saveApiCache = async ( 33 | request: Request, 34 | response: Response, 35 | ): Promise => { 36 | const res = response.clone(); 37 | const cache = await caches.open(generateCacheName(new Date())); 38 | return await cache.put(request, res); 39 | }; 40 | 41 | export const generateCacheName = (date: Date): string => 42 | `api-${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, "0")}-${ 43 | `${date.getDate()}`.padStart(2, "0") 44 | }`; 45 | 46 | /** Executes prefetch operations for specified API URLs 47 | * 48 | * Prefetched data is stored in two locations: 49 | * 1. `"prefetch"` cache - temporary storage, cleared after first use 50 | * 2. `"api-yyyy-MM-dd"` cache - date-based persistent storage 51 | * 52 | * > [!NOTE] 53 | * > Throws an exception if the network connection is slow 54 | * 55 | * @param urls List of API URLs to prefetch 56 | */ 57 | export const prefetch = (urls: (string | URL)[]): Promise => 58 | postMessage({ 59 | title: "prefetch", 60 | body: { urls: urls.map((url) => url.toString()) }, 61 | }); 62 | 63 | /** Requests a cache update for the specified API 64 | * 65 | * Updates are processed one at a time with a 10-second interval between each update 66 | * 67 | * @param url The URL of the API to cache 68 | */ 69 | export const fetchApiCache = (url: string): Promise => 70 | postMessage({ title: "fetchApiCache", body: { url } }); 71 | 72 | const postMessage = ( 73 | data: { title: string; body: T }, 74 | ): Promise => { 75 | const { controller } = navigator.serviceWorker; 76 | if (!controller) { 77 | const error = new Error(); 78 | error.name = "ServiceWorkerNotActiveYetError"; 79 | error.message = "Service worker is not active yet"; 80 | throw error; 81 | } 82 | 83 | return new Promise((resolve, reject) => { 84 | const channel = new MessageChannel(); 85 | channel.port1.addEventListener( 86 | "message", 87 | (event) => 88 | event.data?.error ? reject(event.data.error) : resolve(event.data), 89 | ); 90 | controller.postMessage(data, [channel.port2]); 91 | }); 92 | }; 93 | -------------------------------------------------------------------------------- /browser/dom/caret.ts: -------------------------------------------------------------------------------- 1 | import { textInput } from "./dom.ts"; 2 | 3 | /** Position information within the editor 4 | * 5 | * @see {@linkcode Range} for selection range information 6 | */ 7 | export interface Position { 8 | /** Line number (1-based) */ line: number; 9 | /** Character offset within the line (0-based) */ char: number; 10 | } 11 | 12 | /** Represents a text selection range in the editor 13 | * 14 | * When no text is selected, {@linkcode start} and {@linkcode end} positions are the same (cursor position) 15 | * 16 | * @see {@linkcode Position} for position type details 17 | */ 18 | export interface Range { 19 | /** Starting position of the selection */ start: Position; 20 | /** Ending position of the selection */ end: Position; 21 | } 22 | 23 | /** Cursor information contained within the React Component that builds `#text-input` */ 24 | export interface CaretInfo { 25 | /** Current cursor position */ position: Position; 26 | /** Currently selected text */ selectedText: string; 27 | /** Range of the current selection */ selectionRange: Range; 28 | } 29 | 30 | interface ReactFiber { 31 | return: { 32 | return: { 33 | stateNode: { 34 | props: CaretInfo; 35 | }; 36 | }; 37 | }; 38 | } 39 | 40 | /** Retrieves the current cursor position and text selection information 41 | * 42 | * @returns A {@linkcode CaretPosition} containing cursor position and text selection information 43 | * @throws {@linkcode Error} when: 44 | * - `#text-input` element is not found 45 | * - React Component's internal properties are not found 46 | * @see {@linkcode CaretInfo} for return type details 47 | */ 48 | export const caret = (): CaretInfo => { 49 | const textarea = textInput(); 50 | if (!textarea) { 51 | throw Error(`#text-input is not found.`); 52 | } 53 | 54 | const reactKey = Object.keys(textarea) 55 | .find((key) => key.startsWith("__reactFiber")); 56 | if (!reactKey) { 57 | throw Error( 58 | 'div.cursor must has the property whose name starts with "__reactFiber"', 59 | ); 60 | } 61 | 62 | // @ts-ignore Forcefully treating DOM element as an object to access React internals 63 | return (textarea[ 64 | reactKey 65 | ] as ReactFiber).return.return.stateNode.props; 66 | }; 67 | -------------------------------------------------------------------------------- /browser/dom/click.ts: -------------------------------------------------------------------------------- 1 | import { delay } from "@std/async/delay"; 2 | 3 | /** the options for `click()` */ 4 | export interface ClickOptions { 5 | button?: number; 6 | X: number; 7 | Y: number; 8 | shiftKey?: boolean; 9 | ctrlKey?: boolean; 10 | altKey?: boolean; 11 | } 12 | 13 | /** Emulate click event sequences */ 14 | export const click = async ( 15 | element: HTMLElement, 16 | options: ClickOptions, 17 | ): Promise => { 18 | const mouseOptions: MouseEventInit = { 19 | button: options.button ?? 0, 20 | clientX: options.X, 21 | clientY: options.Y, 22 | bubbles: true, 23 | cancelable: true, 24 | shiftKey: options.shiftKey, 25 | ctrlKey: options.ctrlKey, 26 | altKey: options.altKey, 27 | view: window, 28 | }; 29 | element.dispatchEvent(new MouseEvent("mousedown", mouseOptions)); 30 | element.dispatchEvent(new MouseEvent("mouseup", mouseOptions)); 31 | element.dispatchEvent(new MouseEvent("click", mouseOptions)); 32 | 33 | // Wait for Scrapbox's React event handlers to complete 34 | // Note: 10ms delay is determined empirically to ensure reliable event processing 35 | await delay(10); 36 | }; 37 | 38 | export interface HoldDownOptions extends ClickOptions { 39 | holding?: number; 40 | } 41 | 42 | /** Emulate long tap event sequence */ 43 | export const holdDown = async ( 44 | element: HTMLElement, 45 | options: HoldDownOptions, 46 | ): Promise => { 47 | const touch = new Touch({ 48 | identifier: 0, 49 | target: element, 50 | clientX: options.X, 51 | clientY: options.Y, 52 | pageX: options.X + globalThis.scrollX, 53 | pageY: options.Y + globalThis.scrollY, 54 | }); 55 | const mouseOptions = { 56 | button: options.button ?? 0, 57 | clientX: options.X, 58 | clientY: options.Y, 59 | changedTouches: [touch], 60 | touches: [touch], 61 | bubbles: true, 62 | cancelable: true, 63 | shiftKey: options.shiftKey, 64 | ctrlKey: options.ctrlKey, 65 | altKey: options.altKey, 66 | view: window, 67 | }; 68 | element.dispatchEvent(new TouchEvent("touchstart", mouseOptions)); 69 | element.dispatchEvent(new MouseEvent("mousedown", mouseOptions)); 70 | await delay(options.holding ?? 1000); 71 | element.dispatchEvent(new MouseEvent("mouseup", mouseOptions)); 72 | element.dispatchEvent(new TouchEvent("touchend", mouseOptions)); 73 | element.dispatchEvent(new MouseEvent("click", mouseOptions)); 74 | 75 | // Wait for Scrapbox's React event handlers to complete 76 | // Note: 10ms delay is determined empirically to ensure reliable event processing 77 | await delay(10); 78 | }; 79 | -------------------------------------------------------------------------------- /browser/dom/cursor.ts: -------------------------------------------------------------------------------- 1 | import { takeStores } from "./stores.ts"; 2 | import type { Cursor } from "./cursor.d.ts"; 3 | export type { Cursor }; 4 | 5 | export const takeCursor = (): Cursor => takeStores().cursor; 6 | -------------------------------------------------------------------------------- /browser/dom/dom.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ensureHTMLAnchorElement, 3 | ensureHTMLDivElement, 4 | ensureHTMLTextAreaElement, 5 | } from "./ensure.ts"; 6 | 7 | export const editor = (): HTMLDivElement | undefined => 8 | checkDiv(document.getElementById("editor"), "div#editor"); 9 | export const lines = (): HTMLDivElement | undefined => 10 | checkDiv( 11 | document.getElementsByClassName("lines").item(0), 12 | "div.lines", 13 | ); 14 | export const computeLine = (): HTMLDivElement | undefined => 15 | checkDiv(document.getElementById("compute-line"), "div#compute-line"); 16 | export const cursorLine = (): HTMLDivElement | undefined => 17 | checkDiv( 18 | document.getElementsByClassName("cursor-line").item(0), 19 | "div.cursor-line", 20 | ); 21 | export const textInput = (): HTMLTextAreaElement | undefined => { 22 | const textarea = document.getElementById("text-input"); 23 | if (!textarea) return; 24 | ensureHTMLTextAreaElement(textarea, "textarea#text-input"); 25 | return textarea; 26 | }; 27 | export const cursor = (): HTMLDivElement | undefined => 28 | checkDiv( 29 | document.getElementsByClassName("cursor").item(0), 30 | "div.cursor", 31 | ); 32 | export const selections = (): HTMLDivElement | undefined => 33 | checkDiv( 34 | document.getElementsByClassName("selections")?.[0], 35 | "div.selections", 36 | ); 37 | export const grid = (): HTMLDivElement | undefined => 38 | checkDiv( 39 | document.getElementsByClassName("related-page-list clearfix")[0] 40 | ?.getElementsByClassName?.("grid")?.item(0), 41 | ".related-page-list.clearfix div.grid", 42 | ); 43 | export const popupMenu = (): HTMLDivElement | undefined => 44 | checkDiv( 45 | document.getElementsByClassName("popup-menu")?.[0], 46 | "div.popup-menu", 47 | ); 48 | export const pageMenu = (): HTMLDivElement | undefined => 49 | checkDiv( 50 | document.getElementsByClassName("page-menu")?.[0], 51 | "div.page-menu", 52 | ); 53 | export const pageInfoMenu = (): HTMLAnchorElement | undefined => 54 | checkAnchor( 55 | document.getElementById("page-info-menu"), 56 | "a#page-info-menu", 57 | ); 58 | export const pageEditMenu = (): HTMLAnchorElement | undefined => 59 | checkAnchor( 60 | document.getElementById("page-edit-menu"), 61 | "a#page-edit-menu", 62 | ); 63 | export const pageEditButtons = (): HTMLAnchorElement[] => 64 | Array.from( 65 | pageEditMenu()?.nextElementSibling?.getElementsByTagName?.("a") ?? [], 66 | ); 67 | export const randomJumpButton = (): HTMLAnchorElement | undefined => 68 | checkAnchor( 69 | document.getElementsByClassName("random-jump-button").item(0), 70 | "a#random-jump-button", 71 | ); 72 | export const pageCustomButtons = (): HTMLAnchorElement[] => 73 | Array.from(document.getElementsByClassName("page-menu-extension")).flatMap( 74 | (div) => { 75 | const a = div.getElementsByTagName("a").item(0); 76 | return a ? [a] : []; 77 | }, 78 | ); 79 | export const statusBar = (): HTMLDivElement | undefined => 80 | checkDiv( 81 | document.getElementsByClassName("status-bar")?.[0], 82 | "div.status-bar", 83 | ); 84 | 85 | const checkDiv = (div: Element | null, name: string) => { 86 | if (!div) return; 87 | ensureHTMLDivElement(div, name); 88 | return div; 89 | }; 90 | 91 | const checkAnchor = (a: Element | null, name: string) => { 92 | if (!a) return; 93 | ensureHTMLAnchorElement(a, name); 94 | return a; 95 | }; 96 | -------------------------------------------------------------------------------- /browser/dom/edit.ts: -------------------------------------------------------------------------------- 1 | import { goHead, goLine } from "./motion.ts"; 2 | import { press } from "./press.ts"; 3 | import { getLineCount } from "./node.ts"; 4 | import { range } from "@core/iterutil/range"; 5 | import { textInput } from "./dom.ts"; 6 | import { isArray } from "@core/unknownutil/is/array"; 7 | import { isNumber } from "@core/unknownutil/is/number"; 8 | import { isString } from "@core/unknownutil/is/string"; 9 | import type { Scrapbox } from "@cosense/types/userscript"; 10 | declare const scrapbox: Scrapbox; 11 | 12 | export const undo = (count = 1): void => { 13 | for (const _ of range(1, count)) { 14 | press("z", { ctrlKey: true }); 15 | } 16 | }; 17 | export const redo = (count = 1): void => { 18 | for (const _ of range(1, count)) { 19 | press("z", { shiftKey: true, ctrlKey: true }); 20 | } 21 | }; 22 | 23 | export const insertIcon = (count = 1): void => { 24 | for (const _ of range(1, count)) { 25 | press("i", { ctrlKey: true }); 26 | } 27 | }; 28 | 29 | export const insertTimestamp = (index = 1): void => { 30 | for (const _ of range(1, index)) { 31 | press("t", { altKey: true }); 32 | } 33 | }; 34 | 35 | export const insertLine = async ( 36 | lineNo: number, 37 | text: string, 38 | ): Promise => { 39 | await goLine(lineNo); 40 | goHead(); 41 | press("Enter"); 42 | press("ArrowUp"); 43 | await insertText(text); 44 | }; 45 | 46 | export const replaceLines = async ( 47 | start: number, 48 | end: number, 49 | text: string, 50 | ): Promise => { 51 | await goLine(start); 52 | goHead(); 53 | for (const _ of range(start, end)) { 54 | press("ArrowDown", { shiftKey: true }); 55 | } 56 | press("End", { shiftKey: true }); 57 | await insertText(text); 58 | }; 59 | 60 | export const deleteLines = async ( 61 | from: number | string | string[], 62 | count = 1, 63 | ): Promise => { 64 | if (isNumber(from)) { 65 | if (getLineCount() === from + count) { 66 | await goLine(from - 1); 67 | press("ArrowRight", { shiftKey: true }); 68 | } else { 69 | await goLine(from); 70 | goHead(); 71 | } 72 | for (let i = 0; i < count; i++) { 73 | press("ArrowRight", { shiftKey: true }); 74 | press("End", { shiftKey: true }); 75 | } 76 | press("ArrowRight", { shiftKey: true }); 77 | press("Delete"); 78 | return; 79 | } 80 | if (isString(from) || isArray(from)) { 81 | const ids = Array.isArray(from) ? from : [from]; 82 | for (const id of ids) { 83 | await goLine(id); 84 | press("Home", { shiftKey: true }); 85 | press("Home", { shiftKey: true }); 86 | press("Backspace"); 87 | press("Backspace"); 88 | } 89 | return; 90 | } 91 | throw new TypeError( 92 | `The type of value must be number | string | string[] but actual is "${typeof from}"`, 93 | ); 94 | }; 95 | 96 | export const indentLines = (count = 1): void => { 97 | for (const _ of range(1, count)) { 98 | press("ArrowRight", { ctrlKey: true }); 99 | } 100 | }; 101 | export const outdentLines = (count = 1): void => { 102 | for (const _ of range(1, count)) { 103 | press("ArrowLeft", { ctrlKey: true }); 104 | } 105 | }; 106 | export const moveLines = (count: number): void => { 107 | if (count > 0) { 108 | downLines(count); 109 | } else { 110 | upLines(-count); 111 | } 112 | }; 113 | // Move selected lines to the position after the target line number 114 | export const moveLinesBefore = (from: number, to: number): void => { 115 | const count = to - from; 116 | if (count >= 0) { 117 | downLines(count); 118 | } else { 119 | upLines(-count - 1); 120 | } 121 | }; 122 | export const upLines = (count = 1): void => { 123 | for (const _ of range(1, count)) { 124 | press("ArrowUp", { ctrlKey: true }); 125 | } 126 | }; 127 | export const downLines = (count = 1): void => { 128 | for (const _ of range(1, count)) { 129 | press("ArrowDown", { ctrlKey: true }); 130 | } 131 | }; 132 | 133 | export const indentBlocks = (count = 1): void => { 134 | for (const _ of range(1, count)) { 135 | press("ArrowRight", { altKey: true }); 136 | } 137 | }; 138 | export const outdentBlocks = (count = 1): void => { 139 | for (const _ of range(1, count)) { 140 | press("ArrowLeft", { altKey: true }); 141 | } 142 | }; 143 | export const moveBlocks = (count: number): void => { 144 | if (count > 0) { 145 | downBlocks(count); 146 | } else { 147 | upBlocks(-count); 148 | } 149 | }; 150 | export const upBlocks = (count = 1): void => { 151 | for (const _ of range(1, count)) { 152 | press("ArrowUp", { altKey: true }); 153 | } 154 | }; 155 | export const downBlocks = (count = 1): void => { 156 | for (const _ of range(1, count)) { 157 | press("ArrowDown", { altKey: true }); 158 | } 159 | }; 160 | 161 | export const insertText = (text: string): Promise => { 162 | const cursor = textInput(); 163 | if (!cursor) { 164 | throw Error("#text-input is not ditected."); 165 | } 166 | cursor.focus(); 167 | cursor.value = text; 168 | 169 | const event = new InputEvent("input", { bubbles: true }); 170 | cursor.dispatchEvent(event); 171 | return scrapbox.Page.waitForSave(); 172 | }; 173 | -------------------------------------------------------------------------------- /browser/dom/ensure.ts: -------------------------------------------------------------------------------- 1 | // These code are based on https://deno.land/x/unknownutil@v1.1.0/ensure.ts 2 | 3 | export const ensureHTMLDivElement: ( 4 | value: unknown, 5 | name: string, 6 | ) => asserts value is HTMLDivElement = ( 7 | value, 8 | name, 9 | ) => { 10 | if (value instanceof HTMLDivElement) return; 11 | throw new TypeError( 12 | `"${name}" must be HTMLDivElememt but actual is "${value}"`, 13 | ); 14 | }; 15 | 16 | export const ensureHTMLAnchorElement: ( 17 | value: unknown, 18 | name: string, 19 | ) => asserts value is HTMLAnchorElement = ( 20 | value, 21 | name, 22 | ) => { 23 | if (value instanceof HTMLAnchorElement) return; 24 | throw new TypeError( 25 | `"${name}" must be HTMLAnchorElememt but actual is "${value}"`, 26 | ); 27 | }; 28 | 29 | export const ensureHTMLTextAreaElement: ( 30 | value: unknown, 31 | name: string, 32 | ) => asserts value is HTMLTextAreaElement = ( 33 | value, 34 | name, 35 | ) => { 36 | if (value instanceof HTMLTextAreaElement) return; 37 | throw new TypeError( 38 | `"${name}" must be HTMLTextAreaElement but actual is "${value}"`, 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /browser/dom/extractCodeFiles.test.ts: -------------------------------------------------------------------------------- 1 | import { extractCodeFiles } from "./extractCodeFiles.ts"; 2 | import type { Line } from "@cosense/types/userscript"; 3 | import { assertSnapshot } from "@std/testing/snapshot"; 4 | import sample from "./sample-lines1.json" with { type: "json" }; 5 | 6 | Deno.test("extractCodeFiles", async (t) => { 7 | await assertSnapshot( 8 | t, 9 | extractCodeFiles(sample as Line[]), 10 | ); 11 | }); 12 | -------------------------------------------------------------------------------- /browser/dom/extractCodeFiles.ts: -------------------------------------------------------------------------------- 1 | import type { Line } from "@cosense/types/userscript"; 2 | 3 | /** Represents a single source code file with its code blocks */ 4 | export interface CodeFile { 5 | /** file name */ 6 | filename: string; 7 | 8 | /** language */ 9 | lang: string; 10 | 11 | /** splitted code */ 12 | blocks: CodeBlock[]; 13 | } 14 | 15 | /** Represents a single code block within a source file */ 16 | export interface CodeBlock { 17 | /** ID of the first line in the code block */ 18 | startId: string; 19 | 20 | /** ID of the last line in the code block */ 21 | endId: string; 22 | 23 | /** Last update timestamp of the code block */ 24 | updated: number; 25 | 26 | /** Indentation level of the .code-title element in Scrapbox */ 27 | indent: number; 28 | 29 | /** Lines of code within the block 30 | * 31 | * Excludes `.code-title` 32 | * 33 | * Indentation is already removed from each line 34 | */ 35 | lines: string[]; 36 | } 37 | 38 | /** Extract code blocks from {@linkcode scrapbox.Page.lines} 39 | * 40 | * @param lines - Page lines to process 41 | * @returns A {@linkcode Map}<{@linkcode string}, {@linkcode string}> containing: 42 | * - Key: The filename 43 | * - Value: The source code content 44 | */ 45 | export const extractCodeFiles = ( 46 | lines: Iterable, 47 | ): Map => { 48 | const files = new Map(); 49 | 50 | for (const line of lines) { 51 | if (!("codeBlock" in line)) continue; 52 | const { filename, lang, ...rest } = line.codeBlock; 53 | const file = files.get(filename) ?? { filename, lang, blocks: [] }; 54 | if (rest.start || file.blocks.length === 0) { 55 | file.blocks.push({ 56 | startId: line.id, 57 | endId: line.id, 58 | updated: line.updated, 59 | // Register the indentation level of `.code-title`, not the content 60 | indent: rest.indent - 1, 61 | lines: [], 62 | }); 63 | } else { 64 | const block = file.blocks[file.blocks.length - 1]; 65 | block.endId = line.id; 66 | block.updated = Math.max(block.updated, line.updated); 67 | block.lines.push([...line.text].slice(block.indent + 1).join("")); 68 | } 69 | 70 | files.set(filename, file); 71 | } 72 | 73 | return files; 74 | }; 75 | -------------------------------------------------------------------------------- /browser/dom/getCachedLines.ts: -------------------------------------------------------------------------------- 1 | import type { Line, Scrapbox } from "@cosense/types/userscript"; 2 | declare const scrapbox: Scrapbox; 3 | 4 | let isLatestData = /* @__PURE__ */ false; 5 | let lines: Line[] | null = /* @__PURE__ */ null; 6 | 7 | let initialize: (() => void) | undefined = () => { 8 | scrapbox.addListener("lines:changed", () => isLatestData = false); 9 | scrapbox.addListener("layout:changed", () => isLatestData = false); 10 | initialize = undefined; 11 | }; 12 | 13 | /** Get cached version of `{@linkcode https://jsr.io/@cosense/types/doc/userscript/~/Page.lines scrapbox.Page.lines}` 14 | * 15 | * This function caches the result of `{@linkcode https://jsr.io/@cosense/types/doc/userscript/~/Page.lines scrapbox.Page.lines}` to improve performance, 16 | * as generating the lines array is computationally expensive. 17 | * The cache is automatically invalidated when the page content changes. 18 | * 19 | * @returns Same as `{@linkcode https://jsr.io/@cosense/types/doc/userscript/~/Page.lines scrapbox.Page.lines}`. Always returns the latest data through cache management 20 | */ 21 | export const getCachedLines = (): readonly Line[] | null => { 22 | initialize?.(); 23 | if (!isLatestData) { 24 | lines = scrapbox.Page.lines; 25 | isLatestData = true; 26 | } 27 | return lines; 28 | }; 29 | -------------------------------------------------------------------------------- /browser/dom/isHeightViewable.ts: -------------------------------------------------------------------------------- 1 | export const isHeightViewable = (element: HTMLElement): boolean => { 2 | const { top, bottom } = element.getBoundingClientRect(); 3 | return top >= 0 && bottom <= globalThis.innerHeight; 4 | }; 5 | -------------------------------------------------------------------------------- /browser/dom/mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./node.ts"; 2 | export * from "./motion.ts"; 3 | export * from "./edit.ts"; 4 | export * from "./press.ts"; 5 | export * from "./click.ts"; 6 | export * from "./statusBar.ts"; 7 | export * from "./caret.ts"; 8 | export * from "./dom.ts"; 9 | export * from "./open.ts"; 10 | export * from "./cache.ts"; 11 | export * from "./cursor.ts"; 12 | export * from "./selection.ts"; 13 | export * from "./stores.ts"; 14 | export * from "./takeInternalLines.ts"; 15 | export * from "./pushPageTransition.ts"; 16 | export * from "./extractCodeFiles.ts"; 17 | export * from "./textInputEventListener.ts"; 18 | -------------------------------------------------------------------------------- /browser/dom/open.ts: -------------------------------------------------------------------------------- 1 | import { encodeTitleURI } from "../../title.ts"; 2 | import { 3 | type PageTransitionContext, 4 | pushPageTransition, 5 | } from "./pushPageTransition.ts"; 6 | import type { Scrapbox } from "@cosense/types/userscript"; 7 | declare const scrapbox: Scrapbox; 8 | 9 | export interface OpenOptions { 10 | /** line id */ 11 | id?: string; 12 | 13 | /** Text to append to the page content */ 14 | body?: string; 15 | 16 | /** Whether to open the page in a new tab 17 | * - `true`: open in a new tab 18 | * - `false`: open in the same tab 19 | * 20 | * @default {false} 21 | */ 22 | newTab?: boolean; 23 | 24 | /** Whether to reload the page when opening in the same tab 25 | * 26 | * Default value is `false` for same project (no reload) and `true` for different project (force reload) 27 | */ 28 | reload?: boolean; 29 | 30 | /** Context information required for the auto-scroll feature when navigating to linked content */ 31 | context?: Omit; 32 | } 33 | 34 | /** Open a page 35 | * 36 | * @param project - Project name of the page to open 37 | * @param title - Title of the page to open 38 | * @param options - Configuration options for opening the page 39 | */ 40 | export const open = ( 41 | project: string, 42 | title: string, 43 | options?: OpenOptions, 44 | ): void => { 45 | const url = new URL(`/${project}/${encodeTitleURI(title)}`, location.href); 46 | if (options?.body) url.search = `?body=${encodeURIComponent(options.body)}`; 47 | if (options?.id) url.hash = `#${options.id}`; 48 | 49 | if (options?.context) { 50 | pushPageTransition( 51 | { ...options?.context, to: { project, title } } as PageTransitionContext, 52 | ); 53 | } 54 | 55 | if ( 56 | options?.newTab !== false && 57 | (options?.newTab === true || project !== scrapbox.Project.name) 58 | ) { 59 | globalThis.open(url); 60 | return; 61 | } 62 | if ( 63 | options?.reload !== false && 64 | (options?.reload === true || project !== scrapbox.Project.name) 65 | ) { 66 | globalThis.open(url, "_self"); 67 | return; 68 | } 69 | 70 | const a = document.createElement("a"); 71 | a.href = url.toString(); 72 | document.body.append(a); 73 | a.click(); 74 | a.remove(); 75 | }; 76 | 77 | /** Open a page in the same tab 78 | * 79 | * The page will not be reloaded when opened 80 | * 81 | * @param project - Project name of the page to open 82 | * @param title - Title of the page to open 83 | * @param [body] - Text to append to the page 84 | */ 85 | export const openInTheSameTab = ( 86 | project: string, 87 | title: string, 88 | body?: string, 89 | ): void => open(project, title, { newTab: false, reload: false, body }); 90 | -------------------------------------------------------------------------------- /browser/dom/page.d.ts: -------------------------------------------------------------------------------- 1 | import { BaseStore } from "@cosense/types/userscript"; 2 | import type { Page as PageData } from "@cosense/types/rest"; 3 | 4 | export interface SetPositionOptions { 5 | /** Whether to auto-scroll the page when the cursor moves outside the viewport 6 | * When `true`, the page will automatically scroll to keep the cursor visible 7 | * 8 | * @default {true} 9 | */ 10 | scrollInView?: boolean; 11 | 12 | /** Source of the cursor movement event 13 | * 14 | * Can be set to `"mouse"` when the cursor movement is triggered by mouse interaction 15 | * This parameter helps distinguish between different types of cursor movements 16 | */ 17 | source?: "mouse"; 18 | } 19 | 20 | export interface ApiUrlForFetch { 21 | projectName: string; 22 | title: string; 23 | titleHint: string; 24 | followRename: boolean; 25 | search: string; 26 | } 27 | 28 | export interface ApplySnapshotInit { 29 | page: Pick; 30 | prevPage?: Pick; 31 | nextPage?: Pick; 32 | } 33 | 34 | export type PageWithCache = PageData & { cachedAt: number | undefined }; 35 | 36 | /** Internal class for managing Scrapbox page data 37 | * 38 | * > [!NOTE] 39 | * > Some type definitions are still in progress and may be incomplete 40 | */ 41 | export declare class Page extends BaseStore< 42 | { source: "mouse" | undefined } | "focusTextInput" | "scroll" | undefined 43 | > { 44 | public initialize(): void; 45 | 46 | private data: PageWithCache; 47 | 48 | public get(): PageWithCache; 49 | 50 | public apiUrlForFetch(init: ApiUrlForFetch): string; 51 | public apiUrlForUpdatePageAccessed(pageId: string): string; 52 | public fetch(): Promise; 53 | 54 | public set(page: PageWithCache): void; 55 | public reset(): void; 56 | public applySnapshot(init: ApplySnapshotInit): void; 57 | setTitle(title: string, init?: { from: string }): void; 58 | get fromCacheStorage(): boolean; 59 | public setPin(pin: number): void; 60 | public delete(): void; 61 | public patch(t: unknown): void; 62 | public patchChanges( 63 | t: unknown, 64 | init?: { from: string }, 65 | ): Promise; 66 | get hasSelfBackLink(): boolean; 67 | public requestFetchApiCacheToServiceWorker(): unknown; 68 | } 69 | -------------------------------------------------------------------------------- /browser/dom/position.ts: -------------------------------------------------------------------------------- 1 | /** Position information within the Scrapbox editor 2 | * Represents the cursor or selection position using line and character coordinates 3 | */ 4 | export interface Position { 5 | /** Line number (1-based index) */ line: number; 6 | /** Character position within the line (0-based index) 7 | * Represents the number of characters before the cursor position 8 | */ char: number; 9 | } 10 | -------------------------------------------------------------------------------- /browser/dom/press.ts: -------------------------------------------------------------------------------- 1 | import { textInput } from "./dom.ts"; 2 | 3 | /** the options for `press()` */ 4 | export interface PressOptions { 5 | shiftKey?: boolean; 6 | ctrlKey?: boolean; 7 | altKey?: boolean; 8 | metaKey?: boolean; 9 | noModifiedKeys?: boolean; 10 | } 11 | 12 | /** Dispatches a keyboard event programmatically via JavaScript 13 | * 14 | * Used to send keyboard input commands to Scrapbox. 15 | * > [!NOTE] 16 | * > This function appears to block synchronously until Scrapbox's event listeners 17 | * finish processing the keyboard event. 18 | * 19 | * @param key - The name of the key to simulate pressing 20 | * @param pressOptions - Additional options for the key press (modifiers, etc.) 21 | */ 22 | export const press = ( 23 | key: KeyName, 24 | pressOptions?: PressOptions, 25 | ): void => { 26 | const { noModifiedKeys = false, ...rest } = pressOptions ?? {}; 27 | const options = { 28 | bubbles: true, 29 | cancelable: true, 30 | keyCode: KEYCODE_MAP[key], 31 | ...(noModifiedKeys ? {} : { ...rest }), 32 | }; 33 | const textarea = textInput(); 34 | if (!textarea) throw Error("#text-input must exist."); 35 | textarea.dispatchEvent(new KeyboardEvent("keydown", options)); 36 | textarea.dispatchEvent(new KeyboardEvent("keyup", options)); 37 | }; 38 | 39 | export type KeyName = keyof typeof KEYCODE_MAP; 40 | const KEYCODE_MAP = { 41 | Backspace: 8, 42 | Tab: 9, 43 | Enter: 13, 44 | Delete: 46, 45 | Escape: 27, 46 | " ": 32, 47 | PageUp: 33, 48 | PageDown: 34, 49 | End: 35, 50 | Home: 36, 51 | ArrowLeft: 37, 52 | ArrowUp: 38, 53 | ArrowRight: 39, 54 | ArrowDown: 40, 55 | // alphabets 56 | a: 65, 57 | A: 65, 58 | b: 66, 59 | B: 66, 60 | c: 67, 61 | C: 67, 62 | d: 68, 63 | D: 68, 64 | e: 69, 65 | E: 69, 66 | f: 70, 67 | F: 70, 68 | g: 71, 69 | G: 71, 70 | h: 72, 71 | H: 72, 72 | i: 73, 73 | I: 73, 74 | j: 74, 75 | J: 74, 76 | k: 75, 77 | K: 75, 78 | l: 76, 79 | L: 76, 80 | m: 77, 81 | M: 77, 82 | n: 78, 83 | N: 78, 84 | o: 79, 85 | O: 79, 86 | p: 80, 87 | P: 80, 88 | q: 81, 89 | Q: 81, 90 | r: 82, 91 | R: 82, 92 | s: 83, 93 | S: 83, 94 | t: 84, 95 | T: 84, 96 | u: 85, 97 | U: 85, 98 | v: 86, 99 | V: 86, 100 | w: 87, 101 | W: 87, 102 | x: 88, 103 | X: 88, 104 | y: 89, 105 | Y: 89, 106 | z: 90, 107 | Z: 90, 108 | // number 109 | 0: 48, 110 | 1: 49, 111 | 2: 50, 112 | 3: 51, 113 | 4: 52, 114 | 5: 53, 115 | 6: 54, 116 | 7: 55, 117 | 8: 56, 118 | 9: 57, 119 | // function keys 120 | F1: 113, 121 | F2: 114, 122 | F3: 115, 123 | F4: 116, 124 | F5: 117, 125 | F6: 118, 126 | F7: 119, 127 | F8: 120, 128 | F9: 121, 129 | F10: 122, 130 | F11: 123, 131 | F12: 124, 132 | // Symbols and special characters 133 | ":": 186, 134 | "*": 186, 135 | ";": 187, 136 | "+": 187, 137 | "-": 189, 138 | "=": 189, 139 | ".": 190, 140 | ">": 190, 141 | "/": 191, 142 | "?": 191, 143 | "@": 192, 144 | "`": 192, 145 | "[": 219, 146 | "{": 219, 147 | "\\": 220, 148 | "|": 220, 149 | "]": 221, 150 | "}": 221, 151 | "^": 222, 152 | "~": 222, 153 | "_": 226, // Note: Without Shift, keyCode 226 represents '\' and cannot be distinguished from the backslash key 154 | }; 155 | -------------------------------------------------------------------------------- /browser/dom/pushPageTransition.ts: -------------------------------------------------------------------------------- 1 | import { toTitleLc } from "../../title.ts"; 2 | 3 | /** Represents a link to a Scrapbox page */ 4 | export interface Link { 5 | /** The project name of the linked page */ 6 | project: string; 7 | 8 | /** The title of the linked page */ 9 | title: string; 10 | } 11 | 12 | /** Represents the state of a page-to-page navigation 13 | * Used to track navigation between two specific pages within Scrapbox 14 | */ 15 | export interface PageTransitionContextLink { 16 | type: "page"; 17 | 18 | /** Link to the source/origin page */ 19 | from: Link; 20 | 21 | /** Link to the destination/target page */ 22 | to: Link; 23 | } 24 | 25 | /** Represents the state when navigating from search results to a specific page 26 | * Used to track navigation that originates from a full-text search 27 | */ 28 | export interface PageTransitionContextQuery { 29 | type: "search"; 30 | 31 | /** The search query used in the full-text search */ 32 | query: string; 33 | 34 | /** Link to the destination/target page */ 35 | to: Link; 36 | } 37 | 38 | export type PageTransitionContext = 39 | | PageTransitionContextLink 40 | | PageTransitionContextQuery; 41 | 42 | /** Registers the page transition state and enables automatic scrolling to the linked content 43 | * This function stores navigation context in localStorage, which is used to determine 44 | * where to scroll on the next page load. This is particularly useful for maintaining 45 | * context when users navigate between related pages or from search results. 46 | * 47 | * @param context The transition state containing source and destination information 48 | */ 49 | export const pushPageTransition = (context: PageTransitionContext): void => { 50 | const pageTransitionContext: Record = JSON.parse( 51 | localStorage.getItem("pageTransitionContext") ?? "", 52 | ); 53 | const value = context.type === "page" 54 | ? context.from.project === context.to.project 55 | ? context.from.title === context.to.title 56 | ? { 57 | titleHint: context.to.title, 58 | } 59 | : { 60 | linkFrom: context.from.title, 61 | } 62 | : { 63 | linkFrom: `/${context.from.project}/${context.from.title}`, 64 | } 65 | : { 66 | searchQuery: context.query, 67 | }; 68 | pageTransitionContext[`page_${toTitleLc(context.to.title)}`] = value; 69 | localStorage.setItem( 70 | "pageTransitionContext", 71 | JSON.stringify(pageTransitionContext), 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /browser/dom/selection.d.ts: -------------------------------------------------------------------------------- 1 | import { type BaseLine, BaseStore } from "@cosense/types/userscript"; 2 | import type { Position } from "./position.ts"; 3 | 4 | export interface Range { 5 | start: Position; 6 | end: Position; 7 | } 8 | 9 | export declare class Selection extends BaseStore { 10 | constructor(); 11 | 12 | /** 13 | * A class that manages text selection in Scrapbox pages. 14 | * It handles selection ranges, provides utilities for text manipulation, 15 | * and maintains the selection state across user interactions. 16 | */ 17 | 18 | /** Get the current page content as an array of lines */ 19 | get lines(): BaseLine[]; 20 | 21 | /** Get the current selection range 22 | * 23 | * @param init Set `init.normalizeOrder` to `true` to ensure Range.start is 24 | * the beginning of the selection (useful for consistent text processing) 25 | * @returns The current {@linkcode Range} object representing the selection 26 | */ 27 | getRange(init?: { normalizeOrder: boolean }): Range; 28 | 29 | /** Update the current selection range */ 30 | setRange(range: Range): void; 31 | 32 | /** Clear the current selection */ 33 | clear(): void; 34 | 35 | /** Normalize the selection range order to ensure start position comes before end 36 | * 37 | * @param range - The selection range to normalize 38 | * @returns A normalized {@linkcode Range} with start position at the beginning 39 | * 40 | * This is useful when you need consistent text processing regardless of 41 | * whether the user selected text from top-to-bottom or bottom-to-top. 42 | */ 43 | normalizeOrder(range: Range): Range; 44 | 45 | /** Get the text content of the current selection */ 46 | getSelectedText(): string; 47 | 48 | /** Get the visual height of the selection in pixels */ 49 | getSelectionsHeight(): number; 50 | 51 | /** Get the Y-coordinate of the selection's top-right corner */ 52 | getSelectionTop(): number; 53 | 54 | /** Select all content in the current page */ 55 | selectAll(): void; 56 | 57 | /** Check if there is any active selection 58 | * 59 | * @param range Optional range to check. If not provided, 60 | * checks this class's current selection 61 | */ 62 | hasSelection(range?: Range): boolean; 63 | 64 | /** Check if exactly one line is selected 65 | * 66 | * @param range Optional range to check. If not provided, 67 | * checks this class's current selection 68 | */ 69 | hasSingleLineSelection(range?: Range): boolean; 70 | 71 | /** Check if multiple lines are selected (2 or more) 72 | * 73 | * @param range Optional range to check. If not provided, 74 | * checks this class's current selection 75 | */ 76 | hasMultiLinesSelection(range?: Range): boolean; 77 | 78 | /** Check if all content in the current page is selected 79 | * 80 | * This is equivalent to checking if the selection spans 81 | * from the beginning of the first line to the end of the last line 82 | */ 83 | hasSelectionAll(): boolean; 84 | 85 | private fixPosition(position: Position): void; 86 | private fixRange(): void; 87 | private data: Range; 88 | } 89 | -------------------------------------------------------------------------------- /browser/dom/selection.ts: -------------------------------------------------------------------------------- 1 | import { takeStores } from "./stores.ts"; 2 | import type { Selection } from "./selection.d.ts"; 3 | export type { Selection }; 4 | 5 | export const takeSelection = (): Selection => takeStores().selection; 6 | -------------------------------------------------------------------------------- /browser/dom/statusBar.ts: -------------------------------------------------------------------------------- 1 | import { statusBar } from "./dom.ts"; 2 | 3 | export interface UseStatusBarResult extends Disposable { 4 | /** Display information in the acquired status bar section 5 | * 6 | * @param items - Array of items to display (text, icons, or groups) 7 | */ 8 | render: (...items: Item[]) => void; 9 | /** Remove the acquired status bar section and clean up resources */ 10 | dispose: () => void; 11 | } 12 | 13 | /** Get a section of the status bar and return functions to manipulate it 14 | * 15 | * The status bar is divided into sections, each managed independently. 16 | * This hook creates a new section and provides methods to: 17 | * - Display information (text and icons) in the section 18 | * - Remove the section when it's no longer needed 19 | */ 20 | export const useStatusBar = (): UseStatusBarResult => { 21 | const bar = statusBar(); 22 | if (!bar) throw new Error(`div.status-bar can't be found`); 23 | 24 | const status = document.createElement("div"); 25 | bar.append(status); 26 | 27 | return { 28 | render: (...items: Item[]) => { 29 | status.textContent = ""; 30 | const child = makeGroup(...items); 31 | if (child) status.append(child); 32 | }, 33 | dispose: () => status.remove(), 34 | [Symbol.dispose]: () => status.remove(), 35 | }; 36 | }; 37 | 38 | export interface ItemGroup { 39 | type: "group"; 40 | items: Item[]; 41 | } 42 | export type Item = 43 | | { 44 | type: "spinner" | "check-circle" | "exclamation-triangle"; 45 | } 46 | | { type: "text"; text: string } 47 | | ItemGroup; 48 | 49 | const makeGroup = (...items: Item[]): HTMLSpanElement | undefined => { 50 | const nodes = items.flatMap((item) => { 51 | switch (item.type) { 52 | case "spinner": 53 | return [makeSpinner()]; 54 | case "check-circle": 55 | return [makeCheckCircle()]; 56 | case "exclamation-triangle": 57 | return [makeExclamationTriangle()]; 58 | case "text": 59 | return [makeItem(item.text)]; 60 | case "group": { 61 | const group = makeGroup(...item.items); 62 | return group ? [group] : []; 63 | } 64 | } 65 | }); 66 | if (nodes.length === 0) return; 67 | if (nodes.length === 1) return nodes[0]; 68 | const span = document.createElement("span"); 69 | span.classList.add("item-group"); 70 | span.append(...nodes); 71 | return span; 72 | }; 73 | const makeItem = (child: string | Node) => { 74 | const span = document.createElement("span"); 75 | span.classList.add("item"); 76 | span.append(child); 77 | return span; 78 | }; 79 | 80 | /** Create a loading spinner icon 81 | * 82 | * Creates a FontAwesome spinner icon wrapped in a status bar item. 83 | * Use this to indicate loading or processing states. 84 | */ 85 | const makeSpinner = () => { 86 | const i = document.createElement("i"); 87 | i.classList.add("fa", "fa-spinner"); 88 | return makeItem(i); 89 | }; 90 | 91 | /** Create a checkmark icon 92 | * 93 | * Creates a Kamon checkmark icon wrapped in a status bar item. 94 | * Use this to indicate successful completion or confirmation. 95 | */ 96 | const makeCheckCircle = () => { 97 | const i = document.createElement("i"); 98 | i.classList.add("kamon", "kamon-check-circle"); 99 | return makeItem(i); 100 | }; 101 | 102 | /** Create a warning icon 103 | * 104 | * Creates a FontAwesome warning triangle icon wrapped in a status bar item. 105 | * Use this to indicate warnings, errors, or important notices. 106 | */ 107 | const makeExclamationTriangle = () => { 108 | const i = document.createElement("i"); 109 | i.classList.add("fas", "fa-exclamation-triangle"); 110 | return makeItem(i); 111 | }; 112 | -------------------------------------------------------------------------------- /browser/dom/stores.ts: -------------------------------------------------------------------------------- 1 | import { textInput } from "./dom.ts"; 2 | import type { Cursor } from "./cursor.d.ts"; 3 | import type { Selection } from "./selection.d.ts"; 4 | export type { Cursor, Selection }; 5 | 6 | /** Retrieve Scrapbox's internal cursor and selection stores from the DOM 7 | * 8 | * This function accesses React's internal fiber tree to obtain references to 9 | * the Cursor and Selection store instances that Scrapbox uses to manage 10 | * text input state. These stores provide APIs for: 11 | * - {@linkcode Cursor}: Managing text cursor position and movement 12 | * - {@linkcode Selection}: Handling text selection ranges and operations 13 | * 14 | * @throws {@linkcode Error} If text input element or stores cannot be found 15 | * @returns Object containing {@linkcode CursorStore} and {@linkcode SelectionStore} instances 16 | */ 17 | export const takeStores = (): { cursor: Cursor; selection: Selection } => { 18 | const textarea = textInput(); 19 | if (!textarea) { 20 | throw Error(`#text-input is not found.`); 21 | } 22 | 23 | const reactKey = Object.keys(textarea) 24 | .find((key) => key.startsWith("__reactFiber")); 25 | if (!reactKey) { 26 | throw Error( 27 | '#text-input must has the property whose name starts with "__reactFiber"', 28 | ); 29 | } 30 | 31 | // @ts-ignore Treating DOM element as an object to access React's internal fiber tree. 32 | // This is a hack to access Scrapbox's internal stores, but it's currently the only way 33 | // to obtain references to the cursor and selection management instances. 34 | const stores = (textarea[ 35 | reactKey 36 | ] as ReactFiber).return.return.stateNode._stores as (Cursor | Selection)[]; 37 | 38 | const cursor = stores.find((store) => 39 | store.constructor.name === "Cursor" 40 | ) as (Cursor | undefined); 41 | if (!cursor) { 42 | throw Error('#text-input must has a "Cursor" store.'); 43 | } 44 | const selection = stores.find((store) => 45 | store.constructor.name === "Selection" 46 | ) as (Selection | undefined); 47 | if (!selection) { 48 | throw Error('#text-input must has a "Selection" store.'); 49 | } 50 | 51 | return { cursor, selection }; 52 | }; 53 | 54 | /** Internal React Fiber node structure 55 | * 56 | * This interface represents the minimal structure we need from React's 57 | * internal fiber tree to access Scrapbox's store instances. Note that 58 | * this is an implementation detail and might change with React updates. 59 | */ 60 | interface ReactFiber { 61 | return: { 62 | return: { 63 | stateNode: { 64 | _stores: (Cursor | Selection)[]; 65 | }; 66 | }; 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /browser/dom/takeInternalLines.ts: -------------------------------------------------------------------------------- 1 | import { lines } from "./dom.ts"; 2 | import type { BaseLine } from "@cosense/types/userscript"; 3 | 4 | /** Get a reference to Scrapbox's internal page content data 5 | * 6 | * This function provides direct access to the page content without deep cloning, 7 | * unlike `{@linkcode https://jsr.io/@cosense/types/doc/userscript/~/Page.lines scrapbox.Page.lines}` which creates a deep copy. Use this when: 8 | * - You need better performance by avoiding data cloning 9 | * - You only need to read the raw line data 10 | * 11 | * > [!IMPORTANT] 12 | * > - This returns a direct reference to the internal data. While the type definition 13 | * > marks it as readonly, the content can still be modified through JavaScript. 14 | * > Be careful not to modify the data to avoid unexpected behavior. 15 | * > - Unlike `{@linkcode https://jsr.io/@cosense/types/doc/userscript/~/Page.lines scrapbox.Page.lines}`, the returned data does not include parsed 16 | * > syntax information (no syntax tree or parsed line components). 17 | * 18 | * @returns A {@linkcode ReadonlyArray}<{@linkcode BaseLine}> containing: 19 | * - Success: The page content as a readonly array of line objects 20 | * - Error: May throw one of: 21 | * - `Error` when div.lines element is not found 22 | * - `Error` when React fiber property is missing 23 | */ 24 | export const takeInternalLines = (): readonly BaseLine[] => { 25 | const linesEl = lines(); 26 | if (!linesEl) { 27 | throw Error(`div.lines is not found.`); 28 | } 29 | 30 | const reactKey = Object.keys(linesEl) 31 | .find((key) => key.startsWith("__reactFiber")); 32 | if (!reactKey) { 33 | throw Error( 34 | 'div.lines must has the property whose name starts with "__reactFiber"', 35 | ); 36 | } 37 | 38 | // @ts-ignore Accessing DOM element as an object to reach React's internal data. 39 | // This is necessary to get the raw line data from React's component props. 40 | return (linesEl[reactKey] as ReactFiber).return.stateNode.props 41 | .lines as const; 42 | }; 43 | 44 | /** Internal React Fiber node structure for accessing line data 45 | * 46 | * This interface represents the minimal structure needed to access 47 | * the lines data from React's component props. This is an implementation 48 | * detail that depends on React's internal structure. 49 | * 50 | * @interface 51 | * @internal 52 | */ 53 | interface ReactFiber { 54 | return: { 55 | stateNode: { 56 | props: { 57 | lines: BaseLine[]; 58 | }; 59 | }; 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /browser/dom/textInputEventListener.ts: -------------------------------------------------------------------------------- 1 | import type { Scrapbox } from "@cosense/types/userscript"; 2 | import { textInput } from "./dom.ts"; 3 | import { decode, encode } from "./_internal.ts"; 4 | declare const scrapbox: Scrapbox; 5 | 6 | /** Map structure for tracking event listeners and their options 7 | * 8 | * Structure: 9 | * - First level: Maps event names to their listeners 10 | * - Second level: Maps each listener to its set of encoded options 11 | * - The encoded options allow tracking multiple registrations of the same 12 | * listener with different options 13 | */ 14 | const listenerMap = /* @__PURE__ */ new Map< 15 | keyof HTMLElementEventMap, 16 | Map> 17 | >(); 18 | const onceListenerMap = /* @__PURE__ */ new Map< 19 | EventListener, 20 | Map 21 | >(); 22 | 23 | /** re-register event listeners when the layout changes */ 24 | let reRegister: (() => void) | undefined = () => { 25 | scrapbox.on("layout:changed", () => { 26 | const textinput = textInput(); 27 | if (!textinput) return; 28 | for (const [name, argMap] of listenerMap) { 29 | for (const [listener, encodedOptions] of argMap) { 30 | for (const encoded of encodedOptions) { 31 | textinput.addEventListener( 32 | name, 33 | listener as EventListener, 34 | decode(encoded), 35 | ); 36 | } 37 | } 38 | } 39 | }); 40 | reRegister = undefined; 41 | }; 42 | 43 | /** Add an event listener to the `#text-input` element with automatic re-registration 44 | * 45 | * In Scrapbox, the `#text-input` element is recreated when the page layout changes. 46 | * This function manages event listeners by: 47 | * 1. Storing the listener and its options in a persistent map 48 | * 2. Automatically re-registering all listeners when layout changes 49 | * 3. Handling both regular and once-only event listeners 50 | * 51 | * @param name - The event type to listen for (e.g., 'input', 'keydown') 52 | * @param listener - The callback function to execute when the event occurs 53 | * @param options - Standard addEventListener options or boolean for useCapture 54 | * @returns {@linkcode void} 55 | */ 56 | export const addTextInputEventListener = ( 57 | name: K, 58 | listener: ( 59 | this: HTMLTextAreaElement, 60 | event: HTMLElementEventMap[K], 61 | ) => unknown, 62 | options?: boolean | AddEventListenerOptions, 63 | ): void => { 64 | reRegister?.(); 65 | const argMap = listenerMap.get(name) ?? new Map>(); 66 | const encodedOptions = argMap.get(listener as EventListener) ?? new Set(); 67 | if (encodedOptions.has(encode(options))) return; 68 | encodedOptions.add(encode(options)); 69 | argMap.set(listener as EventListener, encodedOptions); 70 | listenerMap.set(name, argMap); 71 | if (typeof options === "object" && options?.once) { 72 | const onceMap = onceListenerMap.get(listener as EventListener) ?? 73 | new Map(); 74 | const encoded = encode(options); 75 | 76 | /** A wrapper listener that removes itself from the `listenerMap` when called 77 | * 78 | * This wrapper ensures proper cleanup of both the DOM event listener and our 79 | * internal listener tracking when a 'once' listener is triggered. 80 | */ 81 | const onceListener = function ( 82 | this: HTMLTextAreaElement, 83 | event: HTMLElementEventMap[K], 84 | ) { 85 | removeTextInputEventListener(name, listener, options); 86 | onceMap.delete(encoded); 87 | return listener.call(this, event); 88 | }; 89 | onceMap.set(encoded, onceListener as EventListener); 90 | onceListenerMap.set(listener as EventListener, onceMap); 91 | 92 | const textinput = textInput(); 93 | if (!textinput) return; 94 | textinput.addEventListener(name, onceListener, options); 95 | } 96 | const textinput = textInput(); 97 | if (!textinput) return; 98 | textinput.addEventListener(name, listener, options); 99 | }; 100 | 101 | export const removeTextInputEventListener = < 102 | K extends keyof HTMLElementEventMap, 103 | >( 104 | name: K, 105 | listener: (event: HTMLElementEventMap[K]) => unknown, 106 | options?: boolean | AddEventListenerOptions, 107 | ): void => { 108 | reRegister?.(); 109 | const argMap = listenerMap.get(name); 110 | if (!argMap) return; 111 | const encodedOptions = argMap.get(listener as EventListener); 112 | if (!encodedOptions) return; 113 | const encoded = encode(options); 114 | encodedOptions.delete(encoded); 115 | if (typeof options === "object" && options?.once) { 116 | const onceMap = onceListenerMap.get(listener as EventListener); 117 | if (!onceMap) return; 118 | const onceListener = onceMap.get(encoded); 119 | if (!onceListener) return; 120 | 121 | const textinput = textInput(); 122 | if (!textinput) return; 123 | textinput.removeEventListener(name, onceListener, options); 124 | onceMap.delete(encoded); 125 | return; 126 | } 127 | const textinput = textInput(); 128 | if (!textinput) return; 129 | textinput.removeEventListener(name, listener, options); 130 | }; 131 | -------------------------------------------------------------------------------- /browser/mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./dom/mod.ts"; 2 | export * from "../websocket/mod.ts"; 3 | -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "esnext", 5 | "dom", 6 | "dom.iterable", 7 | "deno.ns" 8 | ] 9 | }, 10 | "exclude": [ 11 | "coverage/", 12 | "docs/" 13 | ], 14 | "exports": { 15 | ".": "./mod.ts", 16 | "./browser": "./browser/mod.ts", 17 | "./browser/dom": "./browser/dom/mod.ts", 18 | "./browser/websocket": "./websocket/mod.ts", 19 | "./parseAbsoluteLink": "./parseAbsoluteLink.ts", 20 | "./rest": "./rest/mod.ts", 21 | "./text": "./text.ts", 22 | "./title": "./title.ts", 23 | "./websocket": "./websocket/mod.ts", 24 | "./unstable-api": "./api.ts", 25 | "./unstable-api/pages": "./api/pages.ts", 26 | "./unstable-api/pages/project": "./api/pages/project.ts", 27 | "./unstable-api/pages/project/replace": "./api/pages/project/replace.ts", 28 | "./unstable-api/pages/project/replace/links": "./api/pages/project/replace/links.ts", 29 | "./unstable-api/pages/project/search": "./api/pages/project/search.ts", 30 | "./unstable-api/pages/project/search/query": "./api/pages/project/search/query.ts", 31 | "./unstable-api/pages/project/search/titles": "./api/pages/project/search/titles.ts", 32 | "./unstable-api/pages/project/title": "./api/pages/project/title.ts", 33 | "./unstable-api/pages/projects": "./api/projects.ts", 34 | "./unstable-api/pages/projects/project": "./api/projects/project.ts", 35 | "./unstable-api/pages/project/title/text": "./api/pages/project/title/text.ts", 36 | "./unstable-api/pages/project/title/icon": "./api/pages/project/title/icon.ts", 37 | "./unstable-api/users": "./api/users.ts", 38 | "./unstable-api/users/me": "./api/users/me.ts" 39 | }, 40 | "imports": { 41 | "@core/iterutil": "jsr:@core/iterutil@^0.9.0", 42 | "@core/unknownutil": "jsr:@core/unknownutil@^4.0.0", 43 | "@cosense/std/browser/websocket": "./websocket/mod.ts", 44 | "@cosense/std/rest": "./rest/mod.ts", 45 | "@cosense/std/websocket": "./websocket/mod.ts", 46 | "@cosense/types": "jsr:@cosense/types@^0.10.7", 47 | "@cosense/types/rest": "jsr:@cosense/types@0.10/rest", 48 | "@cosense/types/userscript": "jsr:@cosense/types@0.10/userscript", 49 | "@progfay/scrapbox-parser": "jsr:@progfay/scrapbox-parser@9", 50 | "@std/assert": "jsr:@std/assert@1", 51 | "@std/async": "jsr:@std/async@^1.0.11", 52 | "@std/encoding": "jsr:@std/encoding@1", 53 | "@std/http": "jsr:@std/http@^1.0.13", 54 | "@std/json": "jsr:@std/json@^1.0.0", 55 | "@std/testing": "jsr:@std/testing@^1.0.9", 56 | "@std/testing/snapshot": "jsr:@std/testing@1/snapshot", 57 | "@takker/md5": "jsr:@takker/md5@0.1", 58 | "@takker/onp": "./vendor/raw.githubusercontent.com/takker99/onp/0.0.1/mod.ts", 59 | "option-t": "npm:option-t@^51.0.0", 60 | "socket.io-client": "npm:socket.io-client@^4.7.5" 61 | }, 62 | "lint": { 63 | "exclude": [ 64 | "vendor/" 65 | ] 66 | }, 67 | "name": "@cosense/std", 68 | "tasks": { 69 | "check": { 70 | "command": "deno fmt --check && deno lint && deno publish --dry-run", 71 | "dependencies": [ 72 | "type-check", 73 | "test" 74 | ] 75 | }, 76 | "coverage": "deno test --allow-read=./ --parallel --shuffle --coverage --no-check && deno coverage --html", 77 | "doc": "deno doc --html mod.ts", 78 | "fix": { 79 | "command": "deno fmt && deno lint --fix && deno publish --dry-run --allow-dirty", 80 | "dependencies": [ 81 | "type-check", 82 | "test" 83 | ] 84 | }, 85 | "test": "deno test --allow-read=./ --doc --parallel --shuffle --no-check", 86 | "type-check": "deno check --remote **/*.ts", 87 | // from https://github.com/jsr-core/unknownutil/blob/v4.2.2/deno.jsonc#L84-L85 88 | "update": "deno outdated --update", 89 | "update:commit": "deno task -q update --commit --prefix deps: --pre-commit=fix" 90 | }, 91 | "test": { 92 | "exclude": [ 93 | "README.md", 94 | "./websocket/listen.ts", 95 | "./websocket/updateCodeFile.ts", 96 | "./rest/getCachedAt.ts", 97 | "./rest/getCodeBlocks.ts", 98 | "./rest/getGyazoToken.ts", 99 | "./rest/getTweetInfo.ts", 100 | "./rest/getWebPageTitle.ts", 101 | "./rest/link.ts" 102 | ] 103 | }, 104 | "version": "0.0.0" 105 | } 106 | -------------------------------------------------------------------------------- /deps/onp.ts: -------------------------------------------------------------------------------- 1 | export * from "@takker/onp"; 2 | -------------------------------------------------------------------------------- /error.ts: -------------------------------------------------------------------------------- 1 | export interface TypedError 2 | extends Error { 3 | /** 4 | * The error name 5 | */ 6 | readonly name: Name; 7 | 8 | /** 9 | * The error cause 10 | */ 11 | readonly cause?: Cause; 12 | } 13 | 14 | export const makeError = ( 15 | name: Name, 16 | message?: string, 17 | cause?: Cause, 18 | ): TypedError => { 19 | // from https://stackoverflow.com/a/43001581 20 | type Writeable = { -readonly [P in keyof T]: T[P] }; 21 | 22 | const error = new Error(message, { cause }) as Writeable< 23 | TypedError 24 | >; 25 | error.name = name; 26 | return error; 27 | }; 28 | 29 | export interface HTTPError 30 | extends TypedError<"HTTPError", Cause> { 31 | readonly response: Response; 32 | } 33 | 34 | export const makeHTTPError = ( 35 | response: Response, 36 | message?: string, 37 | cause?: Cause, 38 | ): HTTPError => { 39 | // from https://stackoverflow.com/a/43001581 40 | type Writeable = { -readonly [P in keyof T]: T[P] }; 41 | 42 | const error = new Error(message, { cause }) as Writeable>; 43 | error.name = "HTTPError"; 44 | error.response = response; 45 | return error; 46 | }; 47 | -------------------------------------------------------------------------------- /json_compatible.ts: -------------------------------------------------------------------------------- 1 | import type { JsonValue } from "@std/json/types"; 2 | import type { IsAny } from "@std/testing/types"; 3 | export type { IsAny, JsonValue }; 4 | 5 | /** 6 | * Check if a property {@linkcode K} is optional in {@linkcode T}. 7 | * 8 | * ```ts 9 | * import type { Assert } from "@std/testing/types"; 10 | * 11 | * type _1 = Assert, true>; 12 | * type _2 = Assert, true>; 13 | * type _3 = Assert, true>; 14 | * type _4 = Assert, false>; 15 | * type _5 = Assert, false>; 16 | * type _6 = Assert, false>; 17 | * ``` 18 | * @internal 19 | * 20 | * @see https://dev.to/zirkelc/typescript-how-to-check-for-optional-properties-3192 21 | */ 22 | export type IsOptional = 23 | Record extends Pick ? true : false; 24 | 25 | /** 26 | * A type that is compatible with JSON. 27 | * 28 | * ```ts 29 | * import type { JsonValue } from "@std/json/types"; 30 | * import { assertType } from "@std/testing/types"; 31 | * 32 | * type IsJsonCompatible = [T] extends [JsonCompatible] ? true : false; 33 | * 34 | * assertType>(true); 35 | * assertType>(true); 36 | * assertType>(true); 37 | * assertType>(true); 38 | * assertType>(true); 39 | * assertType>(true); 40 | * assertType>(false); 41 | * // deno-lint-ignore no-explicit-any 42 | * assertType>(false); 43 | * assertType>(false); 44 | * assertType>(false); 45 | * // deno-lint-ignore ban-types 46 | * assertType>(false); 47 | * assertType void>>(false); 48 | * assertType>(false); 49 | * assertType>(false); 50 | * 51 | * assertType>(true); 52 | * // deno-lint-ignore ban-types 53 | * assertType>(true); 54 | * assertType>(true); 55 | * assertType>(true); 56 | * assertType>(true); 57 | * assertType>(true); 58 | * assertType>(true); 59 | * assertType>(true); 60 | * assertType>(false); 61 | * assertType>(false); 62 | * assertType>(true); 63 | * assertType>(true); 64 | * assertType>(false); 65 | * assertType>(true); 66 | * assertType>(false); 67 | * assertType>(true); 68 | * assertType>(false); 69 | * assertType>(true); 70 | * assertType>(true); 71 | * // deno-lint-ignore no-explicit-any 72 | * assertType>(false); 73 | * assertType>(false); 74 | * // deno-lint-ignore ban-types 75 | * assertType>(false); 76 | * // deno-lint-ignore no-explicit-any 77 | * assertType any }>>(false); 78 | * // deno-lint-ignore no-explicit-any 79 | * assertType any) | number }>>(false); 80 | * // deno-lint-ignore no-explicit-any 81 | * assertType any }>>(false); 82 | * class A { 83 | * a = 34; 84 | * } 85 | * assertType>(true); 86 | * class B { 87 | * fn() { 88 | * return "hello"; 89 | * }; 90 | * } 91 | * assertType>(false); 92 | * 93 | * assertType>(true); 94 | * assertType void }>>(false); 95 | * 96 | * assertType>(true); 97 | * interface D { 98 | * aa: string; 99 | * } 100 | * assertType>(true); 101 | * interface E { 102 | * a: D; 103 | * } 104 | * assertType>(true); 105 | * interface F { 106 | * _: E; 107 | * } 108 | * assertType>(true); 109 | * ``` 110 | * 111 | * @see This implementation is heavily inspired by https://github.com/microsoft/TypeScript/issues/1897#issuecomment-580962081 . 112 | */ 113 | export type JsonCompatible = 114 | // deno-lint-ignore ban-types 115 | [Extract] extends [never] ? { 116 | [K in keyof T]: [IsAny] extends [true] ? never 117 | : T[K] extends JsonValue ? T[K] 118 | : [IsOptional] extends [true] 119 | ? JsonCompatible> | Extract 120 | : undefined extends T[K] ? never 121 | : JsonCompatible; 122 | } 123 | : never; 124 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./rest/mod.ts"; 2 | export * from "./browser/mod.ts"; 3 | export * from "./title.ts"; 4 | export * from "./parseAbsoluteLink.ts"; 5 | -------------------------------------------------------------------------------- /parser/__snapshots__/anchor-fm.test.ts.snap: -------------------------------------------------------------------------------- 1 | export const snapshot = {}; 2 | 3 | snapshot[`spotify links > is 1`] = `"1-FM-e1gh6a7/a-a7m2veg"`; 4 | 5 | snapshot[`spotify links > is not 1`] = `undefined`; 6 | 7 | snapshot[`spotify links > is not 2`] = `undefined`; 8 | 9 | snapshot[`spotify links > is not 3`] = `undefined`; 10 | 11 | snapshot[`spotify links > is not 4`] = `undefined`; 12 | -------------------------------------------------------------------------------- /parser/__snapshots__/spotify.test.ts.snap: -------------------------------------------------------------------------------- 1 | export const snapshot = {}; 2 | 3 | snapshot[`spotify links > is 1`] = ` 4 | { 5 | pathType: "track", 6 | videoId: "0rlYL6IQIwLZwYIguyy3l0", 7 | } 8 | `; 9 | 10 | snapshot[`spotify links > is 2`] = ` 11 | { 12 | pathType: "album", 13 | videoId: "1bgUOjg3V0a7tvEfF1N6Kk", 14 | } 15 | `; 16 | 17 | snapshot[`spotify links > is 3`] = ` 18 | { 19 | pathType: "episode", 20 | videoId: "0JtPGoprZK2WlYMjhFF2xD", 21 | } 22 | `; 23 | 24 | snapshot[`spotify links > is 4`] = ` 25 | { 26 | pathType: "playlist", 27 | videoId: "2uOyQytSjDq9GF5z1RJj5w", 28 | } 29 | `; 30 | 31 | snapshot[`spotify links > is not 1`] = `undefined`; 32 | 33 | snapshot[`spotify links > is not 2`] = `undefined`; 34 | 35 | snapshot[`spotify links > is not 3`] = `undefined`; 36 | 37 | snapshot[`spotify links > is not 4`] = `undefined`; 38 | -------------------------------------------------------------------------------- /parser/__snapshots__/vimeo.test.ts.snap: -------------------------------------------------------------------------------- 1 | export const snapshot = {}; 2 | 3 | snapshot[`vimeo links > is 1`] = `"121284607"`; 4 | 5 | snapshot[`vimeo links > is not 1`] = `undefined`; 6 | 7 | snapshot[`vimeo links > is not 2`] = `undefined`; 8 | 9 | snapshot[`vimeo links > is not 3`] = `undefined`; 10 | 11 | snapshot[`vimeo links > is not 4`] = `undefined`; 12 | -------------------------------------------------------------------------------- /parser/__snapshots__/youtube.test.ts.snap: -------------------------------------------------------------------------------- 1 | export const snapshot = {}; 2 | 3 | snapshot[`youtube links > is 1`] = ` 4 | { 5 | params: URLSearchParams { 6 | [Symbol("[[webidl.brand]]")]: Symbol("[[webidl.brand]]"), 7 | [Symbol("url object")]: URL { 8 | hash: "", 9 | host: "www.youtube.com", 10 | hostname: "www.youtube.com", 11 | href: "https://www.youtube.com/watch?v=LSvaOcaUQ3Y", 12 | origin: "https://www.youtube.com", 13 | password: "", 14 | pathname: "/watch", 15 | port: "", 16 | protocol: "https:", 17 | search: "?v=LSvaOcaUQ3Y", 18 | username: "", 19 | }, 20 | [Symbol(list)]: [ 21 | [ 22 | "v", 23 | "LSvaOcaUQ3Y", 24 | ], 25 | ], 26 | }, 27 | pathType: "com", 28 | videoId: "LSvaOcaUQ3Y", 29 | } 30 | `; 31 | 32 | snapshot[`youtube links > is 2`] = ` 33 | { 34 | listId: "PLmoRDY8IgE2Okxy4WWdP95RHXOTGzJfQs", 35 | params: URLSearchParams { 36 | [Symbol("[[webidl.brand]]")]: Symbol("[[webidl.brand]]"), 37 | [Symbol("url object")]: null, 38 | [Symbol(list)]: [ 39 | [ 40 | "list", 41 | "PLmoRDY8IgE2Okxy4WWdP95RHXOTGzJfQs", 42 | ], 43 | ], 44 | }, 45 | pathType: "list", 46 | } 47 | `; 48 | 49 | snapshot[`youtube links > is 3`] = ` 50 | { 51 | params: URLSearchParams { 52 | [Symbol("[[webidl.brand]]")]: Symbol("[[webidl.brand]]"), 53 | [Symbol("url object")]: URL { 54 | hash: "", 55 | host: "www.youtube.com", 56 | hostname: "www.youtube.com", 57 | href: "https://www.youtube.com/watch?v=57rdbK4vmKE&list=PLmoRDY8IgE2Okxy4WWdP95RHXOTGzJfQs", 58 | origin: "https://www.youtube.com", 59 | password: "", 60 | pathname: "/watch", 61 | port: "", 62 | protocol: "https:", 63 | search: "?v=57rdbK4vmKE&list=PLmoRDY8IgE2Okxy4WWdP95RHXOTGzJfQs", 64 | username: "", 65 | }, 66 | [Symbol(list)]: [ 67 | [ 68 | "v", 69 | "57rdbK4vmKE", 70 | ], 71 | [ 72 | "list", 73 | "PLmoRDY8IgE2Okxy4WWdP95RHXOTGzJfQs", 74 | ], 75 | ], 76 | }, 77 | pathType: "com", 78 | videoId: "57rdbK4vmKE", 79 | } 80 | `; 81 | 82 | snapshot[`youtube links > is 4`] = ` 83 | { 84 | params: URLSearchParams { 85 | [Symbol("[[webidl.brand]]")]: Symbol("[[webidl.brand]]"), 86 | [Symbol("url object")]: URL { 87 | hash: "", 88 | host: "music.youtube.com", 89 | hostname: "music.youtube.com", 90 | href: "https://music.youtube.com/watch?v=nj1cre2e6t0", 91 | origin: "https://music.youtube.com", 92 | password: "", 93 | pathname: "/watch", 94 | port: "", 95 | protocol: "https:", 96 | search: "?v=nj1cre2e6t0", 97 | username: "", 98 | }, 99 | [Symbol(list)]: [ 100 | [ 101 | "v", 102 | "nj1cre2e6t0", 103 | ], 104 | ], 105 | }, 106 | pathType: "com", 107 | videoId: "nj1cre2e6t0", 108 | } 109 | `; 110 | 111 | snapshot[`youtube links > is 5`] = ` 112 | { 113 | params: URLSearchParams { 114 | [Symbol("[[webidl.brand]]")]: Symbol("[[webidl.brand]]"), 115 | [Symbol("url object")]: null, 116 | [Symbol(list)]: [], 117 | }, 118 | pathType: "dotbe", 119 | videoId: "nj1cre2e6t0", 120 | } 121 | `; 122 | 123 | snapshot[`youtube links > is not 1`] = `undefined`; 124 | 125 | snapshot[`youtube links > is not 2`] = `undefined`; 126 | 127 | snapshot[`youtube links > is not 3`] = `undefined`; 128 | 129 | snapshot[`youtube links > is not 4`] = `undefined`; 130 | -------------------------------------------------------------------------------- /parser/anchor-fm.test.ts: -------------------------------------------------------------------------------- 1 | import { parseAnchorFM } from "./anchor-fm.ts"; 2 | import { assertSnapshot } from "@std/testing/snapshot"; 3 | 4 | Deno.test("spotify links", async (t) => { 5 | await t.step("is", async (t) => { 6 | await assertSnapshot( 7 | t, 8 | parseAnchorFM( 9 | "https://anchor.fm/notainc/episodes/1-FM-e1gh6a7/a-a7m2veg", 10 | ), 11 | ); 12 | }); 13 | 14 | await t.step("is not", async (t) => { 15 | await assertSnapshot( 16 | t, 17 | parseAnchorFM( 18 | "https://gyazo.com/da78df293f9e83a74b5402411e2f2e01", 19 | ), 20 | ); 21 | await assertSnapshot( 22 | t, 23 | parseAnchorFM( 24 | "ほげほげ", 25 | ), 26 | ); 27 | await assertSnapshot( 28 | t, 29 | parseAnchorFM( 30 | "https://yourtube.com/watch?v=rafere", 31 | ), 32 | ); 33 | await assertSnapshot( 34 | t, 35 | parseAnchorFM( 36 | "https://example.com", 37 | ), 38 | ); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /parser/anchor-fm.ts: -------------------------------------------------------------------------------- 1 | const AnchorFMRegExp = 2 | /https?:\/\/anchor\.fm\/[a-zA-Z\d_-]+\/episodes\/([a-zA-Z\d_-]+(?:\/[a-zA-Z\d_-]+)?)(?:\?[^\s]{0,100}|)/; 3 | 4 | /** Extract the episode ID from an Anchor FM URL 5 | * 6 | * This function parses Anchor FM podcast episode URLs and extracts their unique 7 | * episode identifiers. It supports various Anchor FM URL formats including: 8 | * - https://anchor.fm/[show]/episodes/[episode-id] 9 | * - https://anchor.fm/[show]/episodes/[episode-id]/[additional-path] 10 | * - https://anchor.fm/[show]/episodes/[episode-id]?[query-params] 11 | * 12 | * @param url - The URL to parse, can be any string including non-Anchor FM URLs 13 | * @returns A {@linkcode Result}<{@linkcode string}, {@linkcode undefined}> containing: 14 | * - Success: The episode ID (e.g., "abc123" from "https://anchor.fm/show/episodes/abc123") 15 | * - Error: {@linkcode undefined} if not a valid Anchor FM URL 16 | */ 17 | export const parseAnchorFM = (url: string): string | undefined => { 18 | const matches = url.match(AnchorFMRegExp); 19 | if (!matches) return undefined; 20 | 21 | const [, videoId] = matches; 22 | return videoId; 23 | }; 24 | -------------------------------------------------------------------------------- /parser/spotify.test.ts: -------------------------------------------------------------------------------- 1 | import { parseSpotify } from "./spotify.ts"; 2 | import { assertSnapshot } from "@std/testing/snapshot"; 3 | 4 | /** Tests for the parseSpotify function which extracts IDs from Spotify URLs 5 | * These tests verify that the function correctly handles various Spotify URL formats 6 | * and returns undefined for non-Spotify URLs 7 | */ 8 | Deno.test("spotify links", async (t) => { 9 | /** Test valid Spotify URLs for different content types 10 | * - Track URLs: /track/{id} 11 | * - Album URLs: /album/{id} 12 | * - Episode URLs: /episode/{id} (podcasts) 13 | * - Playlist URLs: /playlist/{id} 14 | * Each URL may optionally include query parameters 15 | */ 16 | await t.step("is", async (t) => { 17 | await assertSnapshot( 18 | t, 19 | parseSpotify("https://open.spotify.com/track/0rlYL6IQIwLZwYIguyy3l0"), 20 | ); 21 | await assertSnapshot( 22 | t, 23 | parseSpotify("https://open.spotify.com/album/1bgUOjg3V0a7tvEfF1N6Kk"), 24 | ); 25 | await assertSnapshot( 26 | t, 27 | parseSpotify( 28 | "https://open.spotify.com/episode/0JtPGoprZK2WlYMjhFF2xD?si=1YLMdgNpSHOuWkaEmCAQ0g", 29 | ), 30 | ); 31 | await assertSnapshot( 32 | t, 33 | parseSpotify( 34 | "https://open.spotify.com/playlist/2uOyQytSjDq9GF5z1RJj5w?si=e73cac2a2a294f7a", 35 | ), 36 | ); 37 | }); 38 | 39 | /** Test invalid URLs and non-Spotify content 40 | * Verifies that the function returns undefined for: 41 | * - URLs from other services (e.g., Gyazo) 42 | * - Plain text that looks like URLs 43 | * - URLs with similar patterns but from different domains 44 | * - Generic URLs 45 | */ 46 | await t.step("is not", async (t) => { 47 | await assertSnapshot( 48 | t, 49 | parseSpotify( 50 | "https://gyazo.com/da78df293f9e83a74b5402411e2f2e01", 51 | ), 52 | ); 53 | await assertSnapshot( 54 | t, 55 | parseSpotify( 56 | "ほげほげ", 57 | ), 58 | ); 59 | await assertSnapshot( 60 | t, 61 | parseSpotify( 62 | "https://yourtube.com/watch?v=rafere", 63 | ), 64 | ); 65 | await assertSnapshot( 66 | t, 67 | parseSpotify( 68 | "https://example.com", 69 | ), 70 | ); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /parser/spotify.ts: -------------------------------------------------------------------------------- 1 | const spotifyRegExp = 2 | /https?:\/\/open\.spotify\.com\/(track|artist|playlist|album|episode|show)\/([a-zA-Z\d_-]+)(?:\?[^\s]{0,100}|)/; 3 | /** Properties extracted from a Spotify URL 4 | * @property videoId - The unique identifier for the Spotify content (track, artist, playlist, etc.) 5 | * @property pathType - The type of content, which determines how the ID should be used: 6 | * - "track": A single song or audio track 7 | * - "artist": An artist's profile page 8 | * - "playlist": A user-created collection of tracks 9 | * - "album": A collection of tracks released as a single unit 10 | * - "episode": A single podcast episode 11 | * - "show": A podcast series 12 | */ 13 | export interface SpotifyProps { 14 | videoId: string; 15 | pathType: "track" | "artist" | "playlist" | "album" | "episode" | "show"; 16 | } 17 | 18 | /** Parse a Spotify URL to extract content ID and type 19 | * 20 | * This function analyzes URLs from open.spotify.com and extracts both the content ID 21 | * and the type of content. It supports various Spotify content types including: 22 | * - Tracks (songs) 23 | * - Artist profiles 24 | * - Playlists 25 | * - Albums 26 | * - Podcast episodes 27 | * - Podcast shows 28 | * 29 | * The function handles URLs in the format: 30 | * https://open.spotify.com/{type}/{id}[?query_params] 31 | * 32 | * @param url - The URL to parse, can be any string including non-Spotify URLs 33 | * @returns A {@linkcode Result}<{@linkcode SpotifyProps}, {@linkcode undefined}> containing: 34 | * - Success: The content information with: 35 | * - videoId: The unique content identifier 36 | * - pathType: Content type ("track", "artist", "playlist", "album", "episode", or "show") 37 | * - Error: {@linkcode undefined} if not a valid Spotify URL 38 | */ 39 | export const parseSpotify = (url: string): SpotifyProps | undefined => { 40 | const matches = url.match(spotifyRegExp); 41 | if (!matches) return undefined; 42 | 43 | const [, pathType, videoId] = matches; 44 | return { 45 | videoId, 46 | pathType, 47 | } as SpotifyProps; 48 | }; 49 | -------------------------------------------------------------------------------- /parser/vimeo.ts: -------------------------------------------------------------------------------- 1 | const vimeoRegExp = /https?:\/\/vimeo\.com\/([0-9]+)/i; 2 | 3 | /** Extract the video ID from a Vimeo URL 4 | * 5 | * This function parses Vimeo video URLs to extract their numeric video IDs. 6 | * Vimeo uses a simple URL structure where each video has a unique numeric ID: 7 | * https://vimeo.com/{video_id} 8 | * 9 | * For example: 10 | * - https://vimeo.com/123456789 -> returns "123456789" 11 | * - https://vimeo.com/groups/123 -> returns undefined (not a video URL) 12 | * - https://vimeo.com/channels/123 -> returns undefined (not a video URL) 13 | * 14 | * @param url - The URL to parse, can be any string including non-Vimeo URLs 15 | * @returns A {@linkcode Result}<{@linkcode string}, {@linkcode undefined}> containing: 16 | * - Success: The numeric video ID if the URL matches the {@linkcode Vimeo} video pattern 17 | * - Error: {@linkcode undefined} if not a valid Vimeo video URL 18 | */ 19 | export const parseVimeo = (url: string): string | undefined => { 20 | const matches = url.match(vimeoRegExp); 21 | if (!matches) return undefined; 22 | return matches[1]; 23 | }; 24 | -------------------------------------------------------------------------------- /parser/youtube.test.ts: -------------------------------------------------------------------------------- 1 | import { parseYoutube } from "./youtube.ts"; 2 | import { assertSnapshot } from "@std/testing/snapshot"; 3 | 4 | /** Test suite for YouTube URL parsing functionality 5 | * This test suite verifies the parseYoutube function's ability to handle various 6 | * YouTube URL formats and invalid inputs using snapshot testing. 7 | */ 8 | Deno.test("youtube links", async (t) => { 9 | /** Test valid YouTube URL formats 10 | * Verifies parsing of: 11 | * - Standard watch URLs (youtube.com/watch?v=...) 12 | * - Playlist URLs (youtube.com/playlist?list=...) 13 | * - Watch URLs within playlists 14 | * - YouTube Music URLs (music.youtube.com) 15 | * - Short URLs (youtu.be/...) 16 | */ 17 | await t.step("is", async (t) => { 18 | await assertSnapshot( 19 | t, 20 | parseYoutube("https://www.youtube.com/watch?v=LSvaOcaUQ3Y"), 21 | ); 22 | await assertSnapshot( 23 | t, 24 | parseYoutube( 25 | "https://www.youtube.com/playlist?list=PLmoRDY8IgE2Okxy4WWdP95RHXOTGzJfQs", 26 | ), 27 | ); 28 | await assertSnapshot( 29 | t, 30 | parseYoutube( 31 | "https://www.youtube.com/watch?v=57rdbK4vmKE&list=PLmoRDY8IgE2Okxy4WWdP95RHXOTGzJfQs", 32 | ), 33 | ); 34 | await assertSnapshot( 35 | t, 36 | parseYoutube( 37 | "https://music.youtube.com/watch?v=nj1cre2e6t0", 38 | ), 39 | ); 40 | await assertSnapshot( 41 | t, 42 | parseYoutube( 43 | "https://youtu.be/nj1cre2e6t0", 44 | ), 45 | ); 46 | }); 47 | 48 | /** Test invalid URL formats 49 | * Verifies that the function correctly returns undefined for: 50 | * - URLs from other services (e.g., Gyazo) 51 | * - Non-URL strings (including Japanese text) 52 | * - Similar but invalid domains (e.g., "yourtube.com") 53 | * - Generic URLs 54 | */ 55 | await t.step("is not", async (t) => { 56 | await assertSnapshot( 57 | t, 58 | parseYoutube( 59 | "https://gyazo.com/da78df293f9e83a74b5402411e2f2e01", 60 | ), 61 | ); 62 | await assertSnapshot( 63 | t, 64 | parseYoutube( 65 | "test_text", 66 | ), 67 | ); 68 | await assertSnapshot( 69 | t, 70 | parseYoutube( 71 | "https://yourtube.com/watch?v=rafere", 72 | ), 73 | ); 74 | await assertSnapshot( 75 | t, 76 | parseYoutube( 77 | "https://example.com", 78 | ), 79 | ); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /parser/youtube.ts: -------------------------------------------------------------------------------- 1 | // ported from https://github.com/takker99/ScrapBubble/blob/0.4.0/Page.tsx#L662 2 | 3 | /** Regular expressions for matching different YouTube URL formats */ 4 | // Matches standard youtube.com/watch URLs (including music.youtube.com) 5 | const youtubeRegExp = /https?:\/\/(?:www\.|music\.|)youtube\.com\/watch/; 6 | // Matches short youtu.be URLs with optional query parameters 7 | const youtubeDotBeRegExp = 8 | /https?:\/\/youtu\.be\/([a-zA-Z\d_-]+)(?:\?([^\s]{0,100})|)/; 9 | // Matches YouTube Shorts URLs 10 | const youtubeShortRegExp = 11 | /https?:\/\/(?:www\.|)youtube\.com\/shorts\/([a-zA-Z\d_-]+)(?:\?([^\s]+)|)/; 12 | // Matches playlist URLs (including music.youtube.com playlists) 13 | const youtubeListRegExp = 14 | /https?:\/\/(?:www\.|music\.|)youtube\.com\/playlist\?((?:[^\s]+&|)list=([a-zA-Z\d_-]+)(?:&[^\s]+|))/; 15 | 16 | /** Properties extracted from a YouTube URL 17 | * This type represents the parsed data from different types of YouTube URLs. 18 | * It's a union type that handles both video-related URLs and playlist URLs. 19 | * 20 | * For video URLs (standard, short URLs, or youtu.be links): 21 | * @property params - URL query parameters (e.g., timestamp, playlist reference) 22 | * @property videoId - The unique identifier of the video 23 | * @property pathType - The URL format type: 24 | * - "com": Standard youtube.com/watch?v= format 25 | * - "dotbe": Short youtu.be/ format 26 | * - "short": YouTube Shorts format 27 | * 28 | * For playlist URLs: 29 | * @property params - URL query parameters 30 | * @property listId - The unique identifier of the playlist 31 | * @property pathType - Always "list" for playlist URLs 32 | */ 33 | export type YoutubeProps = { 34 | params: URLSearchParams; 35 | videoId: string; 36 | pathType: "com" | "dotbe" | "short"; 37 | } | { 38 | params: URLSearchParams; 39 | listId: string; 40 | pathType: "list"; 41 | }; 42 | 43 | /** Parse a YouTube URL to extract video/playlist ID and other properties 44 | * 45 | * This function handles various YouTube URL formats: 46 | * 1. Standard video URLs: 47 | * - https://www.youtube.com/watch?v={videoId} 48 | * - https://music.youtube.com/watch?v={videoId} 49 | * 50 | * 2. Short URLs: 51 | * - https://youtu.be/{videoId} 52 | * - Can include optional query parameters 53 | * 54 | * 3. YouTube Shorts: 55 | * - https://youtube.com/shorts/{videoId} 56 | * - https://www.youtube.com/shorts/{videoId} 57 | * 58 | * 4. Playlist URLs: 59 | * - https://youtube.com/playlist?list={listId} 60 | * - https://music.youtube.com/playlist?list={listId} 61 | * 62 | * The function preserves all query parameters from the original URL. 63 | * 64 | * @param url - Any URL or string to parse 65 | * @returns A {@linkcode Result}<{@linkcode YoutubeProps}, {@linkcode undefined}> containing: 66 | * - Success: The extracted video/playlist information with: 67 | * - For videos: videoId, params, and pathType ("com", "dotbe", or "short") 68 | * - For playlists: listId, params, and pathType ("list") 69 | * - Error: {@linkcode undefined} if not a valid YouTube URL 70 | */ 71 | export const parseYoutube = (url: string): YoutubeProps | undefined => { 72 | if (youtubeRegExp.test(url)) { 73 | const params = new URL(url).searchParams; 74 | const videoId = params.get("v"); 75 | if (videoId) { 76 | return { 77 | pathType: "com", 78 | videoId, 79 | params, 80 | }; 81 | } 82 | } 83 | 84 | { 85 | const matches = url.match(youtubeDotBeRegExp); 86 | if (matches) { 87 | const [, videoId, params] = matches; 88 | return { 89 | videoId, 90 | params: new URLSearchParams(params), 91 | pathType: "dotbe", 92 | }; 93 | } 94 | } 95 | 96 | { 97 | const matches = url.match(youtubeShortRegExp); 98 | if (matches) { 99 | const [, videoId, params] = matches; 100 | return { 101 | videoId, 102 | params: new URLSearchParams(params), 103 | pathType: "short", 104 | }; 105 | } 106 | } 107 | 108 | { 109 | const matches = url.match(youtubeListRegExp); 110 | if (matches) { 111 | const [, params, listId] = matches; 112 | 113 | return { listId, params: new URLSearchParams(params), pathType: "list" }; 114 | } 115 | } 116 | 117 | return undefined; 118 | }; 119 | -------------------------------------------------------------------------------- /rest/__snapshots__/pages.test.ts.snap: -------------------------------------------------------------------------------- 1 | export const snapshot = {}; 2 | 3 | snapshot[`getPage 1`] = ` 4 | Request { 5 | bodyUsed: false, 6 | headers: Headers {}, 7 | method: "GET", 8 | redirect: "follow", 9 | url: "https://scrapbox.io/api/pages/takker/%E3%83%86%E3%82%B9%E3%83%88%E3%83%9A%E3%83%BC%E3%82%B8?followRename=true", 10 | } 11 | `; 12 | 13 | snapshot[`listPages 1`] = ` 14 | Request { 15 | bodyUsed: false, 16 | headers: Headers {}, 17 | method: "GET", 18 | redirect: "follow", 19 | url: "https://scrapbox.io/api/pages/takker?sort=updated", 20 | } 21 | `; 22 | -------------------------------------------------------------------------------- /rest/__snapshots__/project.test.ts.snap: -------------------------------------------------------------------------------- 1 | export const snapshot = {}; 2 | 3 | snapshot[`getProject 1`] = ` 4 | Request { 5 | bodyUsed: false, 6 | headers: Headers {}, 7 | method: "GET", 8 | redirect: "follow", 9 | url: "https://scrapbox.io/api/projects/takker", 10 | } 11 | `; 12 | 13 | snapshot[`listProjects 1`] = ` 14 | Request { 15 | bodyUsed: false, 16 | headers: Headers {}, 17 | method: "GET", 18 | redirect: "follow", 19 | url: "https://scrapbox.io/api/projects?ids=dummy-id1&ids=dummy-id2", 20 | } 21 | `; 22 | -------------------------------------------------------------------------------- /rest/auth.ts: -------------------------------------------------------------------------------- 1 | import { createOk, mapForResult, type Result } from "option-t/plain_result"; 2 | import { getProfile } from "./profile.ts"; 3 | import type { HTTPError } from "./responseIntoResult.ts"; 4 | import type { AbortError, NetworkError } from "./robustFetch.ts"; 5 | import type { ExtendedOptions } from "./options.ts"; 6 | 7 | /** Create a cookie string for HTTP headers 8 | * 9 | * This function creates a properly formatted cookie string for the `connect.sid` 10 | * session identifier, which is used for authentication in Scrapbox. 11 | * 12 | * @param sid - The session ID string stored in `connect.sid` 13 | * @returns A formatted {@linkcode string} in the format `"connect.sid={@linkcode sid}"` 14 | */ 15 | export const cookie = (sid: string): string => `connect.sid=${sid}`; 16 | 17 | /** Retrieve the CSRF token for secure requests 18 | * 19 | * CSRF (Cross-Site Request Forgery) tokens are security measures that protect 20 | * against unauthorized requests. This function retrieves the token either from: 21 | * 1. `init.csrf` 22 | * 2. `globalThis._csrf` 23 | * 3. The user profile (if neither of the above is available) 24 | * 25 | * @param init - Optional {@linkcode ExtendedOptions} configuration including authentication details 26 | * and CSRF token. If not provided, the function will attempt 27 | * to get the token from other sources. 28 | * @returns A {@linkcode Result}<{@linkcode string}, {@linkcode NetworkError} | {@linkcode AbortError} | {@linkcode HTTPError}> containing: 29 | * - Success: The CSRF token as a {@linkcode string} 30 | * - Error: A {@linkcode NetworkError}, {@linkcode AbortError}, or {@linkcode HTTPError} describing what went wrong 31 | * - Success: The CSRF token string 32 | * - Error: One of several possible errors: 33 | * - {@linkcode NetworkError}: Network connectivity issues 34 | * - {@linkcode AbortError}: Request was aborted 35 | * - {@linkcode HTTPError}: Server response error 36 | */ 37 | export const getCSRFToken = async ( 38 | init?: ExtendedOptions, 39 | ): Promise> => { 40 | // deno-lint-ignore no-explicit-any 41 | const csrf = init?.csrf ?? (globalThis as any)._csrf; 42 | return csrf ? createOk(csrf) : mapForResult( 43 | await getProfile(init), 44 | (user) => user.csrfToken, 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /rest/getCachedAt.ts: -------------------------------------------------------------------------------- 1 | /** Get the timestamp when a response was cached by the ServiceWorker 2 | * 3 | * This function retrieves the timestamp when a Response was cached by the 4 | * ServiceWorker, using a custom header `x-serviceworker-cached`. ServiceWorkers 5 | * are web workers that act as proxy servers between web apps, the browser, 6 | * and the network, enabling offline functionality and faster page loads. 7 | * 8 | * @param res - The Response object to check for cache information 9 | * @returns 10 | * - A number representing the UNIX timestamp (milliseconds since epoch) when 11 | * the response was cached by the ServiceWorker 12 | * - `undefined` if: 13 | * 1. The response wasn't cached (no `x-serviceworker-cached` header) 14 | * 2. The header value couldn't be parsed as a number 15 | * 16 | * @example 17 | * ```typescript 18 | * const response = await fetch('/api/data'); 19 | * const cachedAt = getCachedAt(response); 20 | * if (cachedAt) { 21 | * console.log(`Data was cached at: ${new Date(cachedAt)}`); 22 | * } else { 23 | * console.log('This is a fresh response from the server'); 24 | * } 25 | * ``` 26 | */ 27 | export const getCachedAt = (res: Response): number | undefined => { 28 | const cachedAt = res.headers.get("x-serviceworker-cached"); 29 | if (!cachedAt) return; 30 | return parseInt(cachedAt); 31 | }; 32 | -------------------------------------------------------------------------------- /rest/getCodeBlock.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | NotFoundError, 3 | NotLoggedInError, 4 | NotMemberError, 5 | } from "@cosense/types/rest"; 6 | import { cookie } from "./auth.ts"; 7 | import { encodeTitleURI } from "../title.ts"; 8 | import { type BaseOptions, setDefaults } from "./options.ts"; 9 | import { 10 | isErr, 11 | mapAsyncForResult, 12 | mapErrAsyncForResult, 13 | type Result, 14 | unwrapOk, 15 | } from "option-t/plain_result"; 16 | import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; 17 | import { parseHTTPError } from "./parseHTTPError.ts"; 18 | import type { FetchError } from "./mod.ts"; 19 | 20 | const getCodeBlock_toRequest: GetCodeBlock["toRequest"] = ( 21 | project, 22 | title, 23 | filename, 24 | options, 25 | ) => { 26 | const { sid, hostName } = setDefaults(options ?? {}); 27 | 28 | return new Request( 29 | `https://${hostName}/api/code/${project}/${encodeTitleURI(title)}/${ 30 | encodeTitleURI(filename) 31 | }`, 32 | sid ? { headers: { Cookie: cookie(sid) } } : undefined, 33 | ); 34 | }; 35 | 36 | const getCodeBlock_fromResponse: GetCodeBlock["fromResponse"] = async (res) => 37 | mapAsyncForResult( 38 | await mapErrAsyncForResult( 39 | responseIntoResult(res), 40 | async (res) => 41 | res.response.status === 404 && 42 | res.response.headers.get("Content-Type")?.includes?.("text/plain") 43 | ? { name: "NotFoundError", message: "Code block is not found" } 44 | : (await parseHTTPError(res, [ 45 | "NotLoggedInError", 46 | "NotMemberError", 47 | ])) ?? res, 48 | ), 49 | (res) => res.text(), 50 | ); 51 | 52 | export interface GetCodeBlock { 53 | /** Build a request for `/api/code/:project/:title/:filename` 54 | * 55 | * @param project - Name of the project containing the target page 56 | * @param title - Title of the target page (case-insensitive) 57 | * @param filename - Name of the code block file to retrieve 58 | * @param options - Configuration options 59 | * @returns A {@linkcode Request} object for fetching code block content 60 | */ 61 | toRequest: ( 62 | project: string, 63 | title: string, 64 | filename: string, 65 | options?: BaseOptions, 66 | ) => Request; 67 | 68 | /** Extract code from the response 69 | * 70 | * @param res - Response from the API 71 | * @returns A {@linkcode Result}<{@linkcode string}, {@linkcode Error}> containing: 72 | * - Success: The code block content as a string 73 | * - Error: One of several possible errors: 74 | * - {@linkcode NotFoundError}: Code block not found 75 | * - {@linkcode NotLoggedInError}: Authentication required 76 | * - {@linkcode NotMemberError}: User lacks access 77 | * - {@linkcode HTTPError}: Other HTTP errors 78 | */ 79 | fromResponse: (res: Response) => Promise>; 80 | 81 | ( 82 | project: string, 83 | title: string, 84 | filename: string, 85 | options?: BaseOptions, 86 | ): Promise>; 87 | } 88 | export type CodeBlockError = 89 | | NotFoundError 90 | | NotLoggedInError 91 | | NotMemberError 92 | | HTTPError; 93 | 94 | /** Retrieve text content from a specified code block 95 | * 96 | * @param project Name of the project containing the target page 97 | * @param title Title of the target page (case-insensitive) 98 | * @param filename Name of the code block file to retrieve 99 | * @param options Configuration options 100 | */ 101 | export const getCodeBlock: GetCodeBlock = /* @__PURE__ */ (() => { 102 | const fn: GetCodeBlock = async ( 103 | project, 104 | title, 105 | filename, 106 | options, 107 | ) => { 108 | const req = getCodeBlock_toRequest(project, title, filename, options); 109 | const res = await setDefaults(options ?? {}).fetch(req); 110 | return isErr(res) ? res : getCodeBlock_fromResponse(unwrapOk(res)); 111 | }; 112 | 113 | fn.toRequest = getCodeBlock_toRequest; 114 | fn.fromResponse = getCodeBlock_fromResponse; 115 | 116 | return fn; 117 | })(); 118 | -------------------------------------------------------------------------------- /rest/getGyazoToken.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isErr, 3 | mapAsyncForResult, 4 | mapErrAsyncForResult, 5 | type Result, 6 | unwrapOk, 7 | } from "option-t/plain_result"; 8 | import type { NotLoggedInError } from "@cosense/types/rest"; 9 | import { cookie } from "./auth.ts"; 10 | import { parseHTTPError } from "./parseHTTPError.ts"; 11 | import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; 12 | import { type BaseOptions, setDefaults } from "./options.ts"; 13 | import type { FetchError } from "./mod.ts"; 14 | 15 | export interface GetGyazoTokenOptions extends BaseOptions { 16 | /** The team name for Gyazo Teams 17 | * 18 | * Specify this parameter when you want to upload images to a Gyazo Teams workspace. 19 | * If not provided, the image will be uploaded to your personal Gyazo account. 20 | * 21 | * @example 22 | * ```typescript 23 | * import { isErr, unwrapErr, unwrapOk } from "option-t/plain_result"; 24 | * 25 | * const result = await getGyazoToken({ gyazoTeamsName: "my-team" }); 26 | * if (isErr(result)) { 27 | * throw new Error(`Failed to get Gyazo token: ${unwrapErr(result)}`); 28 | * } 29 | * const token = unwrapOk(result); 30 | * ``` 31 | */ 32 | gyazoTeamsName?: string; 33 | } 34 | 35 | export type GyazoTokenError = NotLoggedInError | HTTPError; 36 | 37 | /** Retrieve an OAuth access token for uploading images to Gyazo 38 | * 39 | * This function obtains an OAuth access token that can be used to upload images 40 | * to Gyazo or Gyazo Teams. The token is obtained through Scrapbox's API, which 41 | * handles the OAuth flow with Gyazo. 42 | * 43 | * @param init - Optional configuration for the Gyazo token request, including: 44 | * - gyazoTeamsName: Optional team name for Gyazo Teams workspace 45 | * - sid: Optional session ID for authentication 46 | * - hostName: Optional custom hostname (defaults to scrapbox.io) 47 | * - fetch: Optional custom fetch implementation 48 | * @returns A {@linkcode Result} containing: 49 | * - Success: The access token string, or `undefined` if no token is available 50 | * - Error: One of several possible errors: 51 | * - {@linkcode NotLoggedInError}: User is not authenticated with Scrapbox 52 | * - {@linkcode HTTPError}: Network or server-side error occurred 53 | * - {@linkcode FetchError}: Network request failed 54 | * 55 | * @example 56 | * ```typescript 57 | * import { isErr, unwrapErr, unwrapOk } from "option-t/plain_result"; 58 | * 59 | * const result = await getGyazoToken(); 60 | * if (isErr(result)) { 61 | * throw new Error(`Failed to get Gyazo token: ${unwrapErr(result)}`); 62 | * } 63 | * const token = unwrapOk(result); 64 | * ``` 65 | */ 66 | export const getGyazoToken = async ( 67 | init?: GetGyazoTokenOptions, 68 | ): Promise> => { 69 | const { fetch, sid, hostName, gyazoTeamsName } = setDefaults(init ?? {}); 70 | const req = new Request( 71 | `https://${hostName}/api/login/gyazo/oauth-upload/token${ 72 | gyazoTeamsName ? `?gyazoTeamsName=${gyazoTeamsName}` : "" 73 | }`, 74 | sid ? { headers: { Cookie: cookie(sid) } } : undefined, 75 | ); 76 | 77 | const res = await fetch(req); 78 | if (isErr(res)) return res; 79 | 80 | return mapAsyncForResult( 81 | await mapErrAsyncForResult( 82 | responseIntoResult(unwrapOk(res)), 83 | async (error) => 84 | (await parseHTTPError(error, ["NotLoggedInError"])) ?? error, 85 | ), 86 | (res) => res.json().then((json) => json.token as string | undefined), 87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /rest/getTweetInfo.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isErr, 3 | mapAsyncForResult, 4 | mapErrAsyncForResult, 5 | type Result, 6 | unwrapOk, 7 | } from "option-t/plain_result"; 8 | import type { 9 | BadRequestError, 10 | InvalidURLError, 11 | SessionError, 12 | TweetInfo, 13 | } from "@cosense/types/rest"; 14 | import { cookie, getCSRFToken } from "./auth.ts"; 15 | import { parseHTTPError } from "./parseHTTPError.ts"; 16 | import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; 17 | import { type ExtendedOptions, setDefaults } from "./options.ts"; 18 | import type { FetchError } from "./mod.ts"; 19 | 20 | export type TweetInfoError = 21 | | SessionError 22 | | InvalidURLError 23 | | BadRequestError 24 | | HTTPError; 25 | 26 | /** Retrieve information about a specified Tweet 27 | * 28 | * Fetches metadata and content information for a given Tweet URL through Scrapbox's 29 | * Twitter embed API. This function handles authentication and CSRF token management 30 | * automatically. 31 | * 32 | * @param url - The URL of the Tweet to fetch information for. Can be either a {@linkcode string} 33 | * or {@linkcode URL} object. Should be a valid Twitter/X post URL. 34 | * @param init - Optional {@linkcode RequestInit} configuration for customizing request behavior and authentication 35 | * @returns A {@linkcode Result}<{@linkcode TweetInfo}, {@linkcode Error}> containing: 36 | * - Success: {@linkcode TweetInfo} object with Tweet metadata 37 | * - Error: One of several possible errors: 38 | * - {@linkcode SessionError}: Authentication issues 39 | * - {@linkcode InvalidURLError}: Malformed or invalid Tweet URL 40 | * - {@linkcode BadRequestError}: API request issues 41 | * - {@linkcode HTTPError}: Network or server errors 42 | * 43 | * @example 44 | * ```typescript 45 | * import { isErr, unwrapErr, unwrapOk } from "option-t/plain_result"; 46 | * 47 | * const result = await getTweetInfo("https://twitter.com/user/status/123456789"); 48 | * if (isErr(result)) { 49 | * throw new Error(`Failed to get Tweet info: ${unwrapErr(result)}`); 50 | * } 51 | * const tweetInfo = unwrapOk(result); 52 | * console.log("Tweet text:", tweetInfo.description); 53 | * ``` 54 | * 55 | * > [!NOTE] 56 | * > The function includes a 3000ms timeout for the API request. 57 | */ 58 | export const getTweetInfo = async ( 59 | url: string | URL, 60 | init?: ExtendedOptions, 61 | ): Promise> => { 62 | const { sid, hostName, fetch } = setDefaults(init ?? {}); 63 | 64 | const csrfResult = await getCSRFToken(init); 65 | if (isErr(csrfResult)) return csrfResult; 66 | 67 | const req = new Request( 68 | `https://${hostName}/api/embed-text/twitter?url=${ 69 | encodeURIComponent(`${url}`) 70 | }`, 71 | { 72 | method: "POST", 73 | headers: { 74 | "Content-Type": "application/json;charset=utf-8", 75 | "X-CSRF-TOKEN": unwrapOk(csrfResult), 76 | ...(sid ? { Cookie: cookie(sid) } : {}), 77 | }, 78 | body: JSON.stringify({ timeout: 3000 }), 79 | }, 80 | ); 81 | 82 | const res = await fetch(req); 83 | if (isErr(res)) return res; 84 | 85 | return mapErrAsyncForResult( 86 | await mapAsyncForResult( 87 | responseIntoResult(unwrapOk(res)), 88 | (res) => res.json() as Promise, 89 | ), 90 | async (res) => { 91 | if (res.response.status === 422) { 92 | return { 93 | name: "InvalidURLError", 94 | message: (await res.response.json()).message as string, 95 | }; 96 | } 97 | const parsed = await parseHTTPError(res, [ 98 | "SessionError", 99 | "BadRequestError", 100 | ]); 101 | return parsed ?? res; 102 | }, 103 | ); 104 | }; 105 | -------------------------------------------------------------------------------- /rest/getWebPageTitle.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isErr, 3 | mapAsyncForResult, 4 | mapErrAsyncForResult, 5 | type Result, 6 | unwrapOk, 7 | } from "option-t/plain_result"; 8 | import type { 9 | BadRequestError, 10 | InvalidURLError, 11 | SessionError, 12 | } from "@cosense/types/rest"; 13 | import { cookie, getCSRFToken } from "./auth.ts"; 14 | import { parseHTTPError } from "./parseHTTPError.ts"; 15 | import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; 16 | import { type ExtendedOptions, setDefaults } from "./options.ts"; 17 | import type { FetchError } from "./mod.ts"; 18 | 19 | export type WebPageTitleError = 20 | | SessionError 21 | | InvalidURLError 22 | | BadRequestError 23 | | HTTPError; 24 | 25 | /** Retrieve the title of a web page through Scrapbox's server 26 | * 27 | * This function fetches the title of a web page by making a request through 28 | * Scrapbox's server. This approach helps handle various edge cases and 29 | * authentication requirements that might be needed to access certain pages. 30 | * 31 | * @param url - The URL of the web page to fetch the title from. Can be either 32 | * a {@linkcode string} or {@linkcode URL} object. 33 | * @param init - Optional {@linkcode RequestInit} configuration for customizing the request behavior 34 | * @returns A {@linkcode Result}<{@linkcode string}, {@linkcode Error}> containing: 35 | * - Success: The page title as a string 36 | * - Error: One of several possible errors: 37 | * - {@linkcode SessionError}: Authentication issues 38 | * - {@linkcode InvalidURLError}: Malformed or invalid URL 39 | * - {@linkcode BadRequestError}: API request issues 40 | * - {@linkcode HTTPError}: Network or server errors 41 | * 42 | * @example 43 | * ```typescript 44 | * import { isErr, unwrapErr, unwrapOk } from "option-t/plain_result"; 45 | * 46 | * const result = await getWebPageTitle("https://example.com"); 47 | * if (isErr(result)) { 48 | * throw new Error(`Failed to get page title: ${unwrapErr(result)}`); 49 | * } 50 | * console.log("Page title:", unwrapOk(result)); 51 | * ``` 52 | * 53 | * > [!NOTE] 54 | * > The function includes a 3000ms timeout for the API request. 55 | */ 56 | export const getWebPageTitle = async ( 57 | url: string | URL, 58 | init?: ExtendedOptions, 59 | ): Promise> => { 60 | const { sid, hostName, fetch } = setDefaults(init ?? {}); 61 | 62 | const csrfResult = await getCSRFToken(init); 63 | if (isErr(csrfResult)) return csrfResult; 64 | 65 | const req = new Request( 66 | `https://${hostName}/api/embed-text/url?url=${ 67 | encodeURIComponent(`${url}`) 68 | }`, 69 | { 70 | method: "POST", 71 | headers: { 72 | "Content-Type": "application/json;charset=utf-8", 73 | "X-CSRF-TOKEN": unwrapOk(csrfResult), 74 | ...(sid ? { Cookie: cookie(sid) } : {}), 75 | }, 76 | body: JSON.stringify({ timeout: 3000 }), 77 | }, 78 | ); 79 | 80 | const res = await fetch(req); 81 | if (isErr(res)) return res; 82 | 83 | return mapAsyncForResult( 84 | await mapErrAsyncForResult( 85 | responseIntoResult(unwrapOk(res)), 86 | async (error) => 87 | (await parseHTTPError(error, [ 88 | "SessionError", 89 | "BadRequestError", 90 | "InvalidURLError", 91 | ])) ?? error, 92 | ), 93 | async (res) => { 94 | const { title } = (await res.json()) as { title: string }; 95 | return title; 96 | }, 97 | ); 98 | }; 99 | -------------------------------------------------------------------------------- /rest/mod.ts: -------------------------------------------------------------------------------- 1 | /** Cosense REST API wrapper 2 | * 3 | * @module 4 | */ 5 | 6 | export * from "./pages.ts"; 7 | export * from "./table.ts"; 8 | export * from "./project.ts"; 9 | export * from "./profile.ts"; 10 | export * from "./replaceLinks.ts"; 11 | export * from "./page-data.ts"; 12 | export * from "./snapshot.ts"; 13 | export * from "./link.ts"; 14 | export * from "./search.ts"; 15 | export * from "./getWebPageTitle.ts"; 16 | export * from "./getTweetInfo.ts"; 17 | export * from "./getGyazoToken.ts"; 18 | export * from "./auth.ts"; 19 | export type { BaseOptions, ExtendedOptions } from "./options.ts"; 20 | export * from "./getCodeBlocks.ts"; 21 | export * from "./getCodeBlock.ts"; 22 | export * from "./uploadToGCS.ts"; 23 | export * from "./getCachedAt.ts"; 24 | 25 | export type { HTTPError } from "./responseIntoResult.ts"; 26 | export type { AbortError, FetchError, NetworkError } from "./robustFetch.ts"; 27 | -------------------------------------------------------------------------------- /rest/options.ts: -------------------------------------------------------------------------------- 1 | import { type RobustFetch, robustFetch } from "./robustFetch.ts"; 2 | 3 | /** Common options shared across all REST API endpoints 4 | * 5 | * These options configure authentication, network behavior, and host settings 6 | * for all API requests in the library. 7 | */ 8 | export interface BaseOptions { 9 | /** Scrapbox session ID (connect.sid) 10 | * 11 | * Authentication token required to access: 12 | * - Private project data 13 | * - User-specific data linked to Scrapbox accounts 14 | * - Protected API endpoints 15 | */ 16 | sid?: string; 17 | 18 | /** Custom fetch implementation for making HTTP requests 19 | * 20 | * Allows overriding the default fetch behavior for testing 21 | * or custom networking requirements. 22 | * 23 | * @default {globalThis.fetch} 24 | */ 25 | fetch?: RobustFetch; 26 | 27 | /** Domain for REST API endpoints 28 | * 29 | * Configurable host name for API requests. This allows using the library 30 | * with self-hosted Scrapbox instances or other custom deployments that 31 | * don't use the default scrapbox.io domain. 32 | * 33 | * @default {"scrapbox.io"} 34 | */ 35 | hostName?: string; 36 | } 37 | /** Extended options including CSRF token configuration 38 | * 39 | * Extends BaseOptions with CSRF token support for endpoints 40 | * that require CSRF protection. 41 | */ 42 | export interface ExtendedOptions extends BaseOptions { 43 | /** CSRF token 44 | * 45 | * If it isn't set, automatically get CSRF token from scrapbox.io server. 46 | */ 47 | csrf?: string; 48 | } 49 | 50 | /** Set default values for {@linkcode BaseOptions} 51 | * 52 | * Ensures all required fields have appropriate default values while 53 | * preserving any user-provided options. 54 | * 55 | * @param options - User-provided {@linkcode Options} to merge with defaults 56 | * @returns {@linkcode Options} object with all required fields populated 57 | */ 58 | export const setDefaults = ( 59 | options: T, 60 | ): Omit & Required> => { 61 | const { fetch = robustFetch, hostName = "scrapbox.io", ...rest } = options; 62 | return { fetch, hostName, ...rest }; 63 | }; 64 | -------------------------------------------------------------------------------- /rest/page-data.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createOk, 3 | isErr, 4 | mapAsyncForResult, 5 | mapErrAsyncForResult, 6 | type Result, 7 | unwrapOk, 8 | } from "option-t/plain_result"; 9 | import type { 10 | ExportedData, 11 | ImportedData, 12 | NotFoundError, 13 | NotLoggedInError, 14 | NotPrivilegeError, 15 | } from "@cosense/types/rest"; 16 | import { cookie, getCSRFToken } from "./auth.ts"; 17 | import { parseHTTPError } from "./parseHTTPError.ts"; 18 | import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; 19 | import { 20 | type BaseOptions, 21 | type ExtendedOptions, 22 | setDefaults, 23 | } from "./options.ts"; 24 | import type { FetchError } from "./mod.ts"; 25 | 26 | export type ImportPagesError = HTTPError; 27 | 28 | /** Import pages into a Scrapbox project 29 | * 30 | * Imports multiple pages into a specified project. The pages are provided as a structured 31 | * data object that follows the {@linkcode ImportedData} format. 32 | * 33 | * @param project - Name of the target project to import pages into 34 | * @param data - Page data to import, following the {@linkcode ImportedData} format 35 | * @param init - Optional {@linkcode ImportOptions} configuration for the import operation 36 | * @returns A {@linkcode Result}<{@linkcode string}, {@linkcode Error}> containing: 37 | * - Success: A success message 38 | * - Error: An error message 39 | */ 40 | export const importPages = async ( 41 | project: string, 42 | data: ImportedData, 43 | init?: ExtendedOptions, 44 | ): Promise< 45 | Result 46 | > => { 47 | if (data.pages.length === 0) return createOk("No pages to import."); 48 | 49 | const { sid, hostName, fetch } = setDefaults(init ?? {}); 50 | const formData = new FormData(); 51 | formData.append( 52 | "import-file", 53 | new Blob([JSON.stringify(data)], { 54 | type: "application/octet-stream", 55 | }), 56 | ); 57 | formData.append("name", "undefined"); 58 | 59 | const csrfResult = await getCSRFToken(init); 60 | if (isErr(csrfResult)) return csrfResult; 61 | 62 | const req = new Request( 63 | `https://${hostName}/api/page-data/import/${project}.json`, 64 | { 65 | method: "POST", 66 | headers: { 67 | ...(sid ? { Cookie: cookie(sid) } : {}), 68 | Accept: "application/json, text/plain, */*", 69 | "X-CSRF-TOKEN": unwrapOk(csrfResult), 70 | }, 71 | body: formData, 72 | }, 73 | ); 74 | 75 | const res = await fetch(req); 76 | if (isErr(res)) return res; 77 | 78 | return mapAsyncForResult( 79 | responseIntoResult(unwrapOk(res)), 80 | async (res) => (await res.json()).message as string, 81 | ); 82 | }; 83 | 84 | export type ExportPagesError = 85 | | NotFoundError 86 | | NotPrivilegeError 87 | | NotLoggedInError 88 | | HTTPError; 89 | 90 | /** Configuration options for the {@linkcode exportPages} function 91 | * 92 | * Extends {@linkcode BaseOptions} with metadata control for page exports. 93 | */ 94 | export interface ExportInit 95 | extends BaseOptions { 96 | /** whether to includes metadata */ 97 | metadata: withMetadata; 98 | } 99 | /** Export all pages from a Scrapbox project 100 | * 101 | * Retrieves all pages from the specified project, optionally including metadata. 102 | * Requires appropriate authentication for private projects. 103 | * 104 | * @param project - Name of the project to export 105 | * @param init - {@linkcode ExportOptions} configuration including metadata preference 106 | * @returns A {@linkcode Result}<{@linkcode ExportedData}, {@linkcode Error}> containing: 107 | * - Success: The exported data 108 | * - Error: An error message 109 | */ 110 | export const exportPages = async ( 111 | project: string, 112 | init: ExportInit, 113 | ): Promise< 114 | Result, ExportPagesError | FetchError> 115 | > => { 116 | const { sid, hostName, fetch, metadata } = setDefaults(init ?? {}); 117 | 118 | const req = new Request( 119 | `https://${hostName}/api/page-data/export/${project}.json?metadata=${metadata}`, 120 | sid ? { headers: { Cookie: cookie(sid) } } : undefined, 121 | ); 122 | const res = await fetch(req); 123 | if (isErr(res)) return res; 124 | 125 | return mapAsyncForResult( 126 | await mapErrAsyncForResult( 127 | responseIntoResult(unwrapOk(res)), 128 | async (error) => 129 | (await parseHTTPError(error, [ 130 | "NotFoundError", 131 | "NotLoggedInError", 132 | "NotPrivilegeError", 133 | ])) ?? error, 134 | ), 135 | (res) => res.json() as Promise>, 136 | ); 137 | }; 138 | -------------------------------------------------------------------------------- /rest/pages.test.ts: -------------------------------------------------------------------------------- 1 | import { getPage, listPages } from "./pages.ts"; 2 | import { assertSnapshot } from "@std/testing/snapshot"; 3 | 4 | /** Test suite for page retrieval functionality */ 5 | Deno.test("getPage", async (t) => { // Tests page fetching with various options 6 | // Test fetching a page with rename following enabled 7 | await assertSnapshot( 8 | t, 9 | getPage.toRequest("takker", "テストページ", { followRename: true }), 10 | ); 11 | }); 12 | /** Test suite for page listing functionality */ 13 | Deno.test("listPages", async (t) => { // Tests page listing with sorting options 14 | await assertSnapshot( 15 | t, 16 | listPages.toRequest("takker", { sort: "updated" }), 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /rest/parseHTTPError.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BadRequestError, 3 | InvalidURLError, 4 | NoQueryError, 5 | NotFoundError, 6 | NotLoggedInError, 7 | NotMemberError, 8 | NotPrivilegeError, 9 | SessionError, 10 | } from "@cosense/types/rest"; 11 | import type { Maybe } from "option-t/maybe"; 12 | import { isArrayOf } from "@core/unknownutil/is/array-of"; 13 | import { isLiteralOneOf } from "@core/unknownutil/is/literal-one-of"; 14 | import { isRecord } from "@core/unknownutil/is/record"; 15 | import { isString } from "@core/unknownutil/is/string"; 16 | 17 | import type { HTTPError } from "./responseIntoResult.ts"; 18 | 19 | export interface RESTfullAPIErrorMap { 20 | BadRequestError: BadRequestError; 21 | NotFoundError: NotFoundError; 22 | NotLoggedInError: NotLoggedInError; 23 | NotMemberError: NotMemberError; 24 | SessionError: SessionError; 25 | InvalidURLError: InvalidURLError; 26 | NoQueryError: NoQueryError; 27 | NotPrivilegeError: NotPrivilegeError; 28 | } 29 | 30 | /** 31 | * Extracts error information from a failed HTTP request 32 | * 33 | * This function parses the response from a failed HTTP request to extract structured error information. 34 | * It handles various error types including authentication, permission, and validation errors. 35 | * 36 | * @returns A {@linkcode Maybe} containing: 37 | * - Success: The specific error type requested in `errorNames` 38 | * - Error: {@linkcode null} if the error type doesn't match 39 | */ 40 | export const parseHTTPError = async < 41 | ErrorNames extends keyof RESTfullAPIErrorMap, 42 | >( 43 | error: HTTPError, 44 | errorNames: ErrorNames[], 45 | ): Promise> => { 46 | const res = error.response.clone(); 47 | const isErrorNames = isLiteralOneOf(errorNames); 48 | try { 49 | const json: unknown = await res.json(); 50 | if (!isRecord(json)) return; 51 | if (res.status === 422) { 52 | if (!isString(json.message)) return; 53 | for ( 54 | const name of [ 55 | "NoQueryError", 56 | "InvalidURLError", 57 | ] as (keyof RESTfullAPIErrorMap)[] 58 | ) { 59 | if (!(errorNames as string[]).includes(name)) continue; 60 | return { 61 | name, 62 | message: json.message, 63 | } as unknown as RESTfullAPIErrorMap[ErrorNames]; 64 | } 65 | } 66 | if (!isErrorNames(json.name)) return; 67 | if (!isString(json.message)) return; 68 | if (json.name === "NotLoggedInError") { 69 | if (!isRecord(json.detals)) return; 70 | if (!isString(json.detals.project)) return; 71 | if (!isArrayOf(isLoginStrategies)(json.detals.loginStrategies)) return; 72 | return { 73 | name: json.name, 74 | message: json.message, 75 | details: { 76 | project: json.detals.project, 77 | loginStrategies: json.detals.loginStrategies, 78 | }, 79 | } as unknown as RESTfullAPIErrorMap[ErrorNames]; 80 | } 81 | return { 82 | name: json.name, 83 | message: json.message, 84 | } as unknown as RESTfullAPIErrorMap[ErrorNames]; 85 | } catch (e: unknown) { 86 | if (e instanceof SyntaxError) return; 87 | // Re-throw all errors except JSON parse errors (SyntaxError) 88 | throw e; 89 | } 90 | }; 91 | 92 | const isLoginStrategies = /* @__PURE__ */ isLiteralOneOf( 93 | [ 94 | "google", 95 | "github", 96 | "microsoft", 97 | "gyazo", 98 | "email", 99 | "saml", 100 | "easy-trial", 101 | ] as const, 102 | ); 103 | -------------------------------------------------------------------------------- /rest/profile.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isErr, 3 | mapAsyncForResult, 4 | type Result, 5 | unwrapOk, 6 | } from "option-t/plain_result"; 7 | import type { GuestUser, MemberUser } from "@cosense/types/rest"; 8 | import { cookie } from "./auth.ts"; 9 | import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; 10 | import type { FetchError } from "./robustFetch.ts"; 11 | import { type BaseOptions, setDefaults } from "./options.ts"; 12 | 13 | export interface GetProfile { 14 | /** Constructs a request for the `/api/users/me endpoint` 15 | * 16 | * This endpoint retrieves the current user's profile information, 17 | * which can be either a {@linkcode MemberUser} or {@linkcode GuestUser} profile. 18 | * 19 | * @param init - Options including `connect.sid` (session ID) and other configuration 20 | * @returns A {@linkcode Request} object for fetching user profile data 21 | */ 22 | toRequest: (init?: BaseOptions) => Request; 23 | 24 | /** get the user profile from the given response 25 | * 26 | * @param res - Response from the API 27 | * @returns A {@linkcode Result}<{@linkcode UserProfile}, {@linkcode Error}> containing: 28 | * - Success: The user's profile data 29 | * - Error: One of several possible errors: 30 | * - {@linkcode NotLoggedInError}: Authentication required 31 | * - {@linkcode HTTPError}: Other HTTP errors 32 | */ 33 | fromResponse: ( 34 | res: Response, 35 | ) => Promise< 36 | Result 37 | >; 38 | 39 | (init?: BaseOptions): Promise< 40 | Result 41 | >; 42 | } 43 | 44 | export type ProfileError = HTTPError; 45 | 46 | const getProfile_toRequest: GetProfile["toRequest"] = ( 47 | init, 48 | ) => { 49 | const { sid, hostName } = setDefaults(init ?? {}); 50 | return new Request( 51 | `https://${hostName}/api/users/me`, 52 | sid ? { headers: { Cookie: cookie(sid) } } : undefined, 53 | ); 54 | }; 55 | 56 | const getProfile_fromResponse: GetProfile["fromResponse"] = (response) => 57 | mapAsyncForResult( 58 | responseIntoResult(response), 59 | async (res) => (await res.json()) as MemberUser | GuestUser, 60 | ); 61 | 62 | export const getProfile: GetProfile = /* @__PURE__ */ (() => { 63 | const fn: GetProfile = async (init) => { 64 | const { fetch, ...rest } = setDefaults(init ?? {}); 65 | 66 | const resResult = await fetch(getProfile_toRequest(rest)); 67 | return isErr(resResult) 68 | ? resResult 69 | : getProfile_fromResponse(unwrapOk(resResult)); 70 | }; 71 | 72 | fn.toRequest = getProfile_toRequest; 73 | fn.fromResponse = getProfile_fromResponse; 74 | return fn; 75 | })(); 76 | -------------------------------------------------------------------------------- /rest/project.test.ts: -------------------------------------------------------------------------------- 1 | import { getProject, listProjects } from "./project.ts"; 2 | import { assertSnapshot } from "@std/testing/snapshot"; 3 | 4 | Deno.test("getProject", async (t) => { 5 | await assertSnapshot( 6 | t, 7 | getProject.toRequest("takker"), 8 | ); 9 | }); 10 | Deno.test("listProjects", async (t) => { 11 | await assertSnapshot( 12 | t, 13 | listProjects.toRequest(["dummy-id1", "dummy-id2"]), 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /rest/replaceLinks.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isErr, 3 | mapAsyncForResult, 4 | mapErrAsyncForResult, 5 | type Result, 6 | unwrapOk, 7 | } from "option-t/plain_result"; 8 | import type { 9 | NotFoundError, 10 | NotLoggedInError, 11 | NotMemberError, 12 | } from "@cosense/types/rest"; 13 | import { cookie, getCSRFToken } from "./auth.ts"; 14 | import { parseHTTPError } from "./parseHTTPError.ts"; 15 | import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; 16 | import type { FetchError } from "./robustFetch.ts"; 17 | import { type ExtendedOptions, setDefaults } from "./options.ts"; 18 | 19 | export type ReplaceLinksError = 20 | | NotFoundError 21 | | NotLoggedInError 22 | | NotMemberError 23 | | HTTPError; 24 | 25 | /** Replaces all links within the specified project 26 | * 27 | * > [!IMPORTANT] 28 | * > This function only replaces links, not page titles. 29 | * > If you need to replace page titles as well, use {@linkcode patch} 30 | * 31 | * @param project - The project name where all links will be replaced 32 | * @param from - The original link text to be replaced 33 | * @param to - The new link text to replace with 34 | * @param init - Options including `connect.sid` (session ID) and other configuration 35 | * @returns A {@linkcode number} indicating the count of pages where links were replaced 36 | */ 37 | export const replaceLinks = async ( 38 | project: string, 39 | from: string, 40 | to: string, 41 | init?: ExtendedOptions, 42 | ): Promise> => { 43 | const { sid, hostName, fetch } = setDefaults(init ?? {}); 44 | 45 | const csrfResult = await getCSRFToken(init); 46 | if (isErr(csrfResult)) return csrfResult; 47 | 48 | const req = new Request( 49 | `https://${hostName}/api/pages/${project}/replace/links`, 50 | { 51 | method: "POST", 52 | headers: { 53 | "Content-Type": "application/json;charset=utf-8", 54 | "X-CSRF-TOKEN": unwrapOk(csrfResult), 55 | ...(sid ? { Cookie: cookie(sid) } : {}), 56 | }, 57 | body: JSON.stringify({ from, to }), 58 | }, 59 | ); 60 | 61 | const resResult = await fetch(req); 62 | if (isErr(resResult)) return resResult; 63 | 64 | return mapAsyncForResult( 65 | await mapErrAsyncForResult( 66 | responseIntoResult(unwrapOk(resResult)), 67 | async (error) => 68 | (await parseHTTPError(error, [ 69 | "NotFoundError", 70 | "NotLoggedInError", 71 | "NotMemberError", 72 | ])) ?? error, 73 | ), 74 | async (res) => { 75 | // message should contain a string like "2 pages have been successfully updated!" 76 | const { message } = (await res.json()) as { message: string }; 77 | return parseInt(message.match(/\d+/)?.[0] ?? "0"); 78 | }, 79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /rest/responseIntoResult.ts: -------------------------------------------------------------------------------- 1 | import { createErr, createOk, type Result } from "option-t/plain_result"; 2 | 3 | /** 4 | * Represents an HTTP error response with status code and message. 5 | * 6 | * @property name - Always "HTTPError" to identify the error type 7 | * @property message - A string containing the HTTP status code and status text 8 | * @property response - The original {@linkcode Response} object that caused the error 9 | */ 10 | export interface HTTPError { 11 | name: "HTTPError"; 12 | message: string; 13 | response: Response; 14 | } 15 | 16 | /** 17 | * Converts a {@linkcode Response} into a {@linkcode Result} type, handling HTTP errors. 18 | * 19 | * @param response - The {@linkcode Response} object to convert into a {@linkcode Result} 20 | * @returns A {@linkcode Result}<{@linkcode Response}, {@linkcode HTTPError}> containing either: 21 | * - Success: The original {@linkcode Response} if status is ok (2xx) 22 | * - Error: A {@linkcode HTTPError} containing: 23 | * - status code and status text as message 24 | * - original {@linkcode Response} object for further processing 25 | */ 26 | export const responseIntoResult = ( 27 | response: Response, 28 | ): Result => 29 | !response.ok 30 | ? createErr({ 31 | name: "HTTPError", 32 | message: `${response.status} ${response.statusText}`, 33 | response, 34 | }) 35 | : createOk(response); 36 | -------------------------------------------------------------------------------- /rest/robustFetch.ts: -------------------------------------------------------------------------------- 1 | import { createErr, createOk, type Result } from "option-t/plain_result"; 2 | 3 | export interface NetworkError { 4 | name: "NetworkError"; 5 | message: string; 6 | request: Request; 7 | } 8 | 9 | export interface AbortError { 10 | name: "AbortError"; 11 | message: string; 12 | request: Request; 13 | } 14 | 15 | export type FetchError = NetworkError | AbortError; 16 | 17 | /** 18 | * Represents a function that performs a network request using the Fetch API. 19 | * 20 | * @param input - The resource URL (as {@linkcode string} or {@linkcode URL}), {@linkcode RequestInfo}, or a {@linkcode Request} object to fetch 21 | * @param init - Optional {@linkcode RequestInit} configuration for the request including headers, method, body, etc. 22 | * @returns A {@linkcode Result}<{@linkcode Response}, {@linkcode FetchError}> containing either: 23 | * - Success: A {@linkcode Response} from the successful fetch operation 24 | * - Error: One of several possible errors: 25 | * - {@linkcode NetworkError}: Network connectivity or DNS resolution failed (from {@linkcode TypeError}) 26 | * - {@linkcode AbortError}: Request was aborted before completion (from {@linkcode DOMException}) 27 | */ 28 | export type RobustFetch = ( 29 | input: RequestInfo | URL, 30 | init?: RequestInit, 31 | ) => Promise>; 32 | 33 | /** 34 | * A simple implementation of {@linkcode RobustFetch} that uses {@linkcode fetch}. 35 | * 36 | * @param input - The resource URL (as {@linkcode string} or {@linkcode URL}), {@linkcode RequestInfo}, or a {@linkcode Request} object to fetch 37 | * @param init - Optional {@linkcode RequestInit} configuration for the request including headers, method, body, etc. 38 | * @returns A {@linkcode Result}<{@linkcode Response}, {@linkcode FetchError}> containing either: 39 | * - Success: A {@linkcode Response} from the successful fetch operation 40 | * - Error: One of several possible errors: 41 | * - {@linkcode NetworkError}: Network connectivity or DNS resolution failed (from {@linkcode TypeError}) 42 | * - {@linkcode AbortError}: Request was aborted before completion (from {@linkcode DOMException}) 43 | */ 44 | export const robustFetch: RobustFetch = async (input, init) => { 45 | const request = new Request(input, init); 46 | try { 47 | return createOk(await globalThis.fetch(request)); 48 | } catch (e: unknown) { 49 | if (e instanceof DOMException && e.name === "AbortError") { 50 | return createErr({ 51 | name: "AbortError", 52 | message: e.message, 53 | request, 54 | }); 55 | } 56 | if (e instanceof TypeError) { 57 | return createErr({ 58 | name: "NetworkError", 59 | message: e.message, 60 | request, 61 | }); 62 | } 63 | throw e; 64 | } 65 | }; 66 | -------------------------------------------------------------------------------- /rest/table.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | NotFoundError, 3 | NotLoggedInError, 4 | NotMemberError, 5 | } from "@cosense/types/rest"; 6 | import { cookie } from "./auth.ts"; 7 | import { encodeTitleURI } from "../title.ts"; 8 | import { type BaseOptions, setDefaults } from "./options.ts"; 9 | import { 10 | isErr, 11 | mapAsyncForResult, 12 | mapErrAsyncForResult, 13 | type Result, 14 | unwrapOk, 15 | } from "option-t/plain_result"; 16 | import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; 17 | import { parseHTTPError } from "./parseHTTPError.ts"; 18 | import type { FetchError } from "./mod.ts"; 19 | 20 | const getTable_toRequest: GetTable["toRequest"] = ( 21 | project, 22 | title, 23 | filename, 24 | options, 25 | ) => { 26 | const { sid, hostName } = setDefaults(options ?? {}); 27 | const path = `https://${hostName}/api/table/${project}/${ 28 | encodeTitleURI(title) 29 | }/${encodeURIComponent(filename)}.csv`; 30 | 31 | return new Request( 32 | path, 33 | sid ? { headers: { Cookie: cookie(sid) } } : undefined, 34 | ); 35 | }; 36 | 37 | const getTable_fromResponse: GetTable["fromResponse"] = async (res) => 38 | mapAsyncForResult( 39 | await mapErrAsyncForResult( 40 | responseIntoResult(res), 41 | async (error) => 42 | error.response.status === 404 43 | ? { 44 | // Build error manually since response may be an empty string 45 | name: "NotFoundError", 46 | message: "Table not found.", 47 | } 48 | : (await parseHTTPError(error, [ 49 | "NotLoggedInError", 50 | "NotMemberError", 51 | ])) ?? error, 52 | ), 53 | (res) => res.text(), 54 | ); 55 | 56 | export type TableError = 57 | | NotFoundError 58 | | NotLoggedInError 59 | | NotMemberError 60 | | HTTPError; 61 | 62 | export interface GetTable { 63 | /** Build a request for `/api/table/:project/:title/:filename.csv` endpoint 64 | * 65 | * @param project - Name of the project containing the target page 66 | * @param title - Title of the page (case-insensitive) 67 | * @param filename - Name of the table to retrieve 68 | * @param options - Additional configuration options 69 | * @returns A {@linkcode Request} object for fetching the table data 70 | */ 71 | toRequest: ( 72 | project: string, 73 | title: string, 74 | filename: string, 75 | options?: BaseOptions, 76 | ) => Request; 77 | 78 | /** Extract page JSON data from the response 79 | * 80 | * @param res - Response from the server 81 | * @returns A {@linkcode Result}<{@linkcode string}, {@linkcode TableError}> containing: 82 | * - Success: The table data in CSV format 83 | * - Error: One of several possible errors: 84 | * - {@linkcode NotFoundError}: Table not found 85 | * - {@linkcode NotLoggedInError}: Authentication required 86 | * - {@linkcode NotMemberError}: User lacks access 87 | * - {@linkcode HTTPError}: Other HTTP errors 88 | */ 89 | fromResponse: (res: Response) => Promise>; 90 | 91 | ( 92 | project: string, 93 | title: string, 94 | filename: string, 95 | options?: BaseOptions, 96 | ): Promise>; 97 | } 98 | 99 | /** Retrieve a specified table in CSV format from a Scrapbox page 100 | * 101 | * This function fetches a table stored in a Scrapbox page and returns its contents 102 | * in CSV format. The table must exist in the specified project and page. 103 | * 104 | * @param project - Name of the project containing the target page 105 | * @param title - Title of the page (case-insensitive) 106 | * @param filename - Name of the table to retrieve 107 | * @param options - Additional configuration options including authentication 108 | * @returns A {@linkcode Result}<{@linkcode string}, {@linkcode TableError} | {@linkcode FetchError}> containing: 109 | * - Success: The table data in CSV format 110 | * - Error: One of several possible errors: 111 | * - {@linkcode NotFoundError}: Table not found 112 | * - {@linkcode NotLoggedInError}: Authentication required 113 | * - {@linkcode NotMemberError}: User lacks access 114 | * - {@linkcode HTTPError}: Other HTTP errors 115 | * - {@linkcode FetchError}: Network or request errors 116 | */ 117 | export const getTable: GetTable = /* @__PURE__ */ (() => { 118 | const fn: GetTable = async ( 119 | project, 120 | title, 121 | filename, 122 | options, 123 | ) => { 124 | const { fetch } = setDefaults(options ?? {}); 125 | const req = getTable_toRequest(project, title, filename, options); 126 | const res = await fetch(req); 127 | if (isErr(res)) return res; 128 | return await getTable_fromResponse(unwrapOk(res)); 129 | }; 130 | 131 | fn.toRequest = getTable_toRequest; 132 | fn.fromResponse = getTable_fromResponse; 133 | 134 | return fn; 135 | })(); 136 | -------------------------------------------------------------------------------- /script.ts: -------------------------------------------------------------------------------- 1 | import "./browser/dom/mod.ts"; 2 | -------------------------------------------------------------------------------- /text.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-irregular-whitespace 2 | import { isString } from "@core/unknownutil/is/string"; 3 | 4 | /** Count the number of leading whitespace characters (indentation level) 5 | * 6 | * ```ts 7 | * import { assertEquals } from "@std/assert/equals"; 8 | * 9 | * assertEquals(getIndentCount("sample text "), 0); 10 | * assertEquals(getIndentCount(" sample text "), 2); 11 | * assertEquals(getIndentCount("   sample text"), 3); 12 | * assertEquals(getIndentCount("\t \t  sample text"), 5); 13 | * ``` 14 | * 15 | * @param text - The input {@linkcode string} to analyze 16 | * @returns The {@linkcode number} of leading whitespace characters 17 | */ 18 | export const getIndentCount = (text: string): number => 19 | text.match(/^(\s*)/)?.[1]?.length ?? 0; 20 | 21 | /** Count the number of subsequent lines that are indented under the specified line 22 | * 23 | * @param index - Line number of the target line 24 | * @param lines - List of lines (can be strings or objects with text property) 25 | */ 26 | export const getIndentLineCount = ( 27 | index: number, 28 | lines: readonly string[] | readonly { text: string }[], 29 | ): number => { 30 | const base = getIndentCount(getText(index, lines)); 31 | let count = 0; 32 | while ( 33 | index + count + 1 < lines.length && 34 | (getIndentCount(getText(index + count + 1, lines))) > base 35 | ) { 36 | count++; 37 | } 38 | return count; 39 | }; 40 | 41 | const getText = ( 42 | index: number, 43 | lines: readonly string[] | readonly { text: string }[], 44 | ) => { 45 | const line = lines[index]; 46 | return isString(line) ? line : line.text; 47 | }; 48 | -------------------------------------------------------------------------------- /title.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-irregular-whitespace 2 | /** 3 | * Convert a string to titleLc format 4 | * 5 | * Primarily used for comparing links for equality 6 | * 7 | * @example Converts spaces (` `) to underscores (`_`) 8 | * ```ts 9 | * import { assertEquals } from "@std/assert/equals"; 10 | * 11 | * assertEquals(toTitleLc("sample text"), "sample_text"); 12 | * assertEquals( 13 | * toTitleLc("空白入り タイトル"), 14 | * "空白入り_タイトル", 15 | * ); 16 | * assertEquals( 17 | * toTitleLc(" 前後にも 空白入り _タイトル "), 18 | * "_前後にも_空白入り__タイトル_", 19 | * ); 20 | * ``` 21 | * 22 | * @example Converts uppercase to lowercase 23 | * ```ts 24 | * import { assertEquals } from "@std/assert/equals"; 25 | * 26 | * assertEquals(toTitleLc("Scrapbox-Gyazo"), "scrapbox-gyazo"); 27 | * assertEquals( 28 | * toTitleLc("全角アルファベット「Scrapbox」も変換できる"), 29 | * "全角アルファベット「scrapbox」も変換できる", 30 | * ); 31 | * assertEquals( 32 | * toTitleLc("Scrapbox is one of the products powered by Nota inc."), 33 | * "scrapbox_is_one_of_the_products_powered_by_nota_inc.", 34 | * ); 35 | * ``` 36 | * 37 | * @param text - String to convert 38 | * @returns A {@linkcode string} containing the converted text in titleLc format 39 | */ 40 | export const toTitleLc = (text: string): string => 41 | text.replaceAll(" ", "_").toLowerCase(); 42 | 43 | /** Convert underscores (`_`) to single-byte spaces (` `) 44 | * 45 | * ```ts 46 | * import { assertEquals } from "@std/assert/equals"; 47 | * 48 | * assertEquals(revertTitleLc("sample_text"), "sample text"); 49 | * assertEquals( 50 | * revertTitleLc("Title_with underscore"), 51 | * "Title with underscore", 52 | * ); 53 | * ``` 54 | * 55 | * @param text - String to convert 56 | * @returns A {@linkcode string} with underscores converted to spaces 57 | */ 58 | export const revertTitleLc = (text: string): string => 59 | text.replaceAll("_", " "); 60 | 61 | /** Encode a title into a URI-safe format 62 | * 63 | * ```ts 64 | * import { assertEquals } from "@std/assert/equals"; 65 | * 66 | * assertEquals(encodeTitleURI("sample text"), "sample_text"); 67 | * assertEquals(encodeTitleURI(":title:"), ":title%3A"); 68 | * ``` 69 | * 70 | * @param title - Title to encode 71 | * @returns A {@linkcode string} containing the URI-safe encoded title 72 | */ 73 | export const encodeTitleURI = (title: string): string => { 74 | return [...title].map((char, index) => { 75 | if (char === " ") return "_"; 76 | if ( 77 | !noEncodeChars.includes(char) || 78 | (index === title.length - 1 && noTailChars.includes(char)) 79 | ) { 80 | return encodeURIComponent(char); 81 | } 82 | return char; 83 | }).join(""); 84 | }; 85 | 86 | const noEncodeChars = '@$&+=:;",'; 87 | const noTailChars = ':;",'; 88 | 89 | /** Convert a title to a URI-safe format while minimizing percent encoding 90 | * 91 | * @example Only words 92 | * ```ts 93 | * import { assertEquals } from "@std/assert/equals"; 94 | * 95 | * assertEquals( 96 | * toReadableTitleURI("Normal_TitleAAA"), 97 | * "Normal_TitleAAA", 98 | * ); 99 | * ``` 100 | * 101 | * @example With spaces 102 | * ```ts 103 | * import { assertEquals } from "@std/assert/equals"; 104 | * 105 | * assertEquals( 106 | * toReadableTitleURI("Title with Spaces"), 107 | * "Title_with_Spaces", 108 | * ); 109 | * ``` 110 | * 111 | * @example With special characters 112 | * ```ts 113 | * import { assertEquals } from "@std/assert/equals"; 114 | * 115 | * assertEquals( 116 | * toReadableTitleURI("Title with special characters: /?{}^|<>%"), 117 | * "Title_with_special_characters:_%2F%3F%7B%7D%5E%7C%3C%3E%25", 118 | * ); 119 | * ``` 120 | * 121 | * @example With multibyte characters 122 | * ```ts 123 | * import { assertEquals } from "@std/assert/equals"; 124 | * 125 | * assertEquals( 126 | * toReadableTitleURI("日本語_(絵文字✨つき) タイトル"), 127 | * "日本語_(絵文字✨つき) タイトル", 128 | * ); 129 | * ``` 130 | * 131 | * @example With percent encoding 132 | * ```ts 133 | * import { assertEquals } from "@std/assert/equals"; 134 | * 135 | * assertEquals( 136 | * toReadableTitleURI("スラッシュ/は/percent encoding対象の/文字です"), 137 | * "スラッシュ%2Fは%2Fpercent_encoding対象の%2F文字です", 138 | * ); 139 | * assertEquals( 140 | * toReadableTitleURI("%2Fなども/と同様percent encodingされる"), 141 | * "%252Fなども%2Fと同様percent_encodingされる", 142 | * ); 143 | * ``` 144 | * 145 | * @param title - Title to convert 146 | * @returns A {@linkcode string} containing the URI-safe title with minimal percent encoding 147 | */ 148 | export const toReadableTitleURI = (title: string): string => { 149 | return title.replaceAll(" ", "_") 150 | .replace(/[/?#\{}^|<>%]/g, (char) => encodeURIComponent(char)); 151 | }; 152 | -------------------------------------------------------------------------------- /util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Timestamp string whose format is `YYYY-MM-DDTHH:mm:ssZ` 3 | */ 4 | export type Timestamp = string; 5 | 6 | /** Represents {@linkcode fetch} 7 | * 8 | * This type can return `undefined`, which is useful for implementing `fetch` using Cache API. 9 | */ 10 | export type Fetch = ( 11 | input: RequestInfo | URL, 12 | init?: RequestInit, 13 | ) => Promise; 14 | 15 | /** Common options shared across all REST API endpoints 16 | * 17 | * These options configure authentication, network behavior, and host settings 18 | * for all API requests in the library. 19 | */ 20 | export interface BaseOptions { 21 | /** Scrapbox session ID (connect.sid) 22 | * 23 | * Authentication token required to access: 24 | * - Private project data 25 | * - User-specific data linked to Scrapbox accounts 26 | * - Protected API endpoints 27 | */ 28 | sid?: string; 29 | 30 | /** Custom fetch implementation for making HTTP requests 31 | * 32 | * Allows overriding the default fetch behavior for testing 33 | * or custom networking requirements. 34 | * 35 | * @default {globalThis.fetch} 36 | */ 37 | fetch?: Fetch; 38 | 39 | /** Base URL for REST API endpoints 40 | * 41 | * @default {"https://scrapbox.io/"} 42 | */ 43 | baseURL?: string; 44 | } 45 | 46 | /** Options for Gyazo API which requires OAuth */ 47 | export interface OAuthOptions 48 | extends BaseOptions { 49 | /** an access token associated with the Gyazo user account */ 50 | accessToken: string; 51 | } 52 | 53 | /** Extended options including CSRF token configuration 54 | * 55 | * Extends BaseOptions with CSRF token support for endpoints 56 | * that require CSRF protection. 57 | */ 58 | export interface ExtendedOptions 59 | extends BaseOptions { 60 | /** CSRF token 61 | * 62 | * If it isn't set, automatically get CSRF token from scrapbox.io server. 63 | */ 64 | csrf?: string; 65 | } 66 | 67 | /** Set default values for {@linkcode BaseOptions} 68 | * 69 | * Ensures all required fields have appropriate default values while 70 | * preserving any user-provided options. 71 | * 72 | * @param options - User-provided {@linkcode Options} to merge with defaults 73 | * @returns {@linkcode Options} object with all required fields populated 74 | * 75 | * @internal 76 | */ 77 | export const setDefaults = < 78 | // deno-lint-ignore no-explicit-any 79 | T extends BaseOptions = BaseOptions, 80 | >( 81 | options: T, 82 | ): Omit & Required> => { 83 | const { 84 | fetch = (input, init) => globalThis.fetch(input, init), 85 | baseURL = "https://scrapbox.io/", 86 | ...rest 87 | } = options; 88 | return { fetch, baseURL, ...rest }; 89 | }; 90 | -------------------------------------------------------------------------------- /websocket/__snapshots__/_codeBlock.test.ts.snap: -------------------------------------------------------------------------------- 1 | export const snapshot = {}; 2 | 3 | snapshot[`extractFromCodeTitle() > accurate titles > "code:foo.extA(extB)" 1`] = ` 4 | { 5 | filename: "foo.extA", 6 | indent: 0, 7 | lang: "extB", 8 | } 9 | `; 10 | 11 | snapshot[`extractFromCodeTitle() > accurate titles > " code:foo.extA(extB)" 1`] = ` 12 | { 13 | filename: "foo.extA", 14 | indent: 1, 15 | lang: "extB", 16 | } 17 | `; 18 | 19 | snapshot[`extractFromCodeTitle() > accurate titles > " code: foo.extA (extB)" 1`] = ` 20 | { 21 | filename: "foo.extA", 22 | indent: 2, 23 | lang: "extB", 24 | } 25 | `; 26 | 27 | snapshot[`extractFromCodeTitle() > accurate titles > " code: foo (extB) " 1`] = ` 28 | { 29 | filename: "foo", 30 | indent: 2, 31 | lang: "extB", 32 | } 33 | `; 34 | 35 | snapshot[`extractFromCodeTitle() > accurate titles > " code: foo.extA " 1`] = ` 36 | { 37 | filename: "foo.extA", 38 | indent: 2, 39 | lang: "extA", 40 | } 41 | `; 42 | 43 | snapshot[`extractFromCodeTitle() > accurate titles > " code: foo " 1`] = ` 44 | { 45 | filename: "foo", 46 | indent: 2, 47 | lang: "foo", 48 | } 49 | `; 50 | 51 | snapshot[`extractFromCodeTitle() > accurate titles > " code: .foo " 1`] = ` 52 | { 53 | filename: ".foo", 54 | indent: 2, 55 | lang: ".foo", 56 | } 57 | `; 58 | -------------------------------------------------------------------------------- /websocket/_codeBlock.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert"; 2 | import { assertSnapshot } from "@std/testing/snapshot"; 3 | import { extractFromCodeTitle } from "./_codeBlock.ts"; 4 | 5 | /** 6 | * Tests for code block title parsing functionality 7 | * 8 | * These tests verify the parsing of code block titles in various formats: 9 | * - Valid formats: code:filename.ext(param), code:filename(param), code:filename.ext 10 | * - Invalid formats: trailing dots, incorrect prefixes, non-code blocks 11 | */ 12 | Deno.test("extractFromCodeTitle()", async (t) => { 13 | await t.step("accurate titles", async (st) => { 14 | const titles = [ 15 | "code:foo.extA(extB)", // Basic format: no spaces 16 | " code:foo.extA(extB)", // Leading space before code: 17 | " code: foo.extA (extB)", // Spaces around components 18 | " code: foo (extB) ", // Extension omitted, has parameter 19 | " code: foo.extA ", // Extension only, no parameter 20 | " code: foo ", // Basic name only 21 | " code: .foo ", // Leading dot in name 22 | ]; 23 | for (const title of titles) { 24 | await st.step(`"${title}"`, async (sst) => { 25 | await assertSnapshot(sst, extractFromCodeTitle(title)); 26 | }); 27 | } 28 | }); 29 | 30 | await t.step("inaccurate titles", async (st) => { 31 | const nonTitles = [ 32 | " code: foo. ", // Invalid: Trailing dot without extension is not a valid code block format 33 | // Returning `null` is expected as this format is invalid 34 | "any:code: foo ", // Invalid: Must start with exactly "code:" prefix 35 | " I'm not code block ", // Invalid: Not a code block format at all 36 | ]; 37 | for (const title of nonTitles) { 38 | await st.step(`"${title}"`, async () => { 39 | await assertEquals(null, extractFromCodeTitle(title)); 40 | }); 41 | } 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /websocket/_codeBlock.ts: -------------------------------------------------------------------------------- 1 | import type { TinyCodeBlock } from "../rest/getCodeBlocks.ts"; 2 | 3 | /** Interface for storing code block title line information 4 | * 5 | * In Scrapbox, code blocks start with a title line that defines: 6 | * - The code's filename or language identifier 7 | * - Optional language specification in parentheses 8 | * - Indentation level for nested blocks 9 | */ 10 | export interface CodeTitle { 11 | filename: string; 12 | lang: string; 13 | indent: number; 14 | } 15 | 16 | /** Extract properties from a code block title line 17 | * 18 | * This function parses a line of text to determine if it's a valid code block title. 19 | * Valid formats include: 20 | * - `code:filename.ext` - Language determined by extension 21 | * - `code:filename(lang)` - Explicit language specification 22 | * - `code:lang` - Direct language specification without filename 23 | * 24 | * @param lineText - The line text to parse 25 | * @returns A {@linkcode CodeTitle} | {@linkcode null}: 26 | * - Success: A {@linkcode CodeTitle} object containing filename and language info 27 | * - Error: {@linkcode null} if the line is not a valid code block title 28 | * and indentation level. 29 | */ 30 | export const extractFromCodeTitle = (lineText: string): CodeTitle | null => { 31 | const matched = lineText.match(/^(\s*)code:(.+?)(\(.+\)){0,1}\s*$/); 32 | if (matched === null) return null; 33 | const filename = matched[2].trim(); 34 | let lang = ""; 35 | if (matched[3] === undefined) { 36 | const ext = filename.match(/.+\.(.*)$/); 37 | if (ext === null) { 38 | // `code:ext` 39 | lang = filename; 40 | } else if (ext[1] === "") { 41 | // Reject "code:foo." format as it's invalid (trailing dot without extension) 42 | // This ensures code blocks have either a valid extension or no extension at all 43 | return null; 44 | } else { 45 | // `code:foo.ext` 46 | lang = ext[1].trim(); 47 | } 48 | } else { 49 | lang = matched[3].slice(1, -1); 50 | } 51 | return { 52 | filename: filename, 53 | lang: lang, 54 | indent: matched[1].length, 55 | }; 56 | }; 57 | 58 | /** Calculate the indentation level for code block content 59 | * 60 | * The content of a code block is indented one level deeper than its title line. 61 | * This function determines the correct indentation by analyzing the title line's 62 | * whitespace and adding one additional level. 63 | */ 64 | export function countBodyIndent( 65 | codeBlock: Pick, 66 | ): number { 67 | return codeBlock.titleLine.text.length - 68 | codeBlock.titleLine.text.trimStart().length + 1; 69 | } 70 | -------------------------------------------------------------------------------- /websocket/applyCommit.ts: -------------------------------------------------------------------------------- 1 | import type { CommitNotification } from "@cosense/types/websocket"; 2 | import type { BaseLine } from "@cosense/types/rest"; 3 | import { getUnixTimeFromId } from "./id.ts"; 4 | 5 | export interface ApplyCommitProp { 6 | /** Timestamp for when the changes were created 7 | * 8 | * Can be provided as either: 9 | * - A Unix timestamp (number) 10 | * - An ID containing a Unix timestamp (string) 11 | * If not specified, the current time will be used 12 | */ 13 | updated?: number | string; 14 | /** The ID of the user making the changes 15 | * 16 | * This ID is used to: 17 | * - Track who made each line modification 18 | * - Associate changes with user accounts 19 | * - Maintain edit history and attribution 20 | */ 21 | userId: string; 22 | } 23 | 24 | /** Apply commits to lines with metadata 25 | * 26 | * This function processes a series of commits (changes) to modify lines in a Scrapbox page. 27 | * Each commit can be one of: 28 | * - Insert: Add a new line at a specific position or at the end 29 | * - Update: Modify the text of an existing line 30 | * - Delete: Remove a line 31 | * 32 | * @param lines - The lines to apply commits to, each containing metadata (id, text, etc.) 33 | * @param changes - The commits to apply, each specifying an operation and target line 34 | * @param options - Configuration including userId and optional timestamp 35 | */ 36 | export const applyCommit = ( 37 | lines: readonly BaseLine[], 38 | changes: CommitNotification["changes"], 39 | { updated, userId }: ApplyCommitProp, 40 | ): BaseLine[] => { 41 | const newLines = [...lines]; 42 | const getPos = (lineId: string) => { 43 | const position = newLines.findIndex(({ id }) => id === lineId); 44 | if (position < 0) { 45 | throw RangeError(`No line whose id is ${lineId} found.`); 46 | } 47 | return position; 48 | }; 49 | 50 | for (const change of changes) { 51 | if ("_insert" in change) { 52 | const created = getUnixTimeFromId(change.lines.id); 53 | const newLine = { 54 | text: change.lines.text, 55 | id: change.lines.id, 56 | userId, 57 | updated: created, 58 | created, 59 | }; 60 | if (change._insert === "_end") { 61 | newLines.push(newLine); 62 | } else { 63 | newLines.splice(getPos(change._insert), 0, newLine); 64 | } 65 | } else if ("_update" in change) { 66 | const position = getPos(change._update); 67 | newLines[position].text = change.lines.text; 68 | newLines[position].updated = typeof updated === "string" 69 | ? getUnixTimeFromId(updated) 70 | : updated ?? Math.round(new Date().getTime() / 1000); 71 | } else if ("_delete" in change) { 72 | newLines.splice(getPos(change._delete), 1); 73 | } 74 | } 75 | return newLines; 76 | }; 77 | -------------------------------------------------------------------------------- /websocket/deletePage.ts: -------------------------------------------------------------------------------- 1 | import { push, type PushError, type PushOptions } from "./push.ts"; 2 | import type { Result } from "option-t/plain_result"; 3 | 4 | export type DeletePageOptions = PushOptions; 5 | 6 | /** Delete a specified page whose title is `title` from a given `project` 7 | * 8 | * @param project - The project containing the page to delete 9 | * @param title - The title of the page to delete 10 | * @param options - Additional options for the delete operation 11 | * @returns A {@linkcode Promise} that resolves to a {@linkcode Result} containing: 12 | * - Success: The page title that was deleted as a {@linkcode string} 13 | * - Error: A {@linkcode PushError} describing what went wrong 14 | */ 15 | export const deletePage = ( 16 | project: string, 17 | title: string, 18 | options?: DeletePageOptions, 19 | ): Promise> => 20 | push( 21 | project, 22 | title, 23 | (page) => page.persistent ? [{ deleted: true }] : [], 24 | options, 25 | ); 26 | -------------------------------------------------------------------------------- /websocket/diffToChanges.ts: -------------------------------------------------------------------------------- 1 | import { diff, toExtendedChanges } from "../deps/onp.ts"; 2 | import type { Line } from "@cosense/types/userscript"; 3 | import type { UpdateChange } from "@cosense/types/websocket"; 4 | import type { DeleteChange, InsertChange } from "@cosense/types/rest"; 5 | import { createNewLineId } from "./id.ts"; 6 | 7 | type Options = { 8 | userId: string; 9 | }; 10 | export function* diffToChanges( 11 | left: Pick[], 12 | right: string[], 13 | { userId }: Options, 14 | ): Generator { 15 | const { buildSES } = diff( 16 | left.map(({ text }) => text), 17 | right, 18 | ); 19 | let lineNo = 0; 20 | let lineId = left[0].id; 21 | for (const change of toExtendedChanges(buildSES())) { 22 | switch (change.type) { 23 | case "added": 24 | yield { 25 | _insert: lineId, 26 | lines: { 27 | id: createNewLineId(userId), 28 | text: change.value, 29 | }, 30 | }; 31 | continue; 32 | case "deleted": 33 | yield { 34 | _delete: lineId, 35 | lines: -1, 36 | }; 37 | break; 38 | case "replaced": 39 | yield { 40 | _update: lineId, 41 | lines: { text: change.value }, 42 | }; 43 | break; 44 | } 45 | lineNo++; 46 | lineId = left[lineNo]?.id ?? "_end"; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /websocket/emit.ts: -------------------------------------------------------------------------------- 1 | import { createErr, createOk, type Result } from "option-t/plain_result"; 2 | import type { Socket } from "socket.io-client"; 3 | import type { 4 | JoinRoomRequest, 5 | JoinRoomResponse, 6 | MoveCursorData, 7 | PageCommit, 8 | PageCommitResponse, 9 | } from "@cosense/types/websocket"; 10 | import { 11 | isPageCommitError, 12 | type PageCommitError, 13 | type SocketIOServerDisconnectError, 14 | type TimeoutError, 15 | type UnexpectedRequestError, 16 | } from "./error.ts"; 17 | import type { ScrapboxSocket } from "./socket.ts"; 18 | 19 | export interface WrapperdEmitEvents { 20 | commit: { req: PageCommit; res: PageCommitResponse; err: PageCommitError }; 21 | "room:join": { 22 | req: JoinRoomRequest; 23 | res: JoinRoomResponse; 24 | err: void; 25 | }; 26 | cursor: { 27 | req: Omit; 28 | res: void; 29 | err: void; 30 | }; 31 | } 32 | 33 | export interface EmitOptions { 34 | timeout?: number; 35 | } 36 | 37 | /** 38 | * Sends an event to the socket and returns a promise that resolves with the result. 39 | * 40 | * @template EventName - The name of the event to emit 41 | * @param socket - The {@linkcode ScrapboxSocket} to emit the event on 42 | * @param event - The name of the event to emit 43 | * @param data - The data to send with the event 44 | * @param options - Optional {@linkcode EmitOptions} for the operation 45 | * @returns A {@linkcode Promise}<{@linkcode Result}> containing: 46 | * - Success: The response data from the server 47 | * - Error: One of several possible errors: 48 | * - {@linkcode TimeoutError}: Request timed out 49 | * - {@linkcode SocketIOServerDisconnectError}: Server disconnected 50 | * - {@linkcode UnexpectedRequestError}: Unexpected response format 51 | */ 52 | export const emit = ( 53 | socket: ScrapboxSocket, 54 | event: EventName, 55 | data: WrapperdEmitEvents[EventName]["req"], 56 | options?: EmitOptions, 57 | ): Promise< 58 | Result< 59 | WrapperdEmitEvents[EventName]["res"], 60 | | WrapperdEmitEvents[EventName]["err"] 61 | | TimeoutError 62 | | SocketIOServerDisconnectError 63 | | UnexpectedRequestError 64 | > 65 | > => { 66 | if (event === "cursor") { 67 | socket.emit<"cursor">(event, data as WrapperdEmitEvents["cursor"]["req"]); 68 | return Promise.resolve(createOk(undefined)); 69 | } 70 | 71 | // This event is processed using the socket.io-request protocol 72 | // (see: https://github.com/shokai/socket.io-request) 73 | // We implement a similar request-response pattern here: 74 | // 1. Send event with payload 75 | // 2. Wait for response with timeout 76 | // 3. Handle success/error responses 77 | const { resolve, promise, reject } = Promise.withResolvers< 78 | Result< 79 | WrapperdEmitEvents[EventName]["res"], 80 | | WrapperdEmitEvents[EventName]["err"] 81 | | TimeoutError 82 | | SocketIOServerDisconnectError 83 | | UnexpectedRequestError 84 | > 85 | >(); 86 | 87 | const dispose = () => { 88 | socket.off("disconnect", onDisconnect); 89 | clearTimeout(timeoutId); 90 | }; 91 | const onDisconnect = (reason: Socket.DisconnectReason) => { 92 | // "io client disconnect" should never occur during "commit" or "room:join" operations 93 | // This is an unexpected state that indicates a client-side connection issue 94 | if (reason === "io client disconnect") { 95 | dispose(); 96 | reject(new Error("io client disconnect")); 97 | return; 98 | } 99 | // Unrecoverable error state 100 | if (reason === "io server disconnect") { 101 | dispose(); 102 | resolve(createErr({ name: "SocketIOError" })); 103 | return; 104 | } 105 | // Ignore other reasons because socket.io will automatically reconnect 106 | }; 107 | socket.on("disconnect", onDisconnect); 108 | const timeout = options?.timeout ?? 90000; 109 | const timeoutId = setTimeout(() => { 110 | dispose(); 111 | resolve( 112 | createErr({ 113 | name: "TimeoutError", 114 | message: `exceeded ${timeout} (ms)`, 115 | }), 116 | ); 117 | }, timeout); 118 | 119 | const payload = event === "commit" 120 | ? { method: "commit" as const, data: data as PageCommit } 121 | : { method: "room:join" as const, data: data as JoinRoomRequest }; 122 | 123 | socket.emit("socket.io-request", payload, (res) => { 124 | dispose(); 125 | if ("error" in res) { 126 | resolve( 127 | createErr( 128 | isPageCommitError(res.error) 129 | ? res.error 130 | : { name: "UnexpectedRequestError", ...res }, 131 | ), 132 | ); 133 | return; 134 | } 135 | resolve(createOk(res.data)); 136 | }); 137 | 138 | return promise; 139 | }; 140 | -------------------------------------------------------------------------------- /websocket/error.ts: -------------------------------------------------------------------------------- 1 | import type { JsonValue } from "@std/json"; 2 | 3 | /** the error that occurs when scrapbox.io throws serializable {@link Error} */ 4 | export interface UnexpectedRequestError { 5 | name: "UnexpectedRequestError"; 6 | error: JsonValue; 7 | } 8 | 9 | export interface TimeoutError { 10 | name: "TimeoutError"; 11 | message: string; 12 | } 13 | 14 | export type PageCommitError = 15 | | SocketIOError 16 | | DuplicateTitleError 17 | | NotFastForwardError; 18 | 19 | /* the error that occurs when the socket.io causes an error 20 | * 21 | * when this error occurs, wait for a while and retry the request 22 | */ 23 | export interface SocketIOError { 24 | name: "SocketIOError"; 25 | } 26 | 27 | /** the error that occurs when the socket.io throws "io" server disconnect" */ 28 | export interface SocketIOServerDisconnectError { 29 | name: "SocketIOServerDisconnectError"; 30 | } 31 | 32 | /** the error that occurs when the title is already in use */ 33 | export interface DuplicateTitleError { 34 | name: "DuplicateTitleError"; 35 | } 36 | /** the error caused when commitId is not latest */ 37 | export interface NotFastForwardError { 38 | name: "NotFastForwardError"; 39 | } 40 | 41 | export const isPageCommitError = ( 42 | error: { name: string }, 43 | ): error is PageCommitError => pageCommitErrorNames.includes(error.name); 44 | 45 | const pageCommitErrorNames = [ 46 | "SocketIOError", 47 | "DuplicateTitleError", 48 | "NotFastForwardError", 49 | ]; 50 | -------------------------------------------------------------------------------- /websocket/getHelpfeels.ts: -------------------------------------------------------------------------------- 1 | import type { BaseLine } from "@cosense/types/userscript"; 2 | 3 | /** Extract Helpfeel entries from text 4 | * 5 | * Helpfeel is a Scrapbox notation for questions and help requests. 6 | * Lines starting with "?" are considered Helpfeel entries and are 7 | * used to collect questions and support requests within a project. 8 | * 9 | * ```ts 10 | * import { assertEquals } from "@std/assert/equals"; 11 | * 12 | * const text = `test page 13 | * [normal]link 14 | * but \`this [link]\` is not a link 15 | * 16 | * code:code 17 | * Links [link] and images [https://scrapbox.io/files/65f29c0c9045b5002522c8bb.svg] in code blocks should be ignored 18 | * 19 | * ? Need help with setup!! 20 | * `; 21 | * 22 | * assertEquals(getHelpfeels(text.split("\n").map((text) => ({ text }))), [ 23 | * "Need help with setup!!", 24 | * ]); 25 | * ``` 26 | */ 27 | export const getHelpfeels = (lines: Pick[]): string[] => 28 | lines.flatMap(({ text }) => 29 | /^\s*\? .*$/.test(text) ? [text.trimStart().slice(2)] : [] 30 | ); 31 | -------------------------------------------------------------------------------- /websocket/id.ts: -------------------------------------------------------------------------------- 1 | const zero = (n: string) => n.padStart(8, "0"); 2 | 3 | export const createNewLineId = (userId: string): string => { 4 | const time = Math.floor(new Date().getTime() / 1000).toString(16); 5 | const rand = Math.floor(0xFFFFFE * Math.random()).toString(16); 6 | return `${zero(time).slice(-8)}${userId.slice(-6)}0000${zero(rand)}`; 7 | }; 8 | export const getUnixTimeFromId = (id: string): number => { 9 | if (!isId(id)) throw SyntaxError(`"${id}" is an invalid id.`); 10 | 11 | return parseInt(`0x${id.slice(0, 8)}`, 16); 12 | }; 13 | export const isId = (id: string): boolean => /^[a-f\d]{24,32}$/.test(id); 14 | -------------------------------------------------------------------------------- /websocket/isSameArray.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Compare two arrays to see if they are the same. 3 | * 4 | * This function only checks each element's reference, not the content. 5 | * 6 | * ```ts 7 | * import { assert } from "@std/assert/assert"; 8 | * 9 | * assert(isSameArray([1, 2, 3], [1, 2, 3])); 10 | * assert(isSameArray([1, 2, 3], [3, 2, 1])); 11 | * assert(!isSameArray([1, 2, 3], [3, 2, 3])); 12 | * assert(!isSameArray([1, 2, 3], [1, 2])); 13 | * assert(isSameArray([], [])); 14 | * ``` 15 | * 16 | * @param a 17 | * @param b 18 | * @returns 19 | */ 20 | export const isSameArray = (a: T[], b: T[]): boolean => 21 | a.length === b.length && a.every((x) => b.includes(x)); 22 | -------------------------------------------------------------------------------- /websocket/isSimpleCodeFile.test.ts: -------------------------------------------------------------------------------- 1 | import { assert, assertFalse } from "@std/assert"; 2 | import { isSimpleCodeFile } from "./isSimpleCodeFile.ts"; 3 | import type { SimpleCodeFile } from "./updateCodeFile.ts"; 4 | 5 | const codeFile: SimpleCodeFile = { 6 | filename: "filename", 7 | content: ["line 0", "line 1"], 8 | lang: "language", 9 | }; 10 | 11 | Deno.test("isSimpleCodeFile()", async (t) => { 12 | await t.step("SimpleCodeFile object", () => { 13 | assert(isSimpleCodeFile(codeFile)); 14 | assert(isSimpleCodeFile({ ...codeFile, content: "line 0" })); 15 | assert(isSimpleCodeFile({ ...codeFile, lang: undefined })); 16 | }); 17 | await t.step("similer objects", () => { 18 | assertFalse(isSimpleCodeFile({ ...codeFile, filename: 10 })); 19 | assertFalse(isSimpleCodeFile({ ...codeFile, content: 10 })); 20 | assertFalse(isSimpleCodeFile({ ...codeFile, content: [0, 1] })); 21 | assertFalse(isSimpleCodeFile({ ...codeFile, lang: 10 })); 22 | }); 23 | await t.step("other type values", () => { 24 | assertFalse(isSimpleCodeFile(10)); 25 | assertFalse(isSimpleCodeFile(undefined)); 26 | assertFalse(isSimpleCodeFile(["0", "1", "2"])); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /websocket/isSimpleCodeFile.ts: -------------------------------------------------------------------------------- 1 | import type { SimpleCodeFile } from "./updateCodeFile.ts"; 2 | 3 | /** Check if an object is a {@linkcode SimpleCodeFile} 4 | * 5 | * {@linkcode SimpleCodeFile} represents a code block in Scrapbox with: 6 | * - filename: Name of the code file or block identifier 7 | * - content: Code content as string or array of strings 8 | * - lang: Optional programming language identifier 9 | * 10 | * This function performs runtime type checking to ensure: 11 | * 1. Input is an object (not array or primitive) 12 | * 2. filename is a string 13 | * 3. content is either: 14 | * - a string (single-line code) 15 | * - an array of strings (multi-line code) 16 | * - an empty array (empty code block) 17 | * 4. lang is either undefined or a string 18 | */ 19 | export function isSimpleCodeFile(obj: unknown): obj is SimpleCodeFile { 20 | if (Array.isArray(obj) || !(obj instanceof Object)) return false; 21 | const code = obj as SimpleCodeFile; 22 | const { filename, content, lang } = code; 23 | return ( 24 | typeof filename == "string" && 25 | (typeof content == "string" || 26 | (Array.isArray(content) && 27 | (content.length == 0 || typeof content[0] == "string"))) && 28 | (typeof lang == "string" || lang === undefined) 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /websocket/listen.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | NotFoundError, 3 | NotLoggedInError, 4 | NotMemberError, 5 | } from "@cosense/types/rest"; 6 | import type { HTTPError } from "../rest/responseIntoResult.ts"; 7 | import type { AbortError, NetworkError } from "../rest/robustFetch.ts"; 8 | import type { ScrapboxSocket } from "./socket.ts"; 9 | import type { ListenEventMap } from "@cosense/types/websocket"; 10 | 11 | export interface ListenStreamOptions { 12 | signal?: AbortSignal; 13 | once?: boolean; 14 | } 15 | 16 | export type ListenStreamError = 17 | | NotFoundError 18 | | NotLoggedInError 19 | | NotMemberError 20 | | NetworkError 21 | | AbortError 22 | | HTTPError; 23 | 24 | /** Subscribe to WebSocket events from Scrapbox 25 | * 26 | * This function sets up event listeners for Scrapbox's WebSocket events: 27 | * - Uses socket.on() for continuous listening 28 | * - Uses socket.once() for one-time events when options.once is true 29 | * - Supports automatic cleanup with AbortSignal 30 | * 31 | * @param socket - ScrapboxSocket instance for WebSocket communication 32 | * @param event - Event name to listen for (from {@linkcode ListenEventMap} type) 33 | * @param listener - Callback function to handle the event 34 | * @param options - Optional configuration 35 | * 36 | * @example 37 | * ```typescript 38 | * import { connect } from "@cosense/std/browser/websocket"; 39 | * import { unwrapOk } from "option-t/plain_result"; 40 | * 41 | * // Setup socket and controller 42 | * const socket = unwrapOk(await connect()); 43 | * 44 | * // Listen for pages' changes in a specified project 45 | * listen(socket, "projectUpdatesStream:commit", (data) => { 46 | * console.log("Project updated:", data); 47 | * }); 48 | * ``` 49 | */ 50 | export const listen = ( 51 | socket: ScrapboxSocket, 52 | event: EventName, 53 | listener: ListenEventMap[EventName], 54 | options?: ListenStreamOptions, 55 | ): void => { 56 | if (options?.signal?.aborted) return; 57 | 58 | // deno-lint-ignore no-explicit-any 59 | (options?.once ? socket.once : socket.on)(event, listener as any); 60 | 61 | options?.signal?.addEventListener?.( 62 | "abort", 63 | // deno-lint-ignore no-explicit-any 64 | () => socket.off(event, listener as any), 65 | { once: true }, 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /websocket/makeChanges.ts: -------------------------------------------------------------------------------- 1 | import { diffToChanges } from "./diffToChanges.ts"; 2 | import type { Page } from "@cosense/types/rest"; 3 | import type { ChangeToPush } from "@cosense/types/websocket"; 4 | import { getPageMetadataFromLines } from "./getPageMetadataFromLines.ts"; 5 | import { isSameArray } from "./isSameArray.ts"; 6 | import { isString } from "@core/unknownutil/is/string"; 7 | import { getHelpfeels } from "./getHelpfeels.ts"; 8 | 9 | export function* makeChanges( 10 | before: Page, 11 | after: (string | { text: string })[], 12 | userId: string, 13 | ): Generator { 14 | // Prevent newline characters from being included in the text 15 | // This ensures consistent line handling across different platforms 16 | const after_ = after.flatMap((text) => 17 | (isString(text) ? text : text.text).split("\n") 18 | ); 19 | 20 | // First, yield changes in the main content 21 | // Content changes must be processed before metadata to maintain consistency 22 | for (const change of diffToChanges(before.lines, after_, { userId })) { 23 | yield change; 24 | } 25 | 26 | // Process changes in various metadata 27 | // Metadata includes: 28 | // - links: References to other pages 29 | // - projectLinks: Links to other Scrapbox projects 30 | // - icons: Page icons or thumbnails 31 | // - image: Main page image 32 | // - files: Attached files 33 | // - helpfeels: Questions or help requests (lines starting with "?") 34 | // - infoboxDefinition: Structured data definitions 35 | const [ 36 | title, 37 | links, 38 | projectLinks, 39 | icons, 40 | image, 41 | descriptions, 42 | files, 43 | helpfeels, 44 | infoboxDefinition, 45 | linesCount, 46 | charsCount, 47 | ] = getPageMetadataFromLines(after_.join("\n")); 48 | // Handle title changes 49 | // Note: We always include title change commits for new pages (`persistent === false`) 50 | // to ensure proper page initialization 51 | if (before.title !== title || !before.persistent) yield { title }; 52 | if (!isSameArray(before.links, links)) yield { links }; 53 | if (!isSameArray(before.projectLinks, projectLinks)) yield { projectLinks }; 54 | if (!isSameArray(before.icons, icons)) yield { icons }; 55 | if (before.image !== image) yield { image }; 56 | if (!isSameArray(before.descriptions, descriptions)) yield { descriptions }; 57 | if (!isSameArray(before.files, files)) yield { files }; 58 | if (!isSameArray(getHelpfeels(before.lines), helpfeels)) yield { helpfeels }; 59 | if (!isSameArray(before.infoboxDefinition, infoboxDefinition)) { 60 | yield { infoboxDefinition }; 61 | } 62 | yield { linesCount }; 63 | yield { charsCount }; 64 | } 65 | -------------------------------------------------------------------------------- /websocket/mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./socket.ts"; 2 | export * from "./push.ts"; 3 | export * from "./patch.ts"; 4 | export * from "./deletePage.ts"; 5 | export * from "./pin.ts"; 6 | export * from "./listen.ts"; 7 | export * from "./updateCodeBlock.ts"; 8 | export * from "./updateCodeFile.ts"; 9 | export * from "@cosense/types/websocket"; 10 | -------------------------------------------------------------------------------- /websocket/patch.ts: -------------------------------------------------------------------------------- 1 | import type { ChangeToPush, DeletePageChange } from "@cosense/types/websocket"; 2 | import { makeChanges } from "./makeChanges.ts"; 3 | import type { BaseLine, Page } from "@cosense/types/rest"; 4 | import { push, type PushError, type PushOptions } from "./push.ts"; 5 | import { suggestUnDupTitle } from "./suggestUnDupTitle.ts"; 6 | import type { Result } from "option-t/plain_result"; 7 | import type { Socket } from "socket.io-client"; 8 | import { pinNumber } from "./pin.ts"; 9 | 10 | export interface PatchMetadata extends Page { 11 | /** Number of retry attempts for page modification 12 | * 13 | * Starts at `0` for the first attempt and increments with each retry. 14 | * This helps track and handle concurrent modification conflicts. 15 | */ 16 | attempts: number; 17 | } 18 | 19 | /** 20 | * Function used in {@linkcode patch} to generate a patch from the current page state 21 | * 22 | * This function is used to generate a patch from the current page state. 23 | * It receives the current page lines and metadata and returns the new page content. 24 | * The function can be synchronous or asynchronous. 25 | * 26 | * @param lines - Current page lines 27 | * @param metadata - Current page metadata 28 | * @returns one of the following or a {@linkcode Promise} resolving to one: 29 | * - `NewPageContent["lines"]`: New page lines 30 | * - `NewPageContent`: New page content with optional pinning operation 31 | * - `[]`: Delete the page 32 | * - `undefined`: Abort modification 33 | */ 34 | export type MakePatchFn = ( 35 | lines: BaseLine[], 36 | metadata: PatchMetadata, 37 | ) => 38 | | NewPageContent["lines"] 39 | | NewPageContent 40 | | undefined 41 | | Promise; 42 | 43 | export interface NewPageContent { 44 | /** New page lines */ 45 | lines: (string | { text: string })[]; 46 | 47 | /** Whether to pin the page */ 48 | pin?: boolean; 49 | } 50 | 51 | export type PatchOptions = PushOptions; 52 | 53 | /** Modify an entire Scrapbox page by computing and sending only the differences 54 | * 55 | * This function handles the entire page modification process: 56 | * 1. Fetches current page content 57 | * 2. Applies user-provided update function 58 | * 3. Computes minimal changes needed 59 | * 4. Handles errors (e.g., duplicate titles) 60 | * 5. Retries on conflicts 61 | * 62 | * This function also can pin/unpin pages by setting the `pin` property in the return of `update`. 63 | * 64 | * @param project Project ID containing the target page 65 | * @param title Title of the page to modify 66 | * @param update Function to generate new content 67 | * @param options Optional WebSocket configuration 68 | * 69 | * Special cases: 70 | * - If `update` returns `undefined`: Operation is cancelled 71 | * - If `update` returns `[]`: Page is deleted 72 | * - On duplicate title: Automatically suggests non-conflicting title 73 | */ 74 | export const patch = ( 75 | project: string, 76 | title: string, 77 | update: MakePatchFn, 78 | options?: PatchOptions, 79 | ): Promise> => 80 | push( 81 | project, 82 | title, 83 | async (page, attempts, prev, reason) => { 84 | if (reason === "DuplicateTitleError") { 85 | const fallbackTitle = suggestUnDupTitle(title); 86 | return prev.map((change) => { 87 | if ("title" in change) change.title = fallbackTitle; 88 | return change; 89 | }) as ChangeToPush[] | [DeletePageChange]; 90 | } 91 | const pending = update(page.lines, { ...page, attempts }); 92 | const newContent = pending instanceof Promise ? await pending : pending; 93 | if (newContent === undefined) return []; 94 | const [newLines, pin] = Array.isArray(newContent) 95 | ? ([newContent, undefined] as const) 96 | : ([newContent.lines, newContent.pin] as const); 97 | 98 | if (newLines.length === 0) return [{ deleted: true }]; 99 | 100 | const changes = page.lines === newLines 101 | ? [] 102 | : [...makeChanges(page, newLines, page.userId)]; 103 | if ( 104 | pin !== undefined && ((pin && page.pin === 0) || (!pin && page.pin > 0)) 105 | ) { 106 | changes.push({ pin: pin ? pinNumber() : 0 }); 107 | } 108 | return changes; 109 | }, 110 | options, 111 | ); 112 | -------------------------------------------------------------------------------- /websocket/pin.ts: -------------------------------------------------------------------------------- 1 | import type { Result } from "option-t/plain_result"; 2 | import type { ChangeToPush } from "@cosense/types/websocket"; 3 | import { push, type PushError, type PushOptions } from "./push.ts"; 4 | 5 | export interface PinOptions extends PushOptions { 6 | /** Option to control behavior when the target page doesn't exist 7 | * 8 | * - `true`: Create a new page with just the title and pin it 9 | * - `false`: Do not pin (skip the operation) 10 | * 11 | * This is useful when you want to create and pin placeholder pages 12 | * that will be filled with content later. 13 | * 14 | * @default {false} 15 | */ 16 | create?: boolean; 17 | } 18 | 19 | /** Pin a Scrapbox page to keep it at the top of the project 20 | * 21 | * Pinned pages are sorted by their pin number, which is calculated 22 | * based on the current timestamp to maintain a stable order. 23 | * Higher pin numbers appear first in the list. 24 | * 25 | * > [!NOTE] 26 | * > If you want to modify the page content while pinning it, {@linkcode patch} is more suitable. 27 | * 28 | * @param project - Project containing the target page 29 | * @param title - Title of the page to pin 30 | * @param options - Optional settings: 31 | * - socket: Custom WebSocket connection 32 | * - create: Whether to create non-existent pages 33 | * @returns A {@linkcode Promise} that resolves to a {@linkcode Result} containing: 34 | * - Success: The title of the pinned page as a {@linkcode string} 35 | * - Error: A {@linkcode PushError} describing what went wrong 36 | */ 37 | export const pin = ( 38 | project: string, 39 | title: string, 40 | options?: PinOptions, 41 | ): Promise> => 42 | push( 43 | project, 44 | title, 45 | (page) => { 46 | // Skip if already pinned or if page doesn't exist and create=false 47 | if ( 48 | page.pin > 0 || (!page.persistent && !(options?.create ?? false)) 49 | ) return []; 50 | // Create page and pin it in a single operation 51 | const changes: ChangeToPush[] = [{ pin: pinNumber() }]; 52 | if (!page.persistent) changes.unshift({ title }); 53 | return changes; 54 | }, 55 | options, 56 | ); 57 | 58 | export interface UnPinOptions extends PushOptions {} 59 | 60 | /** Unpin a Scrapbox page, removing it from the pinned list 61 | * 62 | * This sets the page's pin number to `0`, which effectively unpins it. 63 | * 64 | * > [!NOTE] 65 | * > If you want to modify the page content while unpinning it, {@linkcode patch} is more suitable. 66 | * 67 | * @param project - Project containing the target page 68 | * @param title - Title of the page to unpin 69 | * @returns A {@linkcode Promise} that resolves to a {@linkcode Result} containing: 70 | * - Success: The title of the unpinned page as a {@linkcode string} 71 | * - Error: A {@linkcode PushError} describing what went wrong 72 | */ 73 | export const unpin = ( 74 | project: string, 75 | title: string, 76 | options: UnPinOptions, 77 | ): Promise> => 78 | push( 79 | project, 80 | title, 81 | (page) => 82 | // Skip if already unpinned or if page doesn't exist 83 | page.pin == 0 || !page.persistent ? [] : [{ pin: 0 }], 84 | options, 85 | ); 86 | 87 | /** Calculate a pin number for sorting pinned pages 88 | * 89 | * The pin number is calculated as: 90 | * the {@linkcode Number.MAX_SAFE_INTEGER} - (current Unix timestamp in seconds) 91 | * 92 | * This ensures that: 93 | * 1. More recently pinned pages appear lower in the pinned list 94 | * 2. Pin numbers are unique and stable 95 | * 3. There's enough room for future pins (the {@linkcode Number.MAX_SAFE_INTEGER} is very large) 96 | */ 97 | export const pinNumber = (): number => 98 | Number.MAX_SAFE_INTEGER - Math.floor(Date.now() / 1000); 99 | -------------------------------------------------------------------------------- /websocket/socket.ts: -------------------------------------------------------------------------------- 1 | import { io, type Socket } from "socket.io-client"; 2 | import { createErr, createOk, type Result } from "option-t/plain_result"; 3 | import type { EmitEventMap, ListenEventMap } from "@cosense/types/websocket"; 4 | import { cookie } from "../rest/auth.ts"; 5 | 6 | /** A pre-configured {@linkcode Socket} type for Scrapbox */ 7 | export type ScrapboxSocket = Socket; 8 | 9 | /** connect to websocket 10 | * 11 | * @param socket - The {@linkcode Socket} to be connected. If not provided, a new socket will be created 12 | * @param sid - Scrapbox session ID (connect.sid). This is only required in Deno/Node.js environment. 13 | * @returns A {@linkcode Promise}<{@linkcode Socket}> that resolves to a {@linkcode Socket} if connected successfully, or an {@linkcode Error} if failed 14 | */ 15 | export const connect = (socket?: ScrapboxSocket, sid?: string): Promise< 16 | Result 17 | > => { 18 | if (socket?.connected) return Promise.resolve(createOk(socket)); 19 | socket ??= io("https://scrapbox.io", { 20 | reconnectionDelay: 5000, 21 | transports: ["websocket"], 22 | ...(sid 23 | ? { 24 | rejectUnauthorized: false, 25 | extraHeaders: { 26 | Cookie: cookie(sid), 27 | Host: "scrapbox.io", 28 | Referer: "https://scrapbox.io/", 29 | }, 30 | } 31 | : {}), 32 | }); 33 | 34 | const promise = new Promise< 35 | Result 36 | >( 37 | (resolve) => { 38 | const onDisconnect = (reason: Socket.DisconnectReason) => 39 | resolve(createErr(reason)); 40 | socket.once("connect", () => { 41 | socket.off("disconnect", onDisconnect); 42 | resolve(createOk(socket)); 43 | }); 44 | socket.once("disconnect", onDisconnect); 45 | }, 46 | ); 47 | socket.connect(); 48 | return promise; 49 | }; 50 | 51 | /** Disconnect the websocket 52 | * 53 | * @param socket - The socket to be disconnected 54 | */ 55 | export const disconnect = ( 56 | socket: ScrapboxSocket, 57 | ): Promise< 58 | Result< 59 | void, 60 | | "io server disconnect" 61 | | "ping timeout" 62 | | "transport close" 63 | | "transport error" 64 | | "parse error" 65 | > 66 | > => { 67 | if (socket.disconnected) return Promise.resolve(createOk(undefined)); 68 | 69 | const promise = new Promise< 70 | Result< 71 | void, 72 | | "io server disconnect" 73 | | "ping timeout" 74 | | "transport close" 75 | | "transport error" 76 | | "parse error" 77 | > 78 | >( 79 | (resolve) => { 80 | const onDisconnect = (reason: Socket.DisconnectReason) => { 81 | if (reason !== "io client disconnect") { 82 | resolve(createErr(reason)); 83 | return; 84 | } 85 | resolve(createOk(undefined)); 86 | socket.off("disconnect", onDisconnect); 87 | }; 88 | socket.on("disconnect", onDisconnect); 89 | }, 90 | ); 91 | socket.disconnect(); 92 | return promise; 93 | }; 94 | -------------------------------------------------------------------------------- /websocket/suggestUnDupTitle.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Suggest a new title that is already in use. 3 | * 4 | * ```ts 5 | * import { assertEquals } from "@std/assert/equals"; 6 | * 7 | * assertEquals(suggestUnDupTitle("title"), "title_2"); 8 | * assertEquals(suggestUnDupTitle("title_2"), "title_3"); 9 | * assertEquals(suggestUnDupTitle("title_10"), "title_11"); 10 | * assertEquals(suggestUnDupTitle("title_10_3"), "title_10_4"); 11 | * assertEquals(suggestUnDupTitle("another_title_5"), "another_title_6"); 12 | * ``` 13 | * 14 | * @param title - The title to suggest a new name for 15 | * @returns 16 | */ 17 | export const suggestUnDupTitle = (title: string): string => { 18 | const matched = title.match(/(.+?)(?:_(\d+))?$/); 19 | const title_ = matched?.[1] ?? title; 20 | const num = matched?.[2] ? parseInt(matched[2]) + 1 : 2; 21 | return `${title_}_${num}`; 22 | }; 23 | --------------------------------------------------------------------------------