@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.
- package/README.md +30 -0
- package/bin/cosense +3 -0
- package/package.json +43 -0
- package/src/cli.ts +214 -0
- package/src/commands/browsePage.ts +324 -0
- package/src/commands/browseRelatedPages.ts +79 -0
- package/src/commands/list1hopLinks.ts +66 -0
- package/src/commands/list2hopLinks.ts +53 -0
- package/src/commands/listPages.ts +164 -0
- package/src/commands/listProjects.ts +67 -0
- package/src/commands/login.ts +102 -0
- package/src/commands/previewEdit.ts +349 -0
- package/src/commands/readPage.ts +148 -0
- package/src/commands/readProjectMembers.ts +89 -0
- package/src/commands/search1hopLinks.ts +43 -0
- package/src/commands/search2hopLinks.ts +43 -0
- package/src/commands/searchFullText.ts +85 -0
- package/src/commands/searchVector.ts +86 -0
- package/src/commands/submitEdit.ts +73 -0
- package/src/commands/whoami.ts +48 -0
- package/src/lib/annotateRelations.ts +93 -0
- package/src/lib/encodeTitle.ts +13 -0
- package/src/lib/enrichTimestamps.ts +21 -0
- package/src/lib/formatTimestamp.ts +55 -0
- package/src/lib/parseUrl.ts +60 -0
- package/src/lib/relatedPages.ts +15 -0
- package/src/lib/relatedPagesFormat.ts +77 -0
- package/src/lib/request.ts +72 -0
- package/src/lib/resolveUsers.ts +107 -0
- package/src/lib/settings.ts +258 -0
|
@@ -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
|
+
};
|