Skip to content

Commit b150280

Browse files
authored
Merge pull request #169 from takker99:push-metadata
feat(websocket): Push all page metadata
2 parents 6aeb500 + 2a2590f commit b150280

10 files changed

+293
-151
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
export const snapshot = {};
2+
3+
snapshot[`findMetadata() 1`] = `
4+
[
5+
[
6+
"ふつうの",
7+
"リンク2",
8+
"hashtag",
9+
],
10+
[
11+
"/help-jp/外部リンク",
12+
],
13+
[
14+
"scrapbox",
15+
"takker",
16+
],
17+
"https://scrapbox.io/files/65f29c24974fd8002333b160.svg",
18+
[
19+
"65f29c24974fd8002333b160",
20+
"65e7f82e03949c0024a367d0",
21+
"65e7f4413bc95600258481fb",
22+
],
23+
[
24+
"助けてhelpfeel!!",
25+
],
26+
[
27+
"名前 [scrapbox.icon]",
28+
"住所 [リンク2]を入れること",
29+
"電話番号 #をつけてもリンクにならないよ",
30+
"自分の強み 3個くらい列挙",
31+
],
32+
]
33+
`;
+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { findMetadata, getHelpfeels } from "./findMetadata.ts";
2+
import { assertEquals, assertSnapshot } from "../../deps/testing.ts";
3+
4+
const text = `てすと
5+
[ふつうの]リンク
6+
 しかし\`これは[リンク]\`ではない
7+
8+
code:code
9+
コードブロック中の[リンク]や画像[https://scrapbox.io/files/65f29c0c9045b5002522c8bb.svg]は無視される
10+
11+
12+
? 助けてhelpfeel!!
13+
14+
table:infobox
15+
名前 [scrapbox.icon]
16+
住所 [リンク2]を入れること
17+
電話番号 #をつけてもリンクにならないよ
18+
自分の強み 3個くらい列挙
19+
20+
#hashtag もつけるといいぞ?
21+
[/forum-jp]のようなリンクは対象外
22+
[/help-jp/]もだめ
23+
[/icons/なるほど.icon][takker.icon]
24+
[/help-jp/外部リンク]
25+
26+
サムネを用意
27+
[https://scrapbox.io/files/65f29c24974fd8002333b160.svg]
28+
29+
[https://scrapbox.io/files/65e7f4413bc95600258481fb.svg https://scrapbox.io/files/65e7f82e03949c0024a367d0.svg]`;
30+
31+
Deno.test("findMetadata()", (t) => assertSnapshot(t, findMetadata(text)));
32+
Deno.test("getHelpfeels()", () =>
33+
assertEquals(getHelpfeels(text.split("\n").map((text) => ({ text }))), [
34+
"助けてhelpfeel!!",
35+
]));

browser/websocket/findMetadata.ts

