@helpfeel/cosense-cli 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,85 @@
1
+ import { enrichTimestampsOf } from '../lib/enrichTimestamps.ts';
2
+ import { parseProjectUrl } from '../lib/parseUrl.ts';
3
+ import { requestJson } from '../lib/request.ts';
4
+ import { enrichPageUsers, fetchUserMap } from '../lib/resolveUsers.ts';
5
+ import { resolveCredential } from '../lib/settings.ts';
6
+
7
+ export const searchFullTextSummary = '本文全文を対象に検索する';
8
+
9
+ export const searchFullTextHelp = `searchFullText - 本文全文を対象に検索する
10
+
11
+ Usage:
12
+ cosense searchFullText <projectUrl> <query>
13
+
14
+ 引数:
15
+ <projectUrl> プロジェクトのURL(例: https://scrapbox.io/shokai/)
16
+ <query> 検索クエリ
17
+
18
+ 戻り値(top-levelの主なkey):
19
+ projectName string プロジェクト名
20
+ searchQuery string 実行されたクエリ
21
+ count number ヒット総数
22
+ limit number 1リクエストで返る上限
23
+ pages Array<Page> マッチしたページの配列
24
+ existsExactTitleMatch boolean タイトル完全一致がある場合 true
25
+
26
+ 各 Page の field:
27
+ id string ページID
28
+ title string ページタイトル
29
+ user User 作成者
30
+ lastUpdateUser User | null 最終更新者
31
+ users Array<User> 更新者リスト
32
+ views number 閲覧数
33
+ linked number 被リンク数
34
+ created string 作成日時
35
+ updated string 更新日時
36
+ pageRank number PageRank
37
+ linesCount number 行数
38
+ charsCount number 文字数
39
+ words string[] マッチした語の一覧
40
+ lines string[] マッチした本文の行
41
+
42
+ User の field(user / lastUpdateUser / users[] で共通):
43
+ id string Cosense内部のID
44
+ name string? ログイン名(自己紹介ページのタイトルや、本文中のアイコン記法で使われる)
45
+ displayName string? 表示名
46
+ email string? メールアドレス
47
+
48
+ 戻り値のJSON抜粋例:
49
+ {
50
+ "projectName": "shokai",
51
+ "count": 12,
52
+ "pages": [
53
+ { "id": "...", "title": "...", "words": ["codex"], "lines": ["...codexと相談する..."] }
54
+ ]
55
+ }
56
+ `;
57
+
58
+ interface SearchFullTextData {
59
+ pages?: {
60
+ user?: { id: string } | null;
61
+ lastUpdateUser?: { id: string } | null;
62
+ users?: { id: string }[];
63
+ }[];
64
+ }
65
+
66
+ export const searchFullText = async (args: string[]): Promise<void> => {
67
+ const [url, query] = args;
68
+ if (!url || !query) {
69
+ throw new Error('Usage: cosense searchFullText <projectUrl> <query>');
70
+ }
71
+ const { origin, projectName } = parseProjectUrl(url);
72
+ const apiUrl = `${origin}/api/pages/${projectName}/search/query?q=${encodeURIComponent(query)}`;
73
+ const credential = resolveCredential(origin, projectName);
74
+ const data = (await requestJson(apiUrl, {
75
+ credential
76
+ })) as SearchFullTextData;
77
+
78
+ const userMap = await fetchUserMap(origin, projectName);
79
+ for (const page of data.pages ?? []) {
80
+ enrichPageUsers(page, userMap);
81
+ enrichTimestampsOf(page as Record<string, unknown>);
82
+ }
83
+
84
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
85
+ };
@@ -0,0 +1,86 @@
1
+ import { enrichTimestampsOf } from '../lib/enrichTimestamps.ts';
2
+ import { parseProjectUrl } from '../lib/parseUrl.ts';
3
+ import { requestJson } from '../lib/request.ts';
4
+ import { enrichPageUsers, fetchUserMap } from '../lib/resolveUsers.ts';
5
+ import { resolveCredential } from '../lib/settings.ts';
6
+
7
+ export const searchVectorSummary =
8
+ 'ベクトル検索でページを探す(タイトル+本文中リンク記法のみ対象)';
9
+
10
+ export const searchVectorHelp = `searchVector - ベクトル検索でページを探す
11
+
12
+ Usage:
13
+ cosense searchVector <projectUrl> <query>
14
+
15
+ 引数:
16
+ <projectUrl> プロジェクトのURL(例: https://scrapbox.io/shokai/)
17
+ <query> 検索クエリ
18
+
19
+ 戻り値(top-levelの主なkey):
20
+ pages Array<Page> 類似度順のヒット結果
21
+
22
+ 各 page の field:
23
+ title string ページタイトル
24
+ image string サムネイル画像URL
25
+ score number 類似度スコア(高いほど近い)
26
+ exists boolean 実体のあるページなら true。false の場合は空ページ(リンク記法だけ存在)
27
+
28
+ exists=true のページのみ追加で付くfield:
29
+ id string ページID
30
+ user User 作成者
31
+ lastUpdateUser User | null 最終更新者
32
+ users Array<User> 更新者リスト
33
+ views number 閲覧数
34
+ linked number 被リンク数
35
+ created string 作成日時
36
+ updated string 更新日時
37
+ pageRank number PageRank
38
+ linesCount number 行数
39
+ charsCount number 文字数
40
+
41
+ User の field(user / lastUpdateUser / users[] で共通):
42
+ id string Cosense内部のID
43
+ name string? ログイン名(自己紹介ページのタイトルや、本文中のアイコン記法で使われる)
44
+ displayName string? 表示名
45
+ email string? メールアドレス
46
+
47
+ 検索対象:
48
+ - ページタイトル + 本文中のリンク記法([title])のみ
49
+ - 本文の通常テキストは検索対象外
50
+ - 本文の語で検索したい時は searchFullText を使う
51
+
52
+ 戻り値のJSON抜粋例:
53
+ {
54
+ "pages": [
55
+ { "id": "...", "title": "vibe coding", "score": 0.833, "exists": true },
56
+ { "title": "bug修正", "score": 0.811, "exists": false }
57
+ ]
58
+ }
59
+ `;
60
+
61
+ interface SearchVectorData {
62
+ pages?: {
63
+ user?: { id: string } | null;
64
+ lastUpdateUser?: { id: string } | null;
65
+ users?: { id: string }[];
66
+ }[];
67
+ }
68
+
69
+ export const searchVector = async (args: string[]): Promise<void> => {
70
+ const [url, query] = args;
71
+ if (!url || !query) {
72
+ throw new Error('Usage: cosense searchVector <projectUrl> <query>');
73
+ }
74
+ const { origin, projectName } = parseProjectUrl(url);
75
+ const apiUrl = `${origin}/api/pages/${projectName}/search/vector/titles?q=${encodeURIComponent(query)}`;
76
+ const credential = resolveCredential(origin, projectName);
77
+ const data = (await requestJson(apiUrl, { credential })) as SearchVectorData;
78
+
79
+ const userMap = await fetchUserMap(origin, projectName);
80
+ for (const page of data.pages ?? []) {
81
+ enrichPageUsers(page, userMap);
82
+ enrichTimestampsOf(page as Record<string, unknown>);
83
+ }
84
+
85
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
86
+ };
@@ -0,0 +1,73 @@
1
+ import { encodeTitleForUrl } from '../lib/encodeTitle.ts';
2
+ import { parseProjectUrlStrict } from '../lib/parseUrl.ts';
3
+ import { requestJson } from '../lib/request.ts';
4
+ import { resolveUserCredential } from '../lib/settings.ts';
5
+
6
+ export const submitEditSummary =
7
+ 'previewEditで取得したpreviewIdを使ってページ編集を確定する';
8
+
9
+ export const submitEditHelp = `submitEdit - previewEditで取得したpreviewIdを使ってページ編集を確定する
10
+
11
+ Usage:
12
+ cosense submitEdit <projectUrl> <previewId>
13
+
14
+ 引数:
15
+ <projectUrl> プロジェクトのURL (例: https://scrapbox.io/shokai)。 末尾に余分なpathがあるとerror
16
+ <previewId> previewEdit の戻り値の previewId
17
+
18
+ 戻り値(plain text):
19
+ commitId: <生成されたcommitのID>
20
+ title: <実際に書き込まれたpage title>
21
+ url: <作成または更新された page の URL>
22
+
23
+ url はサーバーが返す title から再構築される。 新規作成時にサーバーが auto-suffix した場合
24
+ (同名ページが既に存在する場合) は、 title/url にその suffix が反映される。
25
+
26
+ HTTPエラー:
27
+ HTTP 400 preview を生成した時と違う project の URL を渡している
28
+ HTTP 401 認証なし
29
+ HTTP 403 非memberまたはService Account(書き込みはPAT限定)
30
+ HTTP 404 preview が見つからない / 期限切れ (5分) / 既にconsume済み / 他userのpreview
31
+ HTTP 409 {"error":"NotFastForward","latest":...}
32
+ preview生成後にページが更新された。最新stateを再取得して ops を作り直し、
33
+ previewEdit からやり直す必要がある
34
+ HTTP 409 {"error":"DuplicateTitle"}
35
+ preview→submit の間に他人が同名ページを作った (race condition)
36
+
37
+ ワークフロー:
38
+ previewIdは1回しか使えない (consume-on-submit)。submitに失敗したら previewEdit から
39
+ やり直す。previewIdは5分でexpireするので submit は迅速に。
40
+ `;
41
+
42
+ interface SubmitResponse {
43
+ commitId: string;
44
+ page: { title?: string } | null;
45
+ }
46
+
47
+ export const submitEdit = async (args: string[]): Promise<void> => {
48
+ if (args.length !== 2) {
49
+ throw new Error('Usage: cosense submitEdit <projectUrl> <previewId>');
50
+ }
51
+ const [projectUrl, previewId] = args as [string, string];
52
+
53
+ const { origin, projectName } = parseProjectUrlStrict(projectUrl);
54
+ const apiUrl = `${origin}/api/pages/v2/${projectName}/page-edit-for-ai/submit`;
55
+ // 書き込み系 API は PAT 限定 (Service Account 拒否) なので、PAT を直接解決する
56
+ const credential = resolveUserCredential(origin);
57
+ const response = (await requestJson(apiUrl, {
58
+ credential,
59
+ method: 'POST',
60
+ body: { previewId }
61
+ })) as SubmitResponse;
62
+
63
+ const title = response.page?.title;
64
+ if (typeof title !== 'string') {
65
+ throw new Error(
66
+ `submit response missing page field (commitId: ${response.commitId})`
67
+ );
68
+ }
69
+ const url = `${origin}/${projectName}/${encodeTitleForUrl(title)}`;
70
+ process.stdout.write(
71
+ `commitId: ${response.commitId}\ntitle: ${title}\nurl: ${url}\n`
72
+ );
73
+ };
@@ -0,0 +1,48 @@
1
+ import { enrichTimestampsOf } from '../lib/enrichTimestamps.ts';
2
+ import { parseOrigin } from '../lib/parseUrl.ts';
3
+ import { requestJson } from '../lib/request.ts';
4
+ import { resolveUserCredential } from '../lib/settings.ts';
5
+
6
+ export const whoamiSummary = '現在の認証ユーザーの情報を取得する';
7
+
8
+ export const whoamiHelp = `whoami - 現在の認証ユーザーの情報を取得する
9
+
10
+ Usage:
11
+ cosense whoami <origin>
12
+
13
+ 引数:
14
+ <origin> Cosenseサーバーのorigin(例: https://scrapbox.io)
15
+
16
+ 戻り値(top-levelの主なkey):
17
+ id string Cosense内部のID
18
+ name string ログイン名
19
+ displayName string 表示名
20
+ email string メールアドレス
21
+ photo string アイコン画像URL
22
+ provider string 認証プロバイダ(google / microsoft / saml 等)
23
+ pageFilters Array<{type, value}> page list filter設定
24
+ created string 作成時刻
25
+ updated string 更新時刻
26
+ `;
27
+
28
+ export const whoami = async (args: string[]): Promise<void> => {
29
+ const [originArg] = args;
30
+ if (!originArg) throw new Error('Usage: cosense whoami <origin>');
31
+ if (args.length > 1) {
32
+ throw new Error(
33
+ `Unexpected positional argument: ${args[1]}\nUsage: cosense whoami <origin>`
34
+ );
35
+ }
36
+ const origin = parseOrigin(originArg);
37
+ const credential = resolveUserCredential(origin);
38
+ if (!credential) {
39
+ throw new Error(
40
+ `No Personal Access Token found for ${origin}. Run \`cosense login ${origin}\` to authenticate.`
41
+ );
42
+ }
43
+ const data = (await requestJson(`${origin}/api/users/me`, {
44
+ credential
45
+ })) as Record<string, unknown>;
46
+ enrichTimestampsOf(data);
47
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
48
+ };
@@ -0,0 +1,93 @@
1
+ import { parsePageUrl } from './parseUrl.ts';
2
+ import { HttpError, requestJson } from './request.ts';
3
+ import { resolveCredential } from './settings.ts';
4
+
5
+ export type Relation = 'outgoing' | 'incoming' | 'bidirectional';
6
+
7
+ interface Link1Hop {
8
+ titleLc?: string;
9
+ linksLc?: string[];
10
+ relation?: Relation;
11
+ [key: string]: unknown;
12
+ }
13
+
14
+ interface ReadPageData {
15
+ titleLc?: string;
16
+ title?: string;
17
+ linksLc?: string[];
18
+ links?: string[];
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ interface RelatedPagesData {
23
+ links1hop?: Link1Hop[];
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ const toTitleLc = (title: string): string =>
28
+ title.replace(/ /g, '_').toLowerCase();
29
+
30
+ const computeRelation = (
31
+ startLinksLcSet: Set<string>,
32
+ startTitleLc: string,
33
+ page: Link1Hop
34
+ ): Relation => {
35
+ const linksTo = page.titleLc ? startLinksLcSet.has(page.titleLc) : false;
36
+ const linksFrom = page.linksLc?.includes(startTitleLc) ?? false;
37
+ if (linksTo && linksFrom) return 'bidirectional';
38
+ if (linksTo) return 'outgoing';
39
+ return 'incoming';
40
+ };
41
+
42
+ export const fetchRelatedPagesWithRelations = async (
43
+ url: string,
44
+ query?: string
45
+ ): Promise<RelatedPagesData> => {
46
+ const { origin, projectName, encodedTitle } = parsePageUrl(url);
47
+ const queryParam = query ? `?search=${encodeURIComponent(query)}` : '';
48
+ const startPageUrl = `${origin}/api/pages/v2/${projectName}/${encodedTitle}`;
49
+ const relatedUrl = `${startPageUrl}/links1hop${queryParam}`;
50
+ const credential = resolveCredential(origin, projectName);
51
+
52
+ const [startPageResult, relatedResult] = await Promise.allSettled([
53
+ requestJson(startPageUrl, { credential }),
54
+ requestJson(relatedUrl, { credential })
55
+ ]);
56
+
57
+ // links1hop endpoint must succeed (it has its own backlinks-only fallback for non-existent pages)
58
+ if (relatedResult.status === 'rejected') {
59
+ throw relatedResult.reason;
60
+ }
61
+ const related = relatedResult.value as RelatedPagesData;
62
+
63
+ // readPage returns 404 only when the page is missing and has no backlinks (a page with backlinks
64
+ // is auto-generated as an empty page and returned with 200). Treat that 404 as empty linksLc so
65
+ // all entries are classified as 'incoming'. Other failures (5xx, network, auth) must propagate
66
+ // so we never silently mislabel relations.
67
+ let startTitleLc: string;
68
+ let startLinksLcSet: Set<string>;
69
+ if (startPageResult.status === 'fulfilled') {
70
+ const startPage = startPageResult.value as ReadPageData;
71
+ startTitleLc =
72
+ startPage.titleLc ?? (startPage.title ? toTitleLc(startPage.title) : '');
73
+ startLinksLcSet = new Set<string>(
74
+ startPage.linksLc ?? (startPage.links ?? []).map(toTitleLc)
75
+ );
76
+ } else if (
77
+ startPageResult.reason instanceof HttpError &&
78
+ startPageResult.reason.status === 404
79
+ ) {
80
+ startTitleLc = toTitleLc(decodeURIComponent(encodedTitle));
81
+ startLinksLcSet = new Set<string>();
82
+ } else {
83
+ throw startPageResult.reason;
84
+ }
85
+
86
+ if (Array.isArray(related.links1hop)) {
87
+ for (const page of related.links1hop) {
88
+ page.relation = computeRelation(startLinksLcSet, startTitleLc, page);
89
+ }
90
+ }
91
+
92
+ return related;
93
+ };
@@ -0,0 +1,13 @@
1
+ // 人が読める URL を作るための encoder (cosense 本家の「Copy readable link」 相当)。
2
+ // Unicode 文字 (Japanese 等) は raw のまま (browser は IRI として透過処理する)。
3
+ // cosense server の route match (`/:projectName/:title`, `:title=[^/]+`) を満たすため
4
+ // title 内の `/` は `%2F` 必須。 URL syntax を破壊する `%` `?` `#` も percent-encode する。
5
+ // space は cosense convention で `_` に置換。
6
+ export const encodeTitleForUrl = (title: string): string => {
7
+ return title
8
+ .replace(/%/g, '%25')
9
+ .replace(/\//g, '%2F')
10
+ .replace(/\?/g, '%3F')
11
+ .replace(/#/g, '%23')
12
+ .replace(/ /g, '_');
13
+ };
@@ -0,0 +1,21 @@
1
+ import { formatTimestamp } from './formatTimestamp.ts';
2
+
3
+ const DEFAULT_FIELDS = [
4
+ 'created',
5
+ 'updated',
6
+ 'accessed',
7
+ 'lastAccessed'
8
+ ] as const;
9
+
10
+ export const enrichTimestampsOf = (
11
+ obj: Record<string, unknown> | null | undefined,
12
+ fields: readonly string[] = DEFAULT_FIELDS
13
+ ): void => {
14
+ if (!obj) return;
15
+ for (const field of fields) {
16
+ const formatted = formatTimestamp(obj[field]);
17
+ if (formatted !== undefined) {
18
+ obj[field] = formatted;
19
+ }
20
+ }
21
+ };
@@ -0,0 +1,55 @@
1
+ const NOW_MS = Date.now();
2
+ const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
3
+ const cache = new Map<number, string>();
4
+
5
+ const pad = (n: number): string => String(n).padStart(2, '0');
6
+
7
+ const formatAbsolute = (date: Date): string => {
8
+ const year = date.getFullYear();
9
+ const month = pad(date.getMonth() + 1);
10
+ const day = pad(date.getDate());
11
+ const hour = pad(date.getHours());
12
+ const minute = pad(date.getMinutes());
13
+ const offsetMin = -date.getTimezoneOffset();
14
+ const sign = offsetMin >= 0 ? '+' : '-';
15
+ const abs = Math.abs(offsetMin);
16
+ const oh = pad(Math.floor(abs / 60));
17
+ const om = pad(abs % 60);
18
+ return `${year}-${month}-${day}T${hour}:${minute}${sign}${oh}:${om}`;
19
+ };
20
+
21
+ const formatRelative = (deltaSec: number): string => {
22
+ const abs = Math.abs(deltaSec);
23
+ if (abs < 60) return rtf.format(Math.round(deltaSec), 'second');
24
+ if (abs < 3600) return rtf.format(Math.round(deltaSec / 60), 'minute');
25
+ if (abs < 86400) return rtf.format(Math.round(deltaSec / 3600), 'hour');
26
+ if (abs < 86400 * 7) return rtf.format(Math.round(deltaSec / 86400), 'day');
27
+ if (abs < 86400 * 30)
28
+ return rtf.format(Math.round(deltaSec / (86400 * 7)), 'week');
29
+ if (abs < 86400 * 365)
30
+ return rtf.format(Math.round(deltaSec / (86400 * 30)), 'month');
31
+ return rtf.format(Math.round(deltaSec / (86400 * 365)), 'year');
32
+ };
33
+
34
+ export const formatTimestamp = (unixtimeSec: unknown): string | undefined => {
35
+ if (
36
+ typeof unixtimeSec !== 'number' ||
37
+ !Number.isFinite(unixtimeSec) ||
38
+ unixtimeSec === 0
39
+ ) {
40
+ return undefined;
41
+ }
42
+ const cached = cache.get(unixtimeSec);
43
+ if (cached) return cached;
44
+
45
+ const ms = unixtimeSec * 1000;
46
+ const date = new Date(ms);
47
+ if (Number.isNaN(date.getTime())) return undefined;
48
+
49
+ const absolute = formatAbsolute(date);
50
+ const deltaSec = Math.floor((ms - NOW_MS) / 1000);
51
+ const relative = formatRelative(deltaSec);
52
+ const result = `${absolute} (${relative})`;
53
+ cache.set(unixtimeSec, result);
54
+ return result;
55
+ };
@@ -0,0 +1,60 @@
1
+ export interface ProjectUrl {
2
+ origin: string;
3
+ projectName: string;
4
+ }
5
+
6
+ export interface PageUrl extends ProjectUrl {
7
+ encodedTitle: string;
8
+ }
9
+
10
+ export const parseOrigin = (input: string): string => {
11
+ let url: URL;
12
+ try {
13
+ url = new URL(input);
14
+ } catch {
15
+ throw new Error(`<origin> is not a valid URL: ${input}`);
16
+ }
17
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
18
+ throw new Error(`<origin> must use http: or https: scheme: ${input}`);
19
+ }
20
+ return url.origin;
21
+ };
22
+
23
+ export const parseProjectUrl = (input: string): ProjectUrl => {
24
+ const u = new URL(input);
25
+ const parts = u.pathname.split('/').filter(Boolean);
26
+ if (parts.length < 1) {
27
+ throw new Error(
28
+ `Project URL must be https://<host>/<project>, got: ${input}`
29
+ );
30
+ }
31
+ return { origin: u.origin, projectName: parts[0] as string };
32
+ };
33
+
34
+ export const parseProjectUrlStrict = (input: string): ProjectUrl => {
35
+ const u = new URL(input);
36
+ if (u.search || u.hash) {
37
+ throw new Error(`Project URL must not have query/hash, got: ${input}`);
38
+ }
39
+ const m = u.pathname.match(/^\/([^/]+)\/?$/);
40
+ if (!m) {
41
+ throw new Error(
42
+ `Project URL must be https://<host>/<project> (no extra path), got: ${input}`
43
+ );
44
+ }
45
+ return { origin: u.origin, projectName: m[1] as string };
46
+ };
47
+
48
+ export const parsePageUrl = (input: string): PageUrl => {
49
+ const u = new URL(input);
50
+ const m = u.pathname.match(/^\/([^/]+)\/(.+?)\/?$/);
51
+ if (!m) {
52
+ throw new Error(
53
+ `Page URL must be https://<host>/<project>/<title>, got: ${input}`
54
+ );
55
+ }
56
+ const projectName = m[1] as string;
57
+ const rawTail = m[2] as string;
58
+ const encodedTitle = rawTail.replace(/\//g, '%2F');
59
+ return { origin: u.origin, projectName, encodedTitle };
60
+ };
@@ -0,0 +1,15 @@
1
+ import { parsePageUrl } from './parseUrl.ts';
2
+ import { requestJson } from './request.ts';
3
+ import { resolveCredential } from './settings.ts';
4
+
5
+ export const fetchRelatedPages = async (
6
+ url: string,
7
+ hop: 1 | 2,
8
+ query?: string
9
+ ): Promise<unknown> => {
10
+ const { origin, projectName, encodedTitle } = parsePageUrl(url);
11
+ const queryParam = query ? `?search=${encodeURIComponent(query)}` : '';
12
+ const apiUrl = `${origin}/api/pages/v2/${projectName}/${encodedTitle}/links${hop}hop${queryParam}`;
13
+ const credential = resolveCredential(origin, projectName);
14
+ return requestJson(apiUrl, { credential });
15
+ };
@@ -0,0 +1,77 @@
1
+ export interface Page {
2
+ title: string;
3
+ titleLc?: string;
4
+ pageRank?: number;
5
+ }
6
+
7
+ export const STACK_PREVIEW = 1;
8
+
9
+ const stackPattern =
10
+ /^([^\d]{3,})\s*([\d\-./()<>{}()月火水木金土日年春夏秋冬]+|\d+ - [a-zA-Z])$/;
11
+
12
+ export const detectStackName = (title: string): string | null =>
13
+ stackPattern.exec(title)?.[1] ?? null;
14
+
15
+ export type Group =
16
+ | { type: 'page'; title: string }
17
+ | { type: 'stack'; name: string; titles: string[] };
18
+
19
+ export const buildGroups = (pages: Page[]): Group[] => {
20
+ const groups: Group[] = [];
21
+ const stackIndexByName = new Map<string, number>();
22
+
23
+ for (const page of pages) {
24
+ const stackName = detectStackName(page.title);
25
+ if (stackName !== null) {
26
+ const existingIdx = stackIndexByName.get(stackName);
27
+ if (existingIdx !== undefined) {
28
+ (groups[existingIdx] as Extract<Group, { type: 'stack' }>).titles.push(
29
+ page.title
30
+ );
31
+ } else {
32
+ stackIndexByName.set(stackName, groups.length);
33
+ groups.push({ type: 'stack', name: stackName, titles: [page.title] });
34
+ }
35
+ } else {
36
+ groups.push({ type: 'page', title: page.title });
37
+ }
38
+ }
39
+
40
+ return groups;
41
+ };
42
+
43
+ const toTitleLc = (title: string): string =>
44
+ title.replace(/ /g, '_').toLowerCase();
45
+
46
+ export const dedupAndSortByPageRank = (
47
+ pages: Page[] | undefined,
48
+ seen: Set<string> = new Set<string>()
49
+ ): Page[] => {
50
+ const collected: Page[] = [];
51
+ for (const page of pages ?? []) {
52
+ const tlc = page.titleLc ?? toTitleLc(page.title);
53
+ if (seen.has(tlc)) continue;
54
+ seen.add(tlc);
55
+ collected.push(page);
56
+ }
57
+ collected.sort((a, b) => (b.pageRank ?? 0) - (a.pageRank ?? 0));
58
+ return collected;
59
+ };
60
+
61
+ export const renderGroups = (groups: Group[]): string => {
62
+ const lines: string[] = [];
63
+ for (const group of groups) {
64
+ if (group.type === 'page') {
65
+ lines.push(`- ${group.title}`);
66
+ } else {
67
+ for (const title of group.titles.slice(0, STACK_PREVIEW)) {
68
+ lines.push(`- ${title}`);
69
+ }
70
+ const remaining = group.titles.length - STACK_PREVIEW;
71
+ if (remaining > 0) {
72
+ lines.push(` 他、${remaining}件の${group.name.trimEnd()}を省略します`);
73
+ }
74
+ }
75
+ }
76
+ return lines.join('\n');
77
+ };