diff --git a/browser/websocket/__snapshots__/_codeBlock.test.ts.snap b/browser/websocket/__snapshots__/_codeBlock.test.ts.snap new file mode 100644 index 0000000..361f07e --- /dev/null +++ b/browser/websocket/__snapshots__/_codeBlock.test.ts.snap @@ -0,0 +1,57 @@ +export const snapshot = {}; + +snapshot[`extractFromCodeTitle() > accurate titles > "code:foo.extA(extB)" 1`] = ` +{ + filename: "foo.extA", + indent: 0, + lang: "extB", +} +`; + +snapshot[`extractFromCodeTitle() > accurate titles > " code:foo.extA(extB)" 1`] = ` +{ + filename: "foo.extA", + indent: 1, + lang: "extB", +} +`; + +snapshot[`extractFromCodeTitle() > accurate titles > " code: foo.extA (extB)" 1`] = ` +{ + filename: "foo.extA", + indent: 2, + lang: "extB", +} +`; + +snapshot[`extractFromCodeTitle() > accurate titles > " code: foo (extB) " 1`] = ` +{ + filename: "foo", + indent: 2, + lang: "extB", +} +`; + +snapshot[`extractFromCodeTitle() > accurate titles > " code: foo.extA " 1`] = ` +{ + filename: "foo.extA", + indent: 2, + lang: "extA", +} +`; + +snapshot[`extractFromCodeTitle() > accurate titles > " code: foo " 1`] = ` +{ + filename: "foo", + indent: 2, + lang: "foo", +} +`; + +snapshot[`extractFromCodeTitle() > accurate titles > " code: .foo " 1`] = ` +{ + filename: ".foo", + indent: 2, + lang: ".foo", +} +`; diff --git a/browser/websocket/_codeBlock.test.ts b/browser/websocket/_codeBlock.test.ts new file mode 100644 index 0000000..64f1313 --- /dev/null +++ b/browser/websocket/_codeBlock.test.ts @@ -0,0 +1,36 @@ +/// + +import { assertEquals, assertSnapshot } from "../../deps/testing.ts"; +import { extractFromCodeTitle } from "./_codeBlock.ts"; + +Deno.test("extractFromCodeTitle()", async (t) => { + await t.step("accurate titles", async (st) => { + const titles = [ + "code:foo.extA(extB)", + " code:foo.extA(extB)", + " code: foo.extA (extB)", + " code: foo (extB) ", + " code: foo.extA ", + " code: foo ", + " code: .foo ", + ]; + for (const title of titles) { + await st.step(`"${title}"`, async (sst) => { + await assertSnapshot(sst, extractFromCodeTitle(title)); + }); + } + }); + + await t.step("inaccurate titles", async (st) => { + const nonTitles = [ + " code: foo. ", // コードブロックにはならないので`null`が正常 + "any:code: foo ", + " I'm not code block ", + ]; + for (const title of nonTitles) { + await st.step(`"${title}"`, async () => { + await assertEquals(null, extractFromCodeTitle(title)); + }); + } + }); +}); diff --git a/browser/websocket/_codeBlock.ts b/browser/websocket/_codeBlock.ts new file mode 100644 index 0000000..c3f57fb --- /dev/null +++ b/browser/websocket/_codeBlock.ts @@ -0,0 +1,77 @@ +import { Change, Socket, wrap } from "../../deps/socket.ts"; +import { TinyCodeBlock } from "../../rest/getCodeBlocks.ts"; +import { HeadData } from "./pull.ts"; +import { getProjectId, getUserId } from "./id.ts"; +import { pushWithRetry } from "./_fetch.ts"; + +/** コードブロックのタイトル行の情報を保持しておくためのinterface */ +export interface CodeTitle { + filename: string; + lang: string; + indent: number; +} + +/** コミットを送信する一連の処理 */ +export const applyCommit = async ( + commits: Change[], + head: HeadData, + projectName: string, + pageTitle: string, + socket: Socket, + userId?: string, +): ReturnType => { + const [projectId, userId_] = await Promise.all([ + getProjectId(projectName), + userId ?? getUserId(), + ]); + const { request } = wrap(socket); + return await pushWithRetry(request, commits, { + parentId: head.commitId, + projectId: projectId, + pageId: head.pageId, + userId: userId_, + project: projectName, + title: pageTitle, + retry: 3, + }); +}; + +/** コードブロックのタイトル行から各種プロパティを抽出する + * + * @param lineText {string} 行テキスト + * @return `lineText`がコードタイトル行であれば`CodeTitle`を、そうでなければ`null`を返す + */ +export const extractFromCodeTitle = (lineText: string): CodeTitle | null => { + const matched = lineText.match(/^(\s*)code:(.+?)(\(.+\)){0,1}\s*$/); + if (matched === null) return null; + const filename = matched[2].trim(); + let lang = ""; + if (matched[3] === undefined) { + const ext = filename.match(/.+\.(.*)$/); + if (ext === null) { + // `code:ext` + lang = filename; + } else if (ext[1] === "") { + // `code:foo.`の形式はコードブロックとして成り立たないので排除する + return null; + } else { + // `code:foo.ext` + lang = ext[1].trim(); + } + } else { + lang = matched[3].slice(1, -1); + } + return { + filename: filename, + lang: lang, + indent: matched[1].length, + }; +}; + +/** コードブロック本文のインデント数を計算する */ +export function countBodyIndent( + codeBlock: Pick, +): number { + return codeBlock.titleLine.text.length - + codeBlock.titleLine.text.trimStart().length + 1; +} diff --git a/browser/websocket/isSimpleCodeFile.test.ts b/browser/websocket/isSimpleCodeFile.test.ts new file mode 100644 index 0000000..19a1761 --- /dev/null +++ b/browser/websocket/isSimpleCodeFile.test.ts @@ -0,0 +1,28 @@ +import { assert, assertFalse } from "../../deps/testing.ts"; +import { isSimpleCodeFile } from "./isSimpleCodeFile.ts"; +import { SimpleCodeFile } from "./updateCodeFile.ts"; + +const codeFile: SimpleCodeFile = { + filename: "filename", + content: ["line 0", "line 1"], + lang: "language", +}; + +Deno.test("isSimpleCodeFile()", async (t) => { + await t.step("SimpleCodeFile object", () => { + assert(isSimpleCodeFile(codeFile)); + assert(isSimpleCodeFile({ ...codeFile, content: "line 0" })); + assert(isSimpleCodeFile({ ...codeFile, lang: undefined })); + }); + await t.step("similer objects", () => { + assertFalse(isSimpleCodeFile({ ...codeFile, filename: 10 })); + assertFalse(isSimpleCodeFile({ ...codeFile, content: 10 })); + assertFalse(isSimpleCodeFile({ ...codeFile, content: [0, 1] })); + assertFalse(isSimpleCodeFile({ ...codeFile, lang: 10 })); + }); + await t.step("other type values", () => { + assertFalse(isSimpleCodeFile(10)); + assertFalse(isSimpleCodeFile(undefined)); + assertFalse(isSimpleCodeFile(["0", "1", "2"])); + }); +}); diff --git a/browser/websocket/isSimpleCodeFile.ts b/browser/websocket/isSimpleCodeFile.ts new file mode 100644 index 0000000..3aabccf --- /dev/null +++ b/browser/websocket/isSimpleCodeFile.ts @@ -0,0 +1,15 @@ +import { SimpleCodeFile } from "./updateCodeFile.ts"; + +/** objectがSimpleCodeFile型かどうかを判別する */ +export function isSimpleCodeFile(obj: unknown): obj is SimpleCodeFile { + if (Array.isArray(obj) || !(obj instanceof Object)) return false; + const code = obj as SimpleCodeFile; + const { filename, content, lang } = code; + return ( + typeof filename == "string" && + (typeof content == "string" || + (Array.isArray(content) && + (content.length == 0 || typeof content[0] == "string"))) && + (typeof lang == "string" || lang === undefined) + ); +} diff --git a/browser/websocket/mod.ts b/browser/websocket/mod.ts index 703fb8a..01c1cf2 100644 --- a/browser/websocket/mod.ts +++ b/browser/websocket/mod.ts @@ -3,3 +3,5 @@ export * from "./patch.ts"; export * from "./deletePage.ts"; export * from "./pin.ts"; export * from "./listen.ts"; +export * from "./updateCodeBlock.ts"; +export * from "./updateCodeFile.ts"; diff --git a/browser/websocket/updateCodeBlock.ts b/browser/websocket/updateCodeBlock.ts new file mode 100644 index 0000000..fab7791 --- /dev/null +++ b/browser/websocket/updateCodeBlock.ts @@ -0,0 +1,161 @@ +import { Line } from "../../deps/scrapbox-rest.ts"; +import { + DeleteCommit, + InsertCommit, + Socket, + socketIO, + UpdateCommit, +} from "../../deps/socket.ts"; +import { TinyCodeBlock } from "../../rest/getCodeBlocks.ts"; +import { diffToChanges } from "./diffToChanges.ts"; +import { getUserId } from "./id.ts"; +import { isSimpleCodeFile } from "./isSimpleCodeFile.ts"; +import { pull } from "./pull.ts"; +import { SimpleCodeFile } from "./updateCodeFile.ts"; +import { + applyCommit, + countBodyIndent, + extractFromCodeTitle, +} from "./_codeBlock.ts"; + +export interface UpdateCodeBlockOptions { + /** WebSocketの通信に使うsocket */ + socket?: Socket; + + /** `true`でデバッグ出力ON */ + debug?: boolean; +} + +/** コードブロックの中身を更新する + * + * newCodeにSimpleCodeFileオブジェクトを渡すと、そのオブジェクトに添ってコードブロックのファイル名も書き換えます + * (文字列や文字列配列を渡した場合は書き換えません)。 + * + * @param newCode 更新後のコードブロック + * @param target 更新対象のコードブロック + * @param project 更新対象のコードブロックが存在するプロジェクト名 + */ +export const updateCodeBlock = async ( + newCode: string | string[] | SimpleCodeFile, + target: TinyCodeBlock, + options?: UpdateCodeBlockOptions, +) => { + /** optionsの既定値はこの中に入れる */ + const defaultOptions: Required = { + socket: options?.socket ?? await socketIO(), + debug: false, + }; + const opt = options ? { ...defaultOptions, ...options } : defaultOptions; + const { projectName, pageTitle } = target.pageInfo; + const [ + head, + userId, + ] = await Promise.all([ + pull(projectName, pageTitle), + getUserId(), + ]); + const newCodeBody = getCodeBody(newCode); + const bodyIndent = countBodyIndent(target); + const oldCodeWithoutIndent: Line[] = target.bodyLines.map((e) => { + return { ...e, text: e.text.slice(bodyIndent) }; + }); + + const diffGenerator = diffToChanges(oldCodeWithoutIndent, newCodeBody, { + userId, + }); + const commits = [...fixCommits([...diffGenerator], target)]; + if (isSimpleCodeFile(newCode)) { + const titleCommit = makeTitleChangeCommit(newCode, target); + if (titleCommit) commits.push(titleCommit); + } + + if (opt.debug) { + console.log("%cvvv original code block vvv", "color: limegreen;"); + console.log(target); + console.log("%cvvv new codes vvv", "color: limegreen;"); + console.log(newCode); + console.log("%cvvv commits vvv", "color: limegreen;"); + console.log(commits); + } + + await applyCommit(commits, head, projectName, pageTitle, opt.socket, userId); + if (!options?.socket) opt.socket.disconnect(); +}; + +/** コード本文のテキストを取得する */ +const getCodeBody = (code: string | string[] | SimpleCodeFile): string[] => { + const content = isSimpleCodeFile(code) ? code.content : code; + if (Array.isArray(content)) return content; + return content.split("\n"); +}; + +/** insertコミットの行IDとtextのインデントを修正する */ +function* fixCommits( + commits: readonly (DeleteCommit | InsertCommit | UpdateCommit)[], + target: TinyCodeBlock, +): Generator { + const { nextLine } = target; + const indent = " ".repeat(countBodyIndent(target)); + for (const commit of commits) { + if ("_delete" in commit) { + yield commit; + } else if ( + "_update" in commit + ) { + yield { + ...commit, + lines: { + ...commit.lines, + text: indent + commit.lines.text, + }, + }; + } else if ( + commit._insert != "_end" || + nextLine === null + ) { + yield { + ...commit, + lines: { + ...commit.lines, + text: indent + commit.lines.text, + }, + }; + } else { + yield { + _insert: nextLine.id, + lines: { + ...commit.lines, + text: indent + commit.lines.text, + }, + }; + } + } +} + +/** コードタイトルが違う場合は書き換える */ +const makeTitleChangeCommit = ( + code: SimpleCodeFile, + target: Pick, +): UpdateCommit | null => { + const lineId = target.titleLine.id; + const targetTitle = extractFromCodeTitle(target.titleLine.text); + if ( + targetTitle && + code.filename.trim() == targetTitle.filename && + code.lang?.trim() == targetTitle.lang + ) return null; + const ext = (() => { + const matched = code.filename.match(/.+\.(.*)$/); + if (matched === null) return code.filename; + else if (matched[1] === "") return ""; + else return matched[1].trim(); + })(); + const title = code.filename + + (code.lang && code.lang != ext ? `(${code.lang})` : ""); + return { + _update: lineId, + lines: { + text: " ".repeat(countBodyIndent(target) - 1) + "code:" + title, + }, + }; +}; diff --git a/browser/websocket/updateCodeFile.ts b/browser/websocket/updateCodeFile.ts new file mode 100644 index 0000000..edad586 --- /dev/null +++ b/browser/websocket/updateCodeFile.ts @@ -0,0 +1,239 @@ +import type { Line } from "../../deps/scrapbox-rest.ts"; +import { + DeleteCommit, + InsertCommit, + Socket, + socketIO, + UpdateCommit, +} from "../../deps/socket.ts"; +import { getCodeBlocks, TinyCodeBlock } from "../../rest/getCodeBlocks.ts"; +import { pull } from "./pull.ts"; +import { createNewLineId, getUserId } from "./id.ts"; +import { diff, toExtendedChanges } from "../../deps/onp.ts"; +import { applyCommit, countBodyIndent } from "./_codeBlock.ts"; + +/** コードブロックの上書きに使う情報のinterface */ +export interface SimpleCodeFile { + /** ファイル名 */ + filename: string; + + /** コードブロックの中身(文字列のみ) */ + content: string | string[]; + + /** コードブロック内の強調表示に使う言語名(省略時はfilenameに含まれる拡張子を使用する) */ + lang?: string; +} + +/** updateCodeFile()に使われているオプション */ +export interface UpdateCodeFileOptions { + /** + * 指定したファイルが存在しなかった時、新しいコードブロックをページのどの位置に配置するか + * + * - `"notInsert"`(既定):存在しなかった場合は何もしない + * - `"top"`:ページ上部(タイトル行の真下) + * - `"bottom"`:ページ下部 + */ + insertPositionIfNotExist?: "top" | "bottom" | "notInsert"; + + /** `true`の場合、コードブロック作成時に空行承り太郎(ページ末尾に必ず空行を設ける機能)を有効する(既定は`true`) */ + isInsertEmptyLineInTail?: boolean; + + /** WebSocketの通信に使うsocket */ + socket?: Socket; + + /** `true`でデバッグ出力ON */ + debug?: boolean; +} + +/** REST API経由で取得できるようなコードファイルの中身をまるごと書き換える + * + * ファイルが存在していなかった場合、既定では何も書き換えない \ + * + * 対象と同じ名前のコードブロックが同じページの複数の行にまたがっていた場合も全て書き換える \ + * その際、書き換え後のコードをそれぞれのコードブロックへ分散させるが、それっぽく分けるだけで見た目などは保証しないので注意 + * + * @param codeFile 書き換え後のコードファイルの中身 + * @param project 書き換えたいページのプロジェクト名(Project urlの設定で使われている方) + * @param title 書き換えたいページのタイトル + * @param options その他の設定 + */ +export const updateCodeFile = async ( + codeFile: SimpleCodeFile, + project: string, + title: string, + options?: UpdateCodeFileOptions, +): Promise => { + /** optionsの既定値はこの中に入れる */ + const defaultOptions: Required = { + insertPositionIfNotExist: "notInsert", + isInsertEmptyLineInTail: true, + socket: options?.socket ?? await socketIO(), + debug: false, + }; + const opt = options ? { ...defaultOptions, ...options } : defaultOptions; + const newCode = Array.isArray(codeFile.content) + ? codeFile.content + : codeFile.content.split("\n"); + const head = await pull(project, title); + const lines: Line[] = head.lines; + const codeBlocks = await getCodeBlocks({ + project, + title, + lines: lines, + }, { + filename: codeFile.filename, + }); + const commits = [ + ...makeCommits(codeBlocks, codeFile, lines, { + ...opt, + userId: await getUserId(), + }), + ]; + + if (opt.debug) { + console.log("%cvvv original code Blocks vvv", "color: limegreen;"); + console.log(codeBlocks); + console.log("%cvvv new codes vvv", "color: limegreen;"); + console.log(newCode); + console.log("%cvvv commits vvv", "color: limegreen;"); + console.log(commits); + } + + await applyCommit(commits, head, project, title, opt.socket); + + if (!options?.socket) opt.socket.disconnect(); +}; + +/** TinyCodeBlocksの配列からコード本文をフラットな配列に格納して返す \ + * その際、コードブロックの左側に存在していたインデントは削除する + */ +const flatCodeBodies = (codeBlocks: readonly TinyCodeBlock[]): Line[] => { + return codeBlocks.map((block) => { + const indent = countBodyIndent(block); + return block.bodyLines.map((body) => { + return { ...body, text: body.text.slice(indent) }; + }); + }).flat(); +}; + +/** コードブロックの差分からコミットデータを作成する */ +function* makeCommits( + _codeBlocks: readonly TinyCodeBlock[], + codeFile: SimpleCodeFile, + lines: Line[], + { userId, insertPositionIfNotExist, isInsertEmptyLineInTail }: { + userId: string; + insertPositionIfNotExist: Required< + UpdateCodeFileOptions["insertPositionIfNotExist"] + >; + isInsertEmptyLineInTail: Required< + UpdateCodeFileOptions["isInsertEmptyLineInTail"] + >; + }, +): Generator { + function makeIndent(codeBlock: Pick): string { + return " ".repeat(countBodyIndent(codeBlock)); + } + + const codeBlocks: Pick< + TinyCodeBlock, + "titleLine" | "bodyLines" | "nextLine" + >[] = [..._codeBlocks]; + const codeBodies = flatCodeBodies(_codeBlocks); + if (codeBlocks.length <= 0) { + // ページ内にコードブロックが無かった場合は新しく作成 + if (insertPositionIfNotExist === "notInsert") return; + const nextLine = insertPositionIfNotExist === "top" && lines.length > 1 + ? lines[1] + : null; + const title = { + // コードブロックのタイトル行 + _insert: nextLine?.id ?? "_end", + lines: { + id: createNewLineId(userId), + text: makeCodeBlockTitle(codeFile), + }, + }; + yield title; + // 新しく作成したコードブロックの情報を追記 + codeBlocks.push({ + titleLine: { ...title.lines, userId, created: -1, updated: -1 }, + bodyLines: [], + nextLine: nextLine, + }); + } + + // 差分を求める + const { buildSES } = diff( + codeBodies.map((e) => e.text), + Array.isArray(codeFile.content) + ? codeFile.content + : codeFile.content.split("\n"), + ); + let lineNo = 0; + let isInsertBottom = false; + for (const change of toExtendedChanges(buildSES())) { + // 差分からcommitを作成 + const { lineId, codeIndex } = + ((): { lineId: string; codeIndex: number } => { + if (lineNo >= codeBodies.length) { + const index = codeBlocks.length - 1; + return { + lineId: codeBlocks[index].nextLine?.id ?? "_end", + codeIndex: index, + }; + } + return { + lineId: codeBodies[lineNo].id, + codeIndex: codeBlocks.findIndex((e0) => + e0.bodyLines.some((e1) => e1.id == codeBodies[lineNo].id) + ), + }; + })(); + const codeBlock = codeBlocks[codeIndex]; + if (change.type == "added") { + const insertCodeBlock = + lineId == codeBlock.bodyLines[0]?.id && codeIndex >= 1 + ? codeBlocks[codeIndex - 1] + : codeBlocks[codeIndex]; + const id = insertCodeBlock?.nextLine?.id ?? "_end"; + yield { + _insert: id, + lines: { + id: createNewLineId(userId), + text: makeIndent(insertCodeBlock) + change.value, + }, + }; + if (id == "_end") isInsertBottom = true; + continue; + } else if (change.type == "deleted") { + yield { + _delete: lineId, + lines: -1, + }; + } else if (change.type == "replaced") { + yield { + _update: lineId, + lines: { + text: makeIndent(codeBlock) + change.value, + }, + }; + } + lineNo++; + } + if (isInsertBottom && isInsertEmptyLineInTail) { + // 空行承り太郎 + yield { + _insert: "_end", + lines: { + id: createNewLineId(userId), + text: "", + }, + }; + } +} + +const makeCodeBlockTitle = (code: SimpleCodeFile) => { + const codeName = code.filename + (code.lang ? `(${code.lang})` : ""); + return `code:${codeName}`; +}; diff --git a/rest/__snapshots__/getCodeBlocks.test.ts.snap b/rest/__snapshots__/getCodeBlocks.test.ts.snap new file mode 100644 index 0000000..3fb040d --- /dev/null +++ b/rest/__snapshots__/getCodeBlocks.test.ts.snap @@ -0,0 +1,380 @@ +export const snapshot = {}; + +snapshot[`getCodeBlocks() 1`] = ` +[ + { + bodyLines: [ + { + created: 1672982822, + id: "63b7b1261280f00000c9bc27", + text: ' print("Hello World!")', + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + ], + filename: "コードブロック.py", + lang: "py", + nextLine: { + created: 1672982822, + id: "63b7b1261280f00000c9bc28", + text: "無名コードブロック", + updated: 1672983021, + userId: "5ef2bdebb60650001e1280f0", + }, + pageInfo: { + pageTitle: "コードブロック記法", + projectName: "takker", + }, + titleLine: { + created: 1672982821, + id: "63b7b1261280f00000c9bc26", + text: "code:コードブロック.py", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + }, + { + bodyLines: [ + { + created: 1672982822, + id: "63b7b1261280f00000c9bc2a", + text: ' print("Hello World!")', + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + ], + filename: "py", + lang: "py", + nextLine: { + created: 1672982822, + id: "63b7b1261280f00000c9bc2b", + text: "インデントつき", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + pageInfo: { + pageTitle: "コードブロック記法", + projectName: "takker", + }, + titleLine: { + created: 1672982822, + id: "63b7b1261280f00000c9bc29", + text: "code:py", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + }, + { + bodyLines: [ + { + created: 1672982822, + id: "63b7b1261280f00000c9bc2d", + text: " - インデント\\r", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + { + created: 1672982822, + id: "63b7b1261280f00000c9bc2e", + text: " - インデント", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + ], + filename: "インデント.md", + lang: "md", + nextLine: { + created: 1672982822, + id: "63b7b1261280f00000c9bc2f", + text: "言語を強制", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + pageInfo: { + pageTitle: "コードブロック記法", + projectName: "takker", + }, + titleLine: { + created: 1672982822, + id: "63b7b1261280f00000c9bc2c", + text: " code:インデント.md", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + }, + { + bodyLines: [ + { + created: 1672982822, + id: "63b7b1261280f00000c9bc31", + text: \` console.log("I'm JavaScript");\`, + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + ], + filename: "python", + lang: "js", + nextLine: { + created: 1672982822, + id: "63b7b1261280f00000c9bc32", + text: "文芸的プログラミング", + updated: 1672982825, + userId: "5ef2bdebb60650001e1280f0", + }, + pageInfo: { + pageTitle: "コードブロック記法", + projectName: "takker", + }, + titleLine: { + created: 1672982822, + id: "63b7b1261280f00000c9bc30", + text: " code:python(js)", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + }, + { + bodyLines: [ + { + created: 1672982822, + id: "63b7b1261280f00000c9bc35", + text: " #include ", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + { + created: 1672982822, + id: "63b7b1261280f00000c9bc36", + text: " ", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + ], + filename: "main.cpp", + lang: "cpp", + nextLine: { + created: 1672982822, + id: "63b7b1261280f00000c9bc37", + text: " main函数の定義", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + pageInfo: { + pageTitle: "コードブロック記法", + projectName: "takker", + }, + titleLine: { + created: 1672982822, + id: "63b7b1261280f00000c9bc34", + text: " code:main.cpp", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + }, + { + bodyLines: [ + { + created: 1672982822, + id: "63b7b1261280f00000c9bc39", + text: " int main() {", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + { + created: 1672982822, + id: "63b7b1261280f00000c9bc3a", + text: ' std::cout << "Hello, C++" << "from scrapbox.io" << std::endl;', + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + { + created: 1672982822, + id: "63b7b1261280f00000c9bc3b", + text: " }", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + { + created: 1672982822, + id: "63b7b1261280f00000c9bc3c", + text: " ", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + ], + filename: "main.cpp", + lang: "cpp", + nextLine: { + created: 1672982673, + id: "63b7b0911280f00000c9bc23", + text: "", + updated: 1672982673, + userId: "5ef2bdebb60650001e1280f0", + }, + pageInfo: { + pageTitle: "コードブロック記法", + projectName: "takker", + }, + titleLine: { + created: 1672982822, + id: "63b7b1261280f00000c9bc38", + text: " code:main.cpp", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + }, +] +`; + +snapshot[`getCodeBlocks() > filename filter 1`] = ` +[ + { + bodyLines: [ + { + created: 1672982822, + id: "63b7b1261280f00000c9bc2d", + text: " - インデント\\r", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + { + created: 1672982822, + id: "63b7b1261280f00000c9bc2e", + text: " - インデント", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + ], + filename: "インデント.md", + lang: "md", + nextLine: { + created: 1672982822, + id: "63b7b1261280f00000c9bc2f", + text: "言語を強制", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + pageInfo: { + pageTitle: "コードブロック記法", + projectName: "takker", + }, + titleLine: { + created: 1672982822, + id: "63b7b1261280f00000c9bc2c", + text: " code:インデント.md", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + }, +] +`; + +snapshot[`getCodeBlocks() > language name filter 1`] = ` +[ + { + bodyLines: [ + { + created: 1672982822, + id: "63b7b1261280f00000c9bc27", + text: ' print("Hello World!")', + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + ], + filename: "コードブロック.py", + lang: "py", + nextLine: { + created: 1672982822, + id: "63b7b1261280f00000c9bc28", + text: "無名コードブロック", + updated: 1672983021, + userId: "5ef2bdebb60650001e1280f0", + }, + pageInfo: { + pageTitle: "コードブロック記法", + projectName: "takker", + }, + titleLine: { + created: 1672982821, + id: "63b7b1261280f00000c9bc26", + text: "code:コードブロック.py", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + }, + { + bodyLines: [ + { + created: 1672982822, + id: "63b7b1261280f00000c9bc2a", + text: ' print("Hello World!")', + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + ], + filename: "py", + lang: "py", + nextLine: { + created: 1672982822, + id: "63b7b1261280f00000c9bc2b", + text: "インデントつき", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + pageInfo: { + pageTitle: "コードブロック記法", + projectName: "takker", + }, + titleLine: { + created: 1672982822, + id: "63b7b1261280f00000c9bc29", + text: "code:py", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + }, +] +`; + +snapshot[`getCodeBlocks() > title line ID filter 1`] = ` +[ + { + bodyLines: [ + { + created: 1672982822, + id: "63b7b1261280f00000c9bc35", + text: " #include ", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + { + created: 1672982822, + id: "63b7b1261280f00000c9bc36", + text: " ", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + ], + filename: "main.cpp", + lang: "cpp", + nextLine: { + created: 1672982822, + id: "63b7b1261280f00000c9bc37", + text: " main函数の定義", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + pageInfo: { + pageTitle: "コードブロック記法", + projectName: "takker", + }, + titleLine: { + created: 1672982822, + id: "63b7b1261280f00000c9bc34", + text: " code:main.cpp", + updated: 1672982822, + userId: "5ef2bdebb60650001e1280f0", + }, + }, +] +`; diff --git a/rest/getCodeBlocks.test.ts b/rest/getCodeBlocks.test.ts new file mode 100644 index 0000000..02fd99a --- /dev/null +++ b/rest/getCodeBlocks.test.ts @@ -0,0 +1,272 @@ +/// + +import { Line } from "../deps/scrapbox-rest.ts"; +import { assertEquals, assertSnapshot } from "../deps/testing.ts"; +import { getCodeBlocks } from "./getCodeBlocks.ts"; + +// https://scrapbox.io/takker/コードブロック記法 +const project = "takker"; +const title = "コードブロック記法"; +const sample: Line[] = [ + { + "id": "63b7aeeb5defe7001ddae116", + "text": "コードブロック記法", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982645, + "updated": 1672982645, + }, + { + "id": "63b7b0761280f00000c9bc21", + "text": "ここでは[コードブロック]を表現する[scrapbox記法]を示す", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982645, + "updated": 1672982671, + }, + { + "id": "63b7b0791280f00000c9bc22", + "text": "", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982648, + "updated": 1672982648, + }, + { + "id": "63b7b12b1280f00000c9bc3d", + "text": "サンプル", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982826, + "updated": 1672982828, + }, + { + "id": "63b7b12c1280f00000c9bc3e", + "text": " from [/villagepump/記法サンプル#61dd289e7838e30000dc9cb5]", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982828, + "updated": 1672982835, + }, + { + "id": "63b7b1261280f00000c9bc26", + "text": "code:コードブロック.py", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982821, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc27", + "text": ' print("Hello World!")', + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc28", + "text": "無名コードブロック", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672983021, + }, + { + "id": "63b7b1261280f00000c9bc29", + "text": "code:py", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc2a", + "text": ' print("Hello World!")', + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc2b", + "text": "インデントつき", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc2c", + "text": " code:インデント.md", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc2d", + "text": " - インデント\r", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc2e", + "text": " - インデント", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc2f", + "text": "言語を強制", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc30", + "text": " code:python(js)", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc31", + "text": ' console.log("I\'m JavaScript");', + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc32", + "text": "文芸的プログラミング", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982825, + }, + { + "id": "63b7b1261280f00000c9bc33", + "text": " 標準ヘッダファイルをインクルード", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc34", + "text": " code:main.cpp", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc35", + "text": " #include ", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc36", + "text": " ", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc37", + "text": " main函数の定義", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc38", + "text": " code:main.cpp", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc39", + "text": " int main() {", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc3a", + "text": + ' std::cout << "Hello, C++" << "from scrapbox.io" << std::endl;', + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc3b", + "text": " }", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b1261280f00000c9bc3c", + "text": " ", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982822, + "updated": 1672982822, + }, + { + "id": "63b7b0911280f00000c9bc23", + "text": "", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982673, + "updated": 1672982673, + }, + { + "id": "63b7b0911280f00000c9bc24", + "text": "#2023-01-06 14:24:35 ", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982673, + "updated": 1672982674, + }, + { + "id": "63b7b0931280f00000c9bc25", + "text": "", + "userId": "5ef2bdebb60650001e1280f0", + "created": 1672982674, + "updated": 1672982674, + }, +]; + +Deno.test("getCodeBlocks()", async (t) => { + await assertSnapshot( + t, + await getCodeBlocks({ project, title, lines: sample }), + ); + await t.step("filename filter", async (st) => { + const filename = "インデント.md"; + const codeBlocks = await getCodeBlocks({ project, title, lines: sample }, { + filename, + }); + const yet = []; + for (const codeBlock of codeBlocks) { + yet.push(assertEquals(codeBlock.filename, filename)); + } + await Promise.all(yet); + await assertSnapshot(st, codeBlocks); + }); + await t.step("language name filter", async (st) => { + const lang = "py"; + const codeBlocks = await getCodeBlocks({ project, title, lines: sample }, { + lang, + }); + const yet = []; + for (const codeBlock of codeBlocks) { + yet.push(assertEquals(codeBlock.lang, lang)); + } + await Promise.all(yet); + await assertSnapshot(st, codeBlocks); + }); + await t.step("title line ID filter", async (st) => { + const titleLineId = "63b7b1261280f00000c9bc34"; + const codeBlocks = await getCodeBlocks({ project, title, lines: sample }, { + titleLineId, + }); + const yet = []; + for (const codeBlock of codeBlocks) { + yet.push(assertEquals(codeBlock.titleLine.id, titleLineId)); + } + await Promise.all(yet); + await assertSnapshot(st, codeBlocks); + }); +}); diff --git a/rest/getCodeBlocks.ts b/rest/getCodeBlocks.ts new file mode 100644 index 0000000..549e026 --- /dev/null +++ b/rest/getCodeBlocks.ts @@ -0,0 +1,139 @@ +import type { Line } from "../deps/scrapbox-rest.ts"; +import { pull } from "../browser/websocket/pull.ts"; +import { + CodeTitle, + extractFromCodeTitle, +} from "../browser/websocket/_codeBlock.ts"; + +/** pull()から取れる情報で構成したコードブロックの最低限の情報 */ +export interface TinyCodeBlock { + /** ファイル名 */ + filename: string; + + /** コードブロック内の強調表示に使う言語名 */ + lang: string; + + /** タイトル行 */ + titleLine: Line; + + /** コードブロックの中身(タイトル行を含まない) */ + bodyLines: Line[]; + + /** コードブロックの真下の行(無ければ`null`) */ + nextLine: Line | null; + + /** コードブロックが存在するページの情報 */ + pageInfo: { projectName: string; pageTitle: string }; +} + +/** `getCodeBlocks()`に渡すfilter */ +export interface GetCodeBlocksFilter { + /** ファイル名 */ + filename?: string; + /** syntax highlightに使用されている言語名 */ + lang?: string; + /** タイトル行の行ID */ + titleLineId?: string; +} + +/** 他のページ(または取得済みの行データ)のコードブロックを全て取得する + * + * ファイル単位ではなく、コードブロック単位で返り値を生成する \ + * そのため、同じページ内に同名のコードブロックが複数あったとしても、分けた状態で返す + * + * @param target 取得するページの情報(linesを渡せば内部のページ取得処理を省略する) + * @param filter 取得するコードブロックを絞り込むfilter + * @return コードブロックの配列 + */ +export const getCodeBlocks = async ( + target: { project: string; title: string; lines?: Line[] }, + filter?: GetCodeBlocksFilter, +): Promise => { + const lines = await getLines(target); + const codeBlocks: TinyCodeBlock[] = []; + + let currentCode: CodeTitle & { + /** 読み取り中の行がコードブロックかどうか */ + isCodeBlock: boolean; + } = { + isCodeBlock: false, + filename: "", + lang: "", + indent: 0, + }; + for (const line of lines) { + if (currentCode.isCodeBlock) { + const body = extractFromCodeBody(line.text, currentCode.indent); + if (body === null) { + codeBlocks[codeBlocks.length - 1].nextLine = line; + currentCode.isCodeBlock = false; + continue; + } + codeBlocks[codeBlocks.length - 1].bodyLines.push(line); + } else { + const matched = extractFromCodeTitle(line.text); + if (matched === null) { + currentCode.isCodeBlock = false; + continue; + } + currentCode = { isCodeBlock: true, ...matched }; + codeBlocks.push({ + filename: currentCode.filename, + lang: currentCode.lang, + titleLine: line, + bodyLines: [], + nextLine: null, + pageInfo: { + projectName: target.project, + pageTitle: target.title, + }, + }); + } + } + return codeBlocks.filter((codeBlock) => isMatchFilter(codeBlock, filter)); +}; + +/** targetを`Line[]`に変換する */ +const getLines = async ( + target: { project: string; title: string; lines?: Line[] }, +): Promise => { + if (target.lines !== undefined) { + return target.lines; + } else { + const head = await pull(target.project, target.title); + return head.lines; + } +}; + +/** コードブロックのフィルターに合致しているか検証する */ +const isMatchFilter = ( + codeBlock: TinyCodeBlock, + filter?: GetCodeBlocksFilter, +): boolean => { + const equals = (a: unknown, b: unknown) => !a || a === b; + return ( + equals(filter?.filename, codeBlock.filename) && + equals(filter?.lang, codeBlock.lang) && + equals(filter?.titleLineId, codeBlock.titleLine.id) + ); +}; + +/** 行テキストがコードブロックの一部であればそのテキストを、そうでなければnullを返す + * + * @param lineText {string} 行テキスト + * @param titleIndent {number} コードブロックのタイトル行のインデントの深さ + * @return `lineText`がコードブロックの一部であればそのテキストを、そうでなければ`null`を返す + */ +const extractFromCodeBody = ( + lineText: string, + titleIndent: number, +): string | null => { + const matched = lineText.replaceAll("\r", "").match(/^(\s*)(.*)$/); + if (matched === null || matched.length < 2) { + return null; + } + const indent = matched[1]; + const body = matched[2]; + if (indent.length <= titleIndent) return null; + return indent.slice(indent.length - titleIndent) + body; +}; diff --git a/rest/mod.ts b/rest/mod.ts index 8ecdc31..01c93cc 100644 --- a/rest/mod.ts +++ b/rest/mod.ts @@ -12,3 +12,4 @@ export * from "./getGyazoToken.ts"; export * from "./auth.ts"; export * from "./util.ts"; export * from "./error.ts"; +export * from "./getCodeBlocks.ts";