diff --git a/browser/websocket/__snapshots__/findMetadata.test.ts.snap b/browser/websocket/__snapshots__/findMetadata.test.ts.snap new file mode 100644 index 0000000..cd4b09e --- /dev/null +++ b/browser/websocket/__snapshots__/findMetadata.test.ts.snap @@ -0,0 +1,33 @@ +export const snapshot = {}; + +snapshot[`findMetadata() 1`] = ` +[ + [ + "ふつうの", + "リンク2", + "hashtag", + ], + [ + "/help-jp/外部リンク", + ], + [ + "scrapbox", + "takker", + ], + "https://scrapbox.io/files/65f29c24974fd8002333b160.svg", + [ + "65f29c24974fd8002333b160", + "65e7f82e03949c0024a367d0", + "65e7f4413bc95600258481fb", + ], + [ + "助けてhelpfeel!!", + ], + [ + "名前 [scrapbox.icon]", + "住所 [リンク2]を入れること", + "電話番号 #をつけてもリンクにならないよ", + "自分の強み 3個くらい列挙", + ], +] +`; diff --git a/browser/websocket/findMetadata.test.ts b/browser/websocket/findMetadata.test.ts new file mode 100644 index 0000000..348c922 --- /dev/null +++ b/browser/websocket/findMetadata.test.ts @@ -0,0 +1,35 @@ +import { findMetadata, getHelpfeels } from "./findMetadata.ts"; +import { assertEquals, assertSnapshot } from "../../deps/testing.ts"; + +const text = `てすと +[ふつうの]リンク + しかし\`これは[リンク]\`ではない + +code:code + コードブロック中の[リンク]や画像[https://scrapbox.io/files/65f29c0c9045b5002522c8bb.svg]は無視される + + + ? 助けてhelpfeel!! + + table:infobox + 名前 [scrapbox.icon] + 住所 [リンク2]を入れること + 電話番号 #をつけてもリンクにならないよ + 自分の強み 3個くらい列挙 + +#hashtag もつけるといいぞ? +[/forum-jp]のようなリンクは対象外 + [/help-jp/]もだめ + [/icons/なるほど.icon][takker.icon] +[/help-jp/外部リンク] + +サムネを用意 +[https://scrapbox.io/files/65f29c24974fd8002333b160.svg] + +[https://scrapbox.io/files/65e7f4413bc95600258481fb.svg https://scrapbox.io/files/65e7f82e03949c0024a367d0.svg]`; + +Deno.test("findMetadata()", (t) => assertSnapshot(t, findMetadata(text))); +Deno.test("getHelpfeels()", () => + assertEquals(getHelpfeels(text.split("\n").map((text) => ({ text }))), [ + "助けてhelpfeel!!", + ])); diff --git a/browser/websocket/findMetadata.ts b/browser/websocket/findMetadata.ts new file mode 100644 index 0000000..5b98616 --- /dev/null +++ b/browser/websocket/findMetadata.ts @@ -0,0 +1,179 @@ +import { BaseLine, Node, parse } from "../../deps/scrapbox.ts"; +import { toTitleLc } from "../../title.ts"; +import { parseYoutube } from "../../parser/youtube.ts"; + +/** テキストに含まれているメタデータを取り出す + * + * @param text Scrapboxのテキスト + * @return 順に、links, projectLinks, icons, image, files, helpfeels, infoboxDefinition + */ +export const findMetadata = ( + text: string, +): [ + string[], + string[], + string[], + string | null, + string[], + string[], + string[], +] => { + const blocks = parse(text, { hasTitle: true }).flatMap((block) => { + switch (block.type) { + case "codeBlock": + case "title": + return []; + case "line": + case "table": + return block; + } + }); + + /** 重複判定用map + * + * bracket link とhashtagを区別できるようにしている + * - bracket linkならtrue + * + * linkの形状はbracket linkを優先している + */ + const linksLc = new Map(); + const links = [] as string[]; + const projectLinksLc = new Set(); + const projectLinks = [] as string[]; + const iconsLc = new Set(); + const icons = [] as string[]; + let image: string | null = null; + const files = new Set(); + const helpfeels = new Set(); + + const fileUrlPattern = new RegExp( + `${ + location?.origin ?? "https://scrapbox.io" + }/files/([a-z0-9]{24})(?:|\\.[a-zA-Z0-9]+)(?:|\\?[^\\s]*)$`, + ); + + const lookup = (node: Node) => { + switch (node.type) { + case "hashTag": + if (linksLc.has(toTitleLc(node.href))) return; + linksLc.set(toTitleLc(node.href), false); + links.push(node.href); + return; + case "link": + switch (node.pathType) { + case "relative": { + const link = cutId(node.href); + if (linksLc.get(toTitleLc(link))) return; + linksLc.set(toTitleLc(link), true); + links.push(link); + return; + } + case "root": { + const link = cutId(node.href); + // ignore `/project` or `/project/` + if (/^\/[\w\d-]+\/?$/.test(link)) return; + if (projectLinksLc.has(toTitleLc(link))) return; + projectLinksLc.add(toTitleLc(link)); + projectLinks.push(link); + return; + } + case "absolute": { + const props = parseYoutube(node.href); + if (props && props.pathType !== "list") { + image ??= `https://i.ytimg.com/vi/${props.videoId}/mqdefault.jpg`; + return; + } + const fileId = node.href.match(fileUrlPattern)?.[1]; + if (fileId) files.add(fileId); + return; + } + default: + return; + } + case "icon": + case "strongIcon": { + if (node.pathType === "root") return; + if (iconsLc.has(toTitleLc(node.path))) return; + iconsLc.add(toTitleLc(node.path)); + icons.push(node.path); + return; + } + case "image": + case "strongImage": { + image ??= node.src.endsWith("/thumb/1000") + ? node.src.replace(/\/thumb\/1000$/, "/raw") + : node.src; + { + const fileId = node.src.match(fileUrlPattern)?.[1]; + if (fileId) files.add(fileId); + } + if (node.type === "image") { + const fileId = node.link.match(fileUrlPattern)?.[1]; + if (fileId) files.add(fileId); + } + return; + } + case "helpfeel": + helpfeels.add(node.text); + return; + case "numberList": + case "strong": + case "quote": + case "decoration": { + for (const n of node.nodes) { + lookup(n); + } + return; + } + default: + return; + } + }; + + const infoboxDefinition = [] as string[]; + + for (const block of blocks) { + switch (block.type) { + case "line": + for (const node of block.nodes) { + lookup(node); + } + continue; + case "table": { + for (const row of block.cells) { + for (const nodes of row) { + for (const node of nodes) { + lookup(node); + } + } + } + if (!["infobox", "cosense"].includes(block.fileName)) continue; + infoboxDefinition.push( + ...block.cells.map((row) => + row.map((cell) => cell.map((node) => node.raw).join("")).join("\t") + .trim() + ), + ); + continue; + } + } + } + + return [ + links, + projectLinks, + icons, + image, + [...files], + [...helpfeels], + infoboxDefinition, + ]; +}; + +const cutId = (link: string): string => link.replace(/#[a-f\d]{24,32}$/, ""); + +/** テキストからHelpfeel記法のentryだけ取り出す */ +export const getHelpfeels = (lines: Pick[]): string[] => + lines.flatMap(({ text }) => + /^\s*\? .*$/.test(text) ? [text.trimStart().slice(2)] : [] + ); diff --git a/browser/websocket/isSameArray.test.ts b/browser/websocket/isSameArray.test.ts new file mode 100644 index 0000000..93ab60a --- /dev/null +++ b/browser/websocket/isSameArray.test.ts @@ -0,0 +1,10 @@ +import { isSameArray } from "./isSameArray.ts"; +import { assert } from "../../deps/testing.ts"; + +Deno.test("isSameArray()", () => { + assert(isSameArray([1, 2, 3], [1, 2, 3])); + assert(isSameArray([1, 2, 3], [3, 2, 1])); + assert(!isSameArray([1, 2, 3], [3, 2, 3])); + assert(!isSameArray([1, 2, 3], [1, 2])); + assert(isSameArray([], [])); +}); diff --git a/browser/websocket/isSameArray.ts b/browser/websocket/isSameArray.ts new file mode 100644 index 0000000..deaa2be --- /dev/null +++ b/browser/websocket/isSameArray.ts @@ -0,0 +1,2 @@ +export const isSameArray = (a: T[], b: T[]): boolean => + a.length === b.length && a.every((x) => b.includes(x)); diff --git a/browser/websocket/makeChanges.ts b/browser/websocket/makeChanges.ts index 3e6c7cc..0cda36b 100644 --- a/browser/websocket/makeChanges.ts +++ b/browser/websocket/makeChanges.ts @@ -1,168 +1,51 @@ import { diffToChanges } from "./diffToChanges.ts"; -import { Block, Line, Node, parse } from "../../deps/scrapbox.ts"; import { Page } from "../../deps/scrapbox-rest.ts"; import type { Change } from "../../deps/socket.ts"; -import { toTitleLc } from "../../title.ts"; -import { parseYoutube } from "../../parser/youtube.ts"; +import { findMetadata, getHelpfeels } from "./findMetadata.ts"; +import { isSameArray } from "./isSameArray.ts"; -export interface Init extends Page { - userId: string; -} export function* makeChanges( - left: Pick[], - right: string[], - { userId, ...page }: Init, + before: Page, + after: string[], + userId: string, ): Generator { // 改行文字が入るのを防ぐ - const right_ = right.flatMap((text) => text.split("\n")); + const after_ = after.flatMap((text) => text.split("\n")); // 本文の差分を先に返す - for (const change of diffToChanges(left, right_, { userId })) { + for (const change of diffToChanges(before.lines, after_, { userId })) { yield change; } // titleの差分を入れる // 空ページの場合もタイトル変更commitを入れる - if (left[0].text !== right_[0] || !page.persistent) { - yield { title: right_[0] }; + if (before.lines[0].text !== after_[0] || !before.persistent) { + yield { title: after_[0] }; } // descriptionsの差分を入れる - const leftDescriptions = left.slice(1, 6).map((line) => line.text); - const rightDescriptions = right_.slice(1, 6); + const leftDescriptions = before.lines.slice(1, 6).map((line) => line.text); + const rightDescriptions = after_.slice(1, 6); if (leftDescriptions.join("") !== rightDescriptions.join("")) { yield { descriptions: rightDescriptions }; } - // リンクと画像の差分を入れる - const [links, projectLinks, image] = findLinksAndImage(right_.join("\n")); - if ( - page.links.length !== links.length || - !page.links.every((link) => links.includes(link)) - ) { - yield { links }; - } - if ( - page.projectLinks.length !== projectLinks.length || - !page.projectLinks.every((link) => projectLinks.includes(link)) - ) { - yield { projectLinks }; - } - if (page.image !== image) { - yield { image }; - } -} - -/** テキストに含まれる全てのリンクと最初の画像を探す */ -const findLinksAndImage = ( - text: string, -): [string[], string[], string | null] => { - const blocks = parse(text, { hasTitle: true }).flatMap((block) => { - switch (block.type) { - case "codeBlock": - case "title": - return []; - case "line": - case "table": - return block; - } - }); - - /** 重複判定用map - * - * bracket link とhashtagを区別できるようにしている - * - bracket linkならtrue - * - * linkの形状はbracket linkを優先している - */ - const linksLc = new Map(); - const links = [] as string[]; - const projectLinksLc = new Set(); - const projectLinks = [] as string[]; - let image: string | null = null; - - const lookup = (node: Node) => { - switch (node.type) { - case "hashTag": - if (linksLc.has(toTitleLc(node.href))) return; - linksLc.set(toTitleLc(node.href), false); - links.push(node.href); - return; - case "link": - switch (node.pathType) { - case "relative": { - const link = cutId(node.href); - if (linksLc.get(toTitleLc(link))) return; - linksLc.set(toTitleLc(link), true); - links.push(link); - return; - } - case "root": { - const link = cutId(node.href); - // ignore `/project` or `/project/` - if (/^\/[\w\d-]+\/?$/.test(link)) return; - if (projectLinksLc.has(toTitleLc(link))) return; - projectLinksLc.add(toTitleLc(link)); - projectLinks.push(link); - return; - } - case "absolute": { - const props = parseYoutube(node.href); - if (!props || props.pathType === "list") return; - image ??= `https://i.ytimg.com/vi/${props.videoId}/mqdefault.jpg`; - return; - } - default: - 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 [links, projectLinks, 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; - } - } + // 各種メタデータの差分を入れる + const [ + links, + projectLinks, + icons, + image, + files, + helpfeels, + infoboxDefinition, + ] = findMetadata(after_.join("\n")); + if (!isSameArray(before.links, links)) yield { links }; + if (!isSameArray(before.projectLinks, projectLinks)) yield { projectLinks }; + if (!isSameArray(before.icons, icons)) yield { icons }; + if (before.image !== image) yield { image }; + if (!isSameArray(before.files, files)) yield { files }; + if (!isSameArray(getHelpfeels(before.lines), helpfeels)) yield { helpfeels }; + if (!isSameArray(before.infoboxDefinition, infoboxDefinition)) { + yield { infoboxDefinition }; } } - -const cutId = (link: string): string => link.replace(/#[a-f\d]{24,32}$/, ""); diff --git a/browser/websocket/patch.ts b/browser/websocket/patch.ts index 96ddcf8..0c6260f 100644 --- a/browser/websocket/patch.ts +++ b/browser/websocket/patch.ts @@ -48,7 +48,7 @@ export const patch = ( const newLines = pending instanceof Promise ? await pending : pending; if (newLines === undefined) return []; if (newLines.length === 0) return [{ deleted: true }]; - return [...makeChanges(page.lines, newLines, page)]; + return [...makeChanges(page, newLines, page.userId)]; }, options, ); diff --git a/deps/scrapbox-rest.ts b/deps/scrapbox-rest.ts index 2023a4f..1ac95f7 100644 --- a/deps/scrapbox-rest.ts +++ b/deps/scrapbox-rest.ts @@ -25,4 +25,4 @@ export type { SessionError, Snapshot, TweetInfo, -} from "https://raw.githubusercontent.com/scrapbox-jp/types/0.6.1/rest.ts"; +} from "https://raw.githubusercontent.com/scrapbox-jp/types/0.7.0/rest.ts"; diff --git a/deps/scrapbox.ts b/deps/scrapbox.ts index 9021a58..5499b59 100644 --- a/deps/scrapbox.ts +++ b/deps/scrapbox.ts @@ -2,8 +2,8 @@ export type { BaseLine, Line, Scrapbox, -} from "https://raw.githubusercontent.com/scrapbox-jp/types/0.6.1/userscript.ts"; +} from "https://raw.githubusercontent.com/scrapbox-jp/types/0.7.0/userscript.ts"; export type { BaseStore, -} from "https://raw.githubusercontent.com/scrapbox-jp/types/0.6.1/baseStore.ts"; +} from "https://raw.githubusercontent.com/scrapbox-jp/types/0.7.0/baseStore.ts"; export * from "https://esm.sh/@progfay/scrapbox-parser@9.0.0"; diff --git a/deps/socket.ts b/deps/socket.ts index 0183226..c55cbdf 100644 --- a/deps/socket.ts +++ b/deps/socket.ts @@ -1 +1 @@ -export * from "https://raw.githubusercontent.com/takker99/scrapbox-userscript-websocket/0.2.2/mod.ts"; +export * from "https://raw.githubusercontent.com/takker99/scrapbox-userscript-websocket/0.2.4/mod.ts";