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";