+179
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { BaseLine, Node, parse } from "../../deps/scrapbox.ts";
2+
import { toTitleLc } from "../../title.ts";
3+
import { parseYoutube } from "../../parser/youtube.ts";
4+
5+
/** テキストに含まれているメタデータを取り出す
6+
*
7+
* @param text Scrapboxのテキスト
8+
* @return 順に、links, projectLinks, icons, image, files, helpfeels, infoboxDefinition
9+
*/
10+
export const findMetadata = (
11+
text: string,
12+
): [
13+
string[],
14+
string[],
15+
string[],
16+
string | null,
17+
string[],
18+
string[],
19+
string[],
20+
] => {
21+
const blocks = parse(text, { hasTitle: true }).flatMap((block) => {
22+
switch (block.type) {
23+
case "codeBlock":
24+
case "title":
25+
return [];
26+
case "line":
27+
case "table":
28+
return block;
29+
}
30+
});
31+
32+
/** 重複判定用map
33+
*
34+
* bracket link とhashtagを区別できるようにしている
35+
* - bracket linkならtrue
36+
*
37+
* linkの形状はbracket linkを優先している
38+
*/
39+
const linksLc = new Map<string, boolean>();
40+
const links = [] as string[];
41+
const projectLinksLc = new Set<string>();
42+
const projectLinks = [] as string[];
43+
const iconsLc = new Set<string>();
44+
const icons = [] as string[];
45+
let image: string | null = null;
46+
const files = new Set<string>();
47+
const helpfeels = new Set<string>();
48+
49+
const fileUrlPattern = new RegExp(
50+
`${
51+
location?.origin ?? "https://scrapbox.io"
52+
}/files/([a-z0-9]{24})(?:|\\.[a-zA-Z0-9]+)(?:|\\?[^\\s]*)$`,
53+
);
54+
55+
const lookup = (node: Node) => {
56+
switch (node.type) {
57+
case "hashTag":
58+
if (linksLc.has(toTitleLc(node.href))) return;
59+
linksLc.set(toTitleLc(node.href), false);
60+
links.push(node.href);
61+
return;
62+
case "link":
63+
switch (node.pathType) {
64+
case "relative": {
65+
const link = cutId(node.href);
66+
if (linksLc.get(toTitleLc(link))) return;
67+
linksLc.set(toTitleLc(link), true);
68+
links.push(link);
69+
return;
70+
}
71+
case "root": {
72+
const link = cutId(node.href);
73+
// ignore `/project` or `/project/`
74+
if (/^\/[\w\d-]+\/?$/.test(link)) return;
75+
if (projectLinksLc.has(toTitleLc(link))) return;
76+
projectLinksLc.add(toTitleLc(link));
77+
projectLinks.push(link);
78+
return;
79+
}
80+
case "absolute": {
81+
const props = parseYoutube(node.href);
82+
if (props && props.pathType !== "list") {
83+
image ??= `https://i.ytimg.com/vi/${props.videoId}/mqdefault.jpg`;
84+
return;
85+
}
86+
const fileId = node.href.match(fileUrlPattern)?.[1];
87+
if (fileId) files.add(fileId);
88+
return;
89+
}
90+
default:
91+
return;
92+
}
93+
case "icon":
94+
case "strongIcon": {
95+
if (node.pathType === "root") return;
96+
if (iconsLc.has(toTitleLc(node.path))) return;
97+
iconsLc.add(toTitleLc(node.path));
98+
icons.push(node.path);
99+
return;
100+
}
101+
case "image":
102+
case "strongImage": {
103+
image ??= node.src.endsWith("/thumb/1000")
104+
? node.src.replace(/\/thumb\/1000$/, "/raw")
105+
: node.src;
106+
{
107+
const fileId = node.src.match(fileUrlPattern)?.[1];
108+
if (fileId) files.add(fileId);
109+
}
110+
if (node.type === "image") {
111+
const fileId = node.link.match(fileUrlPattern)?.[1];
112+
if (fileId) files.add(fileId);
113+
}
114+
return;
115+
}
116+
case "helpfeel":
117+
helpfeels.add(node.text);
118+
return;
119+
case "numberList":
120+
case "strong":
121+
case "quote":
122+
case "decoration": {
123+
for (const n of node.nodes) {
124+
lookup(n);
125+
}
126+
return;
127+
}
128+
default:
129+
return;
130+
}
131+
};
132+
133+
const infoboxDefinition = [] as string[];
134+
135+
for (const block of blocks) {
136+
switch (block.type) {
137+
case "line":
138+
for (const node of block.nodes) {
139+
lookup(node);
140+
}
141+
continue;
142+
case "table": {
143+
for (const row of block.cells) {
144+
for (const nodes of row) {
145+
for (const node of nodes) {
146+
lookup(node);
147+
}
148+
}
149+
}
150+
if (!["infobox", "cosense"].includes(block.fileName)) continue;
151+
infoboxDefinition.push(
152+
...block.cells.map((row) =>
153+
row.map((cell) => cell.map((node) => node.raw).join("")).join("\t")
154+
.trim()
155+
),
156+
);
157+
continue;
158+
}
159+
}
160+
}
161+
162+
return [
163+
links,
164+
projectLinks,
165+
icons,
166+
image,
167+
[...files],
168+
[...helpfeels],
169+
infoboxDefinition,
170+
];
171+
};
172+
173+
const cutId = (link: string): string => link.replace(/#[a-f\d]{24,32}$/, "");
174+
175+
/** テキストからHelpfeel記法のentryだけ取り出す */
176+
export const getHelpfeels = (lines: Pick<BaseLine, "text">[]): string[] =>
177+
lines.flatMap(({ text }) =>
178+
/^\s*\? .*$/.test(text) ? [text.trimStart().slice(2)] : []
179+
);

browser/websocket/isSameArray.test.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { isSameArray } from "./isSameArray.ts";
2+
import { assert } from "../../deps/testing.ts";
3+
4+
Deno.test("isSameArray()", () => {
5+
assert(isSameArray([1, 2, 3], [1, 2, 3]));
6+
assert(isSameArray([1, 2, 3], [3, 2, 1]));
7+
assert(!isSameArray([1, 2, 3], [3, 2, 3]));
8+
assert(!isSameArray([1, 2, 3], [1, 2]));
9+
assert(isSameArray([], []));
10+
});

browser/websocket/isSameArray.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const isSameArray = <T>(a: T[], b: T[]): boolean =>
2+
a.length === b.length && a.every((x) => b.includes(x));

0 commit comments

Comments
 (0)