diff --git a/browser/websocket/patch.test.ts b/browser/websocket/diffToChanges.test.ts similarity index 98% rename from browser/websocket/patch.test.ts rename to browser/websocket/diffToChanges.test.ts index 5eeeb85..c1efa18 100644 --- a/browser/websocket/patch.test.ts +++ b/browser/websocket/diffToChanges.test.ts @@ -1,5 +1,5 @@ /// -import { diffToChanges } from "./patch.ts"; +import { diffToChanges } from "./diffToChanges.ts"; import { assertEquals } from "../../deps/testing.ts"; Deno.test("diffToChanges()", async ({ step }) => { diff --git a/browser/websocket/patch.ts b/browser/websocket/diffToChanges.ts similarity index 95% rename from browser/websocket/patch.ts rename to browser/websocket/diffToChanges.ts index 37d63e7..84f7f38 100644 --- a/browser/websocket/patch.ts +++ b/browser/websocket/diffToChanges.ts @@ -11,7 +11,7 @@ type Options = { userId: string; }; export function* diffToChanges( - left: Omit[], + left: Pick[], right: string[], { userId }: Options, ): Generator { diff --git a/browser/websocket/makeChanges.ts b/browser/websocket/makeChanges.ts new file mode 100644 index 0000000..46392b4 --- /dev/null +++ b/browser/websocket/makeChanges.ts @@ -0,0 +1,136 @@ +import { diffToChanges } from "./diffToChanges.ts"; +import type { Line } from "../../deps/scrapbox.ts"; +import { + Block, + convertToBlock, + Node, + packRows, + parseToRows, +} from "../../deps/scrapbox.ts"; +import type { Change } from "../../deps/socket.ts"; +import { toTitleLc } from "../../title.ts"; + +export interface HeadData { + commitId: string; + persistent: boolean; + image: string | null; + linksLc: string[]; + lines: Line[]; +} +export interface Init { + userId: string; + head: HeadData; +} +export function makeChanges( + left: Pick[], + right: string[], + { userId, head }: Init, +) { + // 本文の差分 + const changes: Change[] = [...diffToChanges(left, right, { userId })]; + + // titleの差分を入れる + // 空ページの場合もタイトル変更commitを入れる + if (left[0].text !== right[0] || !head.persistent) { + changes.push({ title: right[0] }); + } + + // descriptionsの差分を入れる + const leftDescriptions = left.slice(1, 6).map((line) => line.text); + const rightDescriptions = right.slice(1, 6); + if (leftDescriptions.join("") !== rightDescriptions.join("")) { + changes.push({ descriptions: rightDescriptions }); + } + + // リンクと画像の差分を入れる + const [linksLc, image] = findLinksAndImage(right.join("\n")); + if ( + head.linksLc.length !== linksLc.length || + !head.linksLc.every((link) => linksLc.includes(link)) + ) { + changes.push({ links: linksLc }); + } + if (head.image !== image) { + changes.push({ image }); + } + + return changes; +} + +/** テキストに含まれる全てのリンクと最初の画像を探す */ +function findLinksAndImage(text: string): [string[], string | null] { + const rows = parseToRows(text); + const blocks = packRows(rows, { hasTitle: true }).flatMap((pack) => { + switch (pack.type) { + case "codeBlock": + case "title": + return []; + case "line": + case "table": + return [convertToBlock(pack)]; + } + }); + + const linksLc = [] as string[]; + let image: string | null = null; + + const lookup = (node: Node) => { + switch (node.type) { + case "hashTag": + linksLc.push(toTitleLc(node.href)); + return; + case "link": { + if (node.pathType !== "relative") return; + linksLc.push(toTitleLc(node.href)); + return; + } + case "image": + case "strongImage": { + image ??= node.src.endsWith("/thumb/1000") + ? node.src.replace(/\/thumb\/1000$/, "/raw") + : node.src; + return; + } + case "strong": + case "quote": + case "decoration": { + for (const n of node.nodes) { + lookup(n); + } + return; + } + default: + return; + } + }; + for (const node of blocksToNodes(blocks)) { + lookup(node); + } + + return [linksLc, image]; +} + +function* blocksToNodes(blocks: Iterable) { + for (const block of blocks) { + switch (block.type) { + case "codeBlock": + case "title": + continue; + case "line": + for (const node of block.nodes) { + yield node; + } + continue; + case "table": { + for (const row of block.cells) { + for (const nodes of row) { + for (const node of nodes) { + yield node; + } + } + } + continue; + } + } + } +} diff --git a/browser/websocket/room.ts b/browser/websocket/room.ts index 04be068..7d46458 100644 --- a/browser/websocket/room.ts +++ b/browser/websocket/room.ts @@ -1,12 +1,8 @@ -import { - Change, - CommitNotification, - socketIO, - wrap, -} from "../../deps/socket.ts"; +import { CommitNotification, socketIO, wrap } from "../../deps/socket.ts"; import { getProjectId, getUserId } from "./id.ts"; -import { diffToChanges } from "./patch.ts"; import { applyCommit } from "./applyCommit.ts"; +import { toTitleLc } from "../../title.ts"; +import { makeChanges } from "./makeChanges.ts"; import type { Line } from "../../deps/scrapbox.ts"; import { ensureEditablePage, pushCommit } from "./_fetch.ts"; export type { CommitNotification }; @@ -48,9 +44,13 @@ export async function joinPageRoom( ]); // 接続したページの情報 - let parentId = page.commitId; - let created = page.persistent; - let lines = page.lines; + let head = { + persistent: page.persistent, + lines: page.lines, + image: page.image, + commitId: page.commitId, + linksLc: page.links.map((link) => toTitleLc(link)), + }; const pageId = page.id; const io = await socketIO(); @@ -63,55 +63,36 @@ export async function joinPageRoom( // subscribe the latest commit (async () => { for await (const { id, changes } of response("commit")) { - parentId = id; - lines = applyCommit(lines, changes, { updated: id, userId }); + head.commitId = id; + head.lines = applyCommit(head.lines, changes, { updated: id, userId }); } })(); return { patch: async (update: (before: Line[]) => string[] | Promise) => { - const tryPush = async () => { - const pending = update(lines); - const newLines = pending instanceof Promise ? await pending : pending; - const changes: Change[] = [ - ...diffToChanges(lines, newLines, { userId }), - ]; - - // 変更後のlinesを計算する - const changedLines = applyCommit(lines, changes, { - userId, - }); - // タイトルの変更チェック - // 空ページの場合もタイトル変更commitを入れる - if (lines[0].text !== changedLines[0].text || !created) { - changes.push({ title: changedLines[0].text }); - } - // サムネイルの変更チェック - const oldDescriptions = lines.slice(1, 6).map((line) => line.text); - const newDescriptions = changedLines.slice(1, 6).map((lines) => - lines.text - ); - if (oldDescriptions.join("\n") !== newDescriptions.join("\n")) { - changes.push({ descriptions: newDescriptions }); - } - - // pushする - const { commitId } = await pushCommit(request, changes, { - parentId, - projectId, - pageId, - userId, - }); - - // pushに成功したら、localにも変更を反映する - parentId = commitId; - created = true; - lines = changedLines; - }; - for (let i = 0; i < 3; i++) { try { - await tryPush(); + const pending = update(head.lines); + const newLines = pending instanceof Promise ? await pending : pending; + const changes = makeChanges(head.lines, newLines, { + userId, + head, + }); + + const { commitId } = await pushCommit(request, changes, { + parentId: head.commitId, + projectId, + pageId, + userId, + }); + + // pushに成功したら、localにも変更を反映する + head.commitId = commitId; + head.persistent = true; + head.lines = applyCommit(head.lines, changes, { + updated: commitId, + userId, + }); break; } catch (_e: unknown) { if (i === 2) { @@ -122,9 +103,13 @@ export async function joinPageRoom( ); try { const page = await ensureEditablePage(project, title); - parentId = page.commitId; - created = page.persistent; - lines = page.lines; + head = { + persistent: page.persistent, + lines: page.lines, + image: page.image, + commitId: page.commitId, + linksLc: page.links.map((link) => toTitleLc(link)), + }; } catch (e: unknown) { throw e; } diff --git a/browser/websocket/shortcuts.ts b/browser/websocket/shortcuts.ts index bdc5a09..e475512 100644 --- a/browser/websocket/shortcuts.ts +++ b/browser/websocket/shortcuts.ts @@ -1,9 +1,9 @@ -import { Change, socketIO, wrap } from "../../deps/socket.ts"; +import { socketIO, wrap } from "../../deps/socket.ts"; import { getProjectId, getUserId } from "./id.ts"; -import { diffToChanges } from "./patch.ts"; -import { applyCommit } from "./applyCommit.ts"; +import { makeChanges } from "./makeChanges.ts"; import { pinNumber } from "./pin.ts"; import type { Line } from "../../deps/scrapbox.ts"; +import { toTitleLc } from "../../title.ts"; import { ensureEditablePage, pushCommit, pushWithRetry } from "./_fetch.ts"; /** 指定したページを削除する @@ -68,53 +68,32 @@ export async function patch( getUserId(), ]); - let persistent = page.persistent; - let lines = page.lines; - let parentId = page.commitId; + let head = { + persistent: page.persistent, + lines: page.lines, + image: page.image, + commitId: page.commitId, + linksLc: page.links.map((link) => toTitleLc(link)), + }; const pageId = page.id; const io = await socketIO(); try { const { request } = wrap(io); - const tryPush = async () => { - const pending = update(lines); - const newLines = pending instanceof Promise ? await pending : pending; - const changes: Change[] = [ - ...diffToChanges(lines, newLines, { userId }), - ]; - - // 変更後のlinesを計算する - const changedLines = applyCommit(lines, changes, { - userId, - }); - // タイトルの変更チェック - // 空ページの場合もタイトル変更commitを入れる - if (lines[0].text !== changedLines[0].text || !persistent) { - changes.push({ title: changedLines[0].text }); - } - // サムネイルの変更チェック - const oldDescriptions = lines.slice(1, 6).map((line) => line.text); - const newDescriptions = changedLines.slice(1, 6).map((lines) => - lines.text - ); - if (oldDescriptions.join("\n") !== newDescriptions.join("\n")) { - changes.push({ descriptions: newDescriptions }); - } - - // pushする - await pushCommit(request, changes, { - parentId, - projectId, - pageId, - userId, - }); - }; - // 3回retryする for (let i = 0; i < 3; i++) { try { - await tryPush(); + const pending = update(head.lines); + const newLines = pending instanceof Promise ? await pending : pending; + const changes = makeChanges(head.lines, newLines, { userId, head }); + + await pushCommit(request, changes, { + parentId: head.commitId, + projectId, + pageId, + userId, + }); break; } catch (_e: unknown) { if (i === 2) { @@ -125,9 +104,13 @@ export async function patch( ); try { const page = await ensureEditablePage(project, title); - parentId = page.commitId; - persistent = page.persistent; - lines = page.lines; + head = { + persistent: page.persistent, + lines: page.lines, + image: page.image, + commitId: page.commitId, + linksLc: page.links.map((link) => toTitleLc(link)), + }; } catch (e: unknown) { throw e; } diff --git a/deps/scrapbox.ts b/deps/scrapbox.ts index 34f115b..a522627 100644 --- a/deps/scrapbox.ts +++ b/deps/scrapbox.ts @@ -14,4 +14,5 @@ export type { Scrapbox, } from "https://raw.githubusercontent.com/scrapbox-jp/types/0.0.8/mod.ts"; import type { Page } from "https://raw.githubusercontent.com/scrapbox-jp/types/0.0.8/mod.ts"; +export * from "https://esm.sh/@progfay/scrapbox-parser@7.2.0"; export type Line = Page["lines"][0]; diff --git a/deps/socket.ts b/deps/socket.ts index 4ff127a..e9e8cf3 100644 --- a/deps/socket.ts +++ b/deps/socket.ts @@ -9,8 +9,8 @@ export type { ProjectUpdatesStreamCommit, ProjectUpdatesStreamEvent, UpdateCommit, -} from "https://raw.githubusercontent.com/takker99/scrapbox-userscript-websocket/0.1.3/mod.ts"; +} from "https://raw.githubusercontent.com/takker99/scrapbox-userscript-websocket/0.1.4/mod.ts"; export { socketIO, wrap, -} from "https://raw.githubusercontent.com/takker99/scrapbox-userscript-websocket/0.1.3/mod.ts"; +} from "https://raw.githubusercontent.com/takker99/scrapbox-userscript-websocket/0.1.4/mod.ts";