@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
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# @helpfeel/cosense-cli
|
|
2
|
+
|
|
3
|
+
Cosenseのページを読み・調べ・編集する為のCLIとAgent Skill
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
### CLI
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g https://github.com/helpfeel/cosense-cli
|
|
11
|
+
cosense --help
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
### Claude Code Agent Skill
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
/plugin marketplace add helpfeel/cosense-cli
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
/plugin install cosense-cli@cosense-cli
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Skillの実行にはCLIが必要
|
|
25
|
+
|
|
26
|
+
## 開発
|
|
27
|
+
|
|
28
|
+
- install [direnv](https://direnv.net/)
|
|
29
|
+
- run `npm install`
|
|
30
|
+
- run `claude` or `codex`
|
package/bin/cosense
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@helpfeel/cosense-cli",
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "Cosense (旧Scrapbox) のページを読み・調べ・編集するCLI",
|
|
5
|
+
"homepage": "https://github.com/helpfeel/cosense-cli",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/helpfeel/cosense-cli.git"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"cosense": "./bin/cosense"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"scripts": {
|
|
20
|
+
"lint": "run-s lint:**",
|
|
21
|
+
"lint:oxfmt": "oxfmt --check",
|
|
22
|
+
"lint:tsc": "tsc -p tsconfig.json",
|
|
23
|
+
"lint:oxlint": "oxlint --report-unused-disable-directives-severity error",
|
|
24
|
+
"format": "oxfmt"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"tsx": "4.21.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "24.12.2",
|
|
31
|
+
"npm-run-all": "4.1.5",
|
|
32
|
+
"oxfmt": "0.46.0",
|
|
33
|
+
"oxlint": "1.61.0",
|
|
34
|
+
"typescript": "6.0.2"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=24"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"registry": "https://registry.npmjs.com/"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import {
|
|
6
|
+
browsePage,
|
|
7
|
+
browsePageHelp,
|
|
8
|
+
browsePageSummary
|
|
9
|
+
} from './commands/browsePage.ts';
|
|
10
|
+
import {
|
|
11
|
+
browseRelatedPages,
|
|
12
|
+
browseRelatedPagesHelp,
|
|
13
|
+
browseRelatedPagesSummary
|
|
14
|
+
} from './commands/browseRelatedPages.ts';
|
|
15
|
+
import {
|
|
16
|
+
list1hopLinks,
|
|
17
|
+
list1hopLinksHelp,
|
|
18
|
+
list1hopLinksSummary
|
|
19
|
+
} from './commands/list1hopLinks.ts';
|
|
20
|
+
import {
|
|
21
|
+
list2hopLinks,
|
|
22
|
+
list2hopLinksHelp,
|
|
23
|
+
list2hopLinksSummary
|
|
24
|
+
} from './commands/list2hopLinks.ts';
|
|
25
|
+
import {
|
|
26
|
+
listPages,
|
|
27
|
+
listPagesHelp,
|
|
28
|
+
listPagesSummary
|
|
29
|
+
} from './commands/listPages.ts';
|
|
30
|
+
import {
|
|
31
|
+
listProjects,
|
|
32
|
+
listProjectsHelp,
|
|
33
|
+
listProjectsSummary
|
|
34
|
+
} from './commands/listProjects.ts';
|
|
35
|
+
import { login, loginHelp, loginSummary } from './commands/login.ts';
|
|
36
|
+
import {
|
|
37
|
+
previewEdit,
|
|
38
|
+
previewEditHelp,
|
|
39
|
+
previewEditSummary
|
|
40
|
+
} from './commands/previewEdit.ts';
|
|
41
|
+
import {
|
|
42
|
+
readPage,
|
|
43
|
+
readPageHelp,
|
|
44
|
+
readPageSummary
|
|
45
|
+
} from './commands/readPage.ts';
|
|
46
|
+
import {
|
|
47
|
+
readProjectMembers,
|
|
48
|
+
readProjectMembersHelp,
|
|
49
|
+
readProjectMembersSummary
|
|
50
|
+
} from './commands/readProjectMembers.ts';
|
|
51
|
+
import {
|
|
52
|
+
submitEdit,
|
|
53
|
+
submitEditHelp,
|
|
54
|
+
submitEditSummary
|
|
55
|
+
} from './commands/submitEdit.ts';
|
|
56
|
+
import {
|
|
57
|
+
search1hopLinks,
|
|
58
|
+
search1hopLinksHelp,
|
|
59
|
+
search1hopLinksSummary
|
|
60
|
+
} from './commands/search1hopLinks.ts';
|
|
61
|
+
import {
|
|
62
|
+
search2hopLinks,
|
|
63
|
+
search2hopLinksHelp,
|
|
64
|
+
search2hopLinksSummary
|
|
65
|
+
} from './commands/search2hopLinks.ts';
|
|
66
|
+
import {
|
|
67
|
+
searchFullText,
|
|
68
|
+
searchFullTextHelp,
|
|
69
|
+
searchFullTextSummary
|
|
70
|
+
} from './commands/searchFullText.ts';
|
|
71
|
+
import {
|
|
72
|
+
searchVector,
|
|
73
|
+
searchVectorHelp,
|
|
74
|
+
searchVectorSummary
|
|
75
|
+
} from './commands/searchVector.ts';
|
|
76
|
+
import { whoami, whoamiHelp, whoamiSummary } from './commands/whoami.ts';
|
|
77
|
+
|
|
78
|
+
interface CommandSpec {
|
|
79
|
+
handler: (args: string[]) => Promise<void>;
|
|
80
|
+
summary: string;
|
|
81
|
+
help: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const packageJsonPath = join(
|
|
85
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
86
|
+
'..',
|
|
87
|
+
'package.json'
|
|
88
|
+
);
|
|
89
|
+
const { version } = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
|
90
|
+
version: string;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const commands: Record<string, CommandSpec> = {
|
|
94
|
+
login: { handler: login, summary: loginSummary, help: loginHelp },
|
|
95
|
+
whoami: { handler: whoami, summary: whoamiSummary, help: whoamiHelp },
|
|
96
|
+
listProjects: {
|
|
97
|
+
handler: listProjects,
|
|
98
|
+
summary: listProjectsSummary,
|
|
99
|
+
help: listProjectsHelp
|
|
100
|
+
},
|
|
101
|
+
browsePage: {
|
|
102
|
+
handler: browsePage,
|
|
103
|
+
summary: browsePageSummary,
|
|
104
|
+
help: browsePageHelp
|
|
105
|
+
},
|
|
106
|
+
browseRelatedPages: {
|
|
107
|
+
handler: browseRelatedPages,
|
|
108
|
+
summary: browseRelatedPagesSummary,
|
|
109
|
+
help: browseRelatedPagesHelp
|
|
110
|
+
},
|
|
111
|
+
readPage: { handler: readPage, summary: readPageSummary, help: readPageHelp },
|
|
112
|
+
readProjectMembers: {
|
|
113
|
+
handler: readProjectMembers,
|
|
114
|
+
summary: readProjectMembersSummary,
|
|
115
|
+
help: readProjectMembersHelp
|
|
116
|
+
},
|
|
117
|
+
listPages: {
|
|
118
|
+
handler: listPages,
|
|
119
|
+
summary: listPagesSummary,
|
|
120
|
+
help: listPagesHelp
|
|
121
|
+
},
|
|
122
|
+
list1hopLinks: {
|
|
123
|
+
handler: list1hopLinks,
|
|
124
|
+
summary: list1hopLinksSummary,
|
|
125
|
+
help: list1hopLinksHelp
|
|
126
|
+
},
|
|
127
|
+
list2hopLinks: {
|
|
128
|
+
handler: list2hopLinks,
|
|
129
|
+
summary: list2hopLinksSummary,
|
|
130
|
+
help: list2hopLinksHelp
|
|
131
|
+
},
|
|
132
|
+
searchVector: {
|
|
133
|
+
handler: searchVector,
|
|
134
|
+
summary: searchVectorSummary,
|
|
135
|
+
help: searchVectorHelp
|
|
136
|
+
},
|
|
137
|
+
searchFullText: {
|
|
138
|
+
handler: searchFullText,
|
|
139
|
+
summary: searchFullTextSummary,
|
|
140
|
+
help: searchFullTextHelp
|
|
141
|
+
},
|
|
142
|
+
search1hopLinks: {
|
|
143
|
+
handler: search1hopLinks,
|
|
144
|
+
summary: search1hopLinksSummary,
|
|
145
|
+
help: search1hopLinksHelp
|
|
146
|
+
},
|
|
147
|
+
search2hopLinks: {
|
|
148
|
+
handler: search2hopLinks,
|
|
149
|
+
summary: search2hopLinksSummary,
|
|
150
|
+
help: search2hopLinksHelp
|
|
151
|
+
},
|
|
152
|
+
previewEdit: {
|
|
153
|
+
handler: previewEdit,
|
|
154
|
+
summary: previewEditSummary,
|
|
155
|
+
help: previewEditHelp
|
|
156
|
+
},
|
|
157
|
+
submitEdit: {
|
|
158
|
+
handler: submitEdit,
|
|
159
|
+
summary: submitEditSummary,
|
|
160
|
+
help: submitEditHelp
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const renderTopLevelHelp = (): string => {
|
|
165
|
+
const nameWidth = Math.max(...Object.keys(commands).map(n => n.length));
|
|
166
|
+
const lines = [
|
|
167
|
+
`cosense v${version} - Cosenseのページを読み・調べ・編集するCLI`,
|
|
168
|
+
'',
|
|
169
|
+
'Usage:',
|
|
170
|
+
' cosense <command> [args...]',
|
|
171
|
+
' cosense <command> --help 個別コマンドの詳細を表示',
|
|
172
|
+
' cosense --help このヘルプを表示',
|
|
173
|
+
' cosense --version バージョンを表示',
|
|
174
|
+
'',
|
|
175
|
+
'Commands:'
|
|
176
|
+
];
|
|
177
|
+
for (const [name, { summary }] of Object.entries(commands)) {
|
|
178
|
+
lines.push(` ${name.padEnd(nameWidth)} ${summary}`);
|
|
179
|
+
}
|
|
180
|
+
return lines.join('\n');
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const [, , command, ...rest] = process.argv;
|
|
184
|
+
|
|
185
|
+
if (command === '--help') {
|
|
186
|
+
process.stdout.write(`${renderTopLevelHelp()}\n`);
|
|
187
|
+
process.exit(0);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (command === '--version') {
|
|
191
|
+
process.stdout.write(`cosense v${version}\n`);
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const spec = command ? commands[command] : undefined;
|
|
196
|
+
if (!spec) {
|
|
197
|
+
process.stderr.write(
|
|
198
|
+
`invalid command${command ? `: ${command}` : ''}\n` +
|
|
199
|
+
'See `cosense --help` for usage.\n'
|
|
200
|
+
);
|
|
201
|
+
process.exit(2);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (rest.includes('--help')) {
|
|
205
|
+
process.stdout.write(`${spec.help}\n`);
|
|
206
|
+
process.exit(0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
await spec.handler(rest);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { enrichTimestampsOf } from '../lib/enrichTimestamps.ts';
|
|
2
|
+
import { parsePageUrl } from '../lib/parseUrl.ts';
|
|
3
|
+
import { fetchRelatedPages } from '../lib/relatedPages.ts';
|
|
4
|
+
import {
|
|
5
|
+
buildGroups,
|
|
6
|
+
dedupAndSortByPageRank,
|
|
7
|
+
type Page as RelatedPage,
|
|
8
|
+
renderGroups
|
|
9
|
+
} from '../lib/relatedPagesFormat.ts';
|
|
10
|
+
import { requestJson } from '../lib/request.ts';
|
|
11
|
+
import {
|
|
12
|
+
enrichPageUsers,
|
|
13
|
+
fetchUserMap,
|
|
14
|
+
type UserMap
|
|
15
|
+
} from '../lib/resolveUsers.ts';
|
|
16
|
+
import { resolveCredential } from '../lib/settings.ts';
|
|
17
|
+
|
|
18
|
+
export const browsePageSummary =
|
|
19
|
+
'単一ページを読む。メタデータ+アイコン記法+テロメア+本文をAIが読みやすい形式で出力する';
|
|
20
|
+
|
|
21
|
+
export const browsePageHelp = `browsePage - 単一ページを読む。メタデータ+アイコン記法+テロメア+本文をAIが読みやすい形式で出力する
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
cosense browsePage <pageUrl>
|
|
25
|
+
|
|
26
|
+
引数:
|
|
27
|
+
<pageUrl> 読むページの完全なURL(例: https://scrapbox.io/shokai/foo)
|
|
28
|
+
URLに #<lineId> fragmentが付いていれば、本文のその行末に行permalinkマーカーを付与する
|
|
29
|
+
|
|
30
|
+
出力形式(Markdown plain text。JSONではない):
|
|
31
|
+
# <title>
|
|
32
|
+
|
|
33
|
+
## メタデータ
|
|
34
|
+
タイトル / 作成日時 / 最終更新日時 / 最終アクセス日時 / 被リンク数 / pageRank /
|
|
35
|
+
views / snapshot / 行数 / 文字数 / 作成者 / 最終更新者 / 関わったユーザー
|
|
36
|
+
|
|
37
|
+
## 人間のアイコン記法
|
|
38
|
+
本文中の [name.icon] のうち、現メンバーまたは退去済みメンバー (memberSnapshots) の
|
|
39
|
+
name に合致するものだけを [name.icon] の形で箇条書きする。
|
|
40
|
+
一致が0件ならセクションごと省略
|
|
41
|
+
|
|
42
|
+
## テロメアのサマリー
|
|
43
|
+
lines[] を最終更新者でグルーピングし、 displayName 更新期間 YYYY/M/D 〜 YYYY/M/D N行更新
|
|
44
|
+
の形式で行数降順に全員出力
|
|
45
|
+
|
|
46
|
+
## 本文
|
|
47
|
+
各行の text を改行で結合。fragment 指定行のみ末尾に #<lineId> を付与
|
|
48
|
+
|
|
49
|
+
-------------------- Related Pages --------------------
|
|
50
|
+
本文と関連ページ一覧の境界を示す非Markdown区切り線。Cosenseの#hashtag記法と
|
|
51
|
+
衝突しないようにMarkdown見出しを避ける。
|
|
52
|
+
## 1 hop link
|
|
53
|
+
このページの 1-hop 近傍ページタイトル一覧。 1-hop が 0 件なら区切り線ごと省略
|
|
54
|
+
|
|
55
|
+
非存在ページ (persistent: false):
|
|
56
|
+
メタデータ・アイコン・テロメアは省略。 (このページはまだ作成されていません) と
|
|
57
|
+
本文(テンプレート)と Related Pages を出力する
|
|
58
|
+
|
|
59
|
+
URLに #<lineId> fragmentが指定された時:
|
|
60
|
+
タイトル直後に判定結果を1行出力する。
|
|
61
|
+
- 24文字の小文字16進数フォーマットで本文に該当行があれば、行permalinkマーカーを本文中に付ける
|
|
62
|
+
- フォーマット正、本文に該当行なし
|
|
63
|
+
- フォーマット不正 (24文字の小文字16進数でない)
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
interface PageLine {
|
|
67
|
+
id?: string;
|
|
68
|
+
text?: string;
|
|
69
|
+
userId?: string;
|
|
70
|
+
user?: { id?: string };
|
|
71
|
+
updated?: number | string;
|
|
72
|
+
created?: number | string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface UserRef {
|
|
76
|
+
id?: string;
|
|
77
|
+
name?: string;
|
|
78
|
+
displayName?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface PageData {
|
|
82
|
+
title?: string;
|
|
83
|
+
persistent?: boolean;
|
|
84
|
+
pageRank?: number;
|
|
85
|
+
linked?: number;
|
|
86
|
+
views?: number;
|
|
87
|
+
linesCount?: number;
|
|
88
|
+
charsCount?: number;
|
|
89
|
+
snapshotCount?: number;
|
|
90
|
+
snapshotCreated?: number | string;
|
|
91
|
+
created?: number | string;
|
|
92
|
+
updated?: number | string;
|
|
93
|
+
accessed?: number | string;
|
|
94
|
+
lastAccessed?: number | string;
|
|
95
|
+
user?: UserRef | null;
|
|
96
|
+
lastUpdateUser?: UserRef | null;
|
|
97
|
+
users?: UserRef[];
|
|
98
|
+
lines?: PageLine[];
|
|
99
|
+
icons?: string[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const LINE_ID_PATTERN = /^[0-9a-f]{24}$/;
|
|
103
|
+
|
|
104
|
+
const extractFragment = (input: string): string | null => {
|
|
105
|
+
const u = new URL(input);
|
|
106
|
+
if (!u.hash) return null;
|
|
107
|
+
const value = u.hash.slice(1);
|
|
108
|
+
return value === '' ? null : value;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const formatUserDisplay = (u: UserRef | null | undefined): string | null => {
|
|
112
|
+
if (!u) return null;
|
|
113
|
+
if (u.displayName && u.name) return `${u.displayName} (${u.name})`;
|
|
114
|
+
return u.displayName ?? u.name ?? u.id ?? null;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const formatDateYMD = (unixSec: number): string => {
|
|
118
|
+
const d = new Date(unixSec * 1000);
|
|
119
|
+
return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const normalizeIndent = (text: string): string =>
|
|
123
|
+
text.replace(/^\s+/, m => '\t'.repeat(m.length));
|
|
124
|
+
|
|
125
|
+
interface TelomereEntry {
|
|
126
|
+
userId: string;
|
|
127
|
+
lineCount: number;
|
|
128
|
+
minUpdated: number;
|
|
129
|
+
maxUpdated: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const buildTelomere = (lines: PageLine[]): TelomereEntry[] => {
|
|
133
|
+
const byUser = new Map<string, TelomereEntry>();
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
const uid = line.userId;
|
|
136
|
+
const updated = line.updated;
|
|
137
|
+
if (!uid || typeof updated !== 'number') continue;
|
|
138
|
+
const entry = byUser.get(uid);
|
|
139
|
+
if (entry) {
|
|
140
|
+
entry.lineCount += 1;
|
|
141
|
+
if (updated < entry.minUpdated) entry.minUpdated = updated;
|
|
142
|
+
if (updated > entry.maxUpdated) entry.maxUpdated = updated;
|
|
143
|
+
} else {
|
|
144
|
+
byUser.set(uid, {
|
|
145
|
+
userId: uid,
|
|
146
|
+
lineCount: 1,
|
|
147
|
+
minUpdated: updated,
|
|
148
|
+
maxUpdated: updated
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return [...byUser.values()].sort((a, b) => b.lineCount - a.lineCount);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const renderMetadata = (page: PageData): string => {
|
|
156
|
+
const items: string[] = [];
|
|
157
|
+
if (typeof page.title === 'string') items.push(`- タイトル: ${page.title}`);
|
|
158
|
+
if (page.created) items.push(`- 作成日時: ${page.created}`);
|
|
159
|
+
if (page.updated) items.push(`- 最終更新日時: ${page.updated}`);
|
|
160
|
+
if (page.accessed) items.push(`- 最終アクセス日時: ${page.accessed}`);
|
|
161
|
+
if (typeof page.linked === 'number')
|
|
162
|
+
items.push(`- 被リンク数: ${page.linked}`);
|
|
163
|
+
if (typeof page.pageRank === 'number') {
|
|
164
|
+
items.push(`- pageRank: ${page.pageRank}`);
|
|
165
|
+
}
|
|
166
|
+
if (typeof page.views === 'number') items.push(`- views: ${page.views}`);
|
|
167
|
+
if (typeof page.snapshotCount === 'number' && page.snapshotCount > 0) {
|
|
168
|
+
const latest = page.snapshotCreated ? `、最新 ${page.snapshotCreated}` : '';
|
|
169
|
+
items.push(`- snapshot: ${page.snapshotCount} 件${latest}`);
|
|
170
|
+
}
|
|
171
|
+
if (typeof page.linesCount === 'number') {
|
|
172
|
+
items.push(`- 行数: ${page.linesCount}`);
|
|
173
|
+
}
|
|
174
|
+
if (typeof page.charsCount === 'number') {
|
|
175
|
+
items.push(`- 文字数: ${page.charsCount}`);
|
|
176
|
+
}
|
|
177
|
+
const author = formatUserDisplay(page.user);
|
|
178
|
+
if (author) items.push(`- 作成者: ${author}`);
|
|
179
|
+
const lastUpdater = formatUserDisplay(page.lastUpdateUser);
|
|
180
|
+
if (lastUpdater) items.push(`- 最終更新者: ${lastUpdater}`);
|
|
181
|
+
if (page.users && page.users.length > 0) {
|
|
182
|
+
items.push('- 関わったユーザー:');
|
|
183
|
+
for (const u of page.users) {
|
|
184
|
+
const f = formatUserDisplay(u);
|
|
185
|
+
if (f) items.push(` - ${f}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return `## メタデータ\n\n${items.join('\n')}`;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const renderHumanIcons = (
|
|
192
|
+
icons: string[] | undefined,
|
|
193
|
+
userMap: UserMap
|
|
194
|
+
): string | null => {
|
|
195
|
+
if (!icons || icons.length === 0) return null;
|
|
196
|
+
const memberNames = new Set<string>();
|
|
197
|
+
for (const info of userMap.values()) {
|
|
198
|
+
if (info.name) memberNames.add(info.name);
|
|
199
|
+
}
|
|
200
|
+
const human = icons.filter(name => memberNames.has(name));
|
|
201
|
+
if (human.length === 0) return null;
|
|
202
|
+
return `## 人間のアイコン記法\n\n${human.map(name => `- [${name}.icon]`).join('\n')}`;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const renderTelomere = (
|
|
206
|
+
entries: TelomereEntry[],
|
|
207
|
+
userMap: UserMap
|
|
208
|
+
): string | null => {
|
|
209
|
+
if (entries.length === 0) return null;
|
|
210
|
+
const lines = entries.map(e => {
|
|
211
|
+
const info = userMap.get(e.userId);
|
|
212
|
+
const name = info?.displayName ?? info?.name ?? e.userId;
|
|
213
|
+
return `- ${name}\t更新期間 ${formatDateYMD(e.minUpdated)} 〜 ${formatDateYMD(e.maxUpdated)}\t${e.lineCount}行更新`;
|
|
214
|
+
});
|
|
215
|
+
return `## テロメアのサマリー\n\n${lines.join('\n')}`;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
interface BodyRender {
|
|
219
|
+
body: string;
|
|
220
|
+
matchedFragment: boolean;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const renderBody = (lines: PageLine[], fragment: string | null): BodyRender => {
|
|
224
|
+
let matchedFragment = false;
|
|
225
|
+
const out: string[] = [];
|
|
226
|
+
for (const line of lines) {
|
|
227
|
+
const text = normalizeIndent(line.text ?? '');
|
|
228
|
+
if (fragment && line.id === fragment) {
|
|
229
|
+
matchedFragment = true;
|
|
230
|
+
out.push(`${text}\t#${line.id}`);
|
|
231
|
+
} else {
|
|
232
|
+
out.push(text);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { body: out.join('\n'), matchedFragment };
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const renderRelatedPages = (hopValue: unknown): string | null => {
|
|
239
|
+
const links1hop = (hopValue as { links1hop?: RelatedPage[] }).links1hop;
|
|
240
|
+
const pages = dedupAndSortByPageRank(links1hop);
|
|
241
|
+
if (pages.length === 0) return null;
|
|
242
|
+
return `-------------------- Related Pages --------------------\n\n## 1 hop link\n\n${renderGroups(buildGroups(pages))}`;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export const browsePage = async (args: string[]): Promise<void> => {
|
|
246
|
+
if (args.length !== 1) {
|
|
247
|
+
throw new Error('Usage: cosense browsePage <pageUrl>');
|
|
248
|
+
}
|
|
249
|
+
const [input] = args as [string];
|
|
250
|
+
const fragment = extractFragment(input);
|
|
251
|
+
const { origin, projectName, encodedTitle } = parsePageUrl(input);
|
|
252
|
+
const apiUrl = `${origin}/api/pages/v2/${projectName}/${encodedTitle}`;
|
|
253
|
+
const credential = resolveCredential(origin, projectName);
|
|
254
|
+
|
|
255
|
+
// 必要なAPIのいずれかが失敗したら exit 1 で落とす。 graceful degradation
|
|
256
|
+
// させると「アイコンが0件」「1-hopが0件」と区別がつかなくなり、AIに誤った
|
|
257
|
+
// 文脈を渡してしまう
|
|
258
|
+
const [page, userMap, hopValue] = await Promise.all([
|
|
259
|
+
requestJson(apiUrl, { credential }) as Promise<PageData>,
|
|
260
|
+
fetchUserMap(origin, projectName),
|
|
261
|
+
fetchRelatedPages(input, 1)
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
const title = page.title ?? '';
|
|
265
|
+
const persistent = page.persistent !== false;
|
|
266
|
+
const sections: string[] = [`# ${title}`];
|
|
267
|
+
|
|
268
|
+
if (!persistent) {
|
|
269
|
+
sections.push('(このページはまだ作成されていません)');
|
|
270
|
+
const { body } = renderBody(page.lines ?? [], null);
|
|
271
|
+
sections.push(`## 本文(テンプレート)\n\n${body}`);
|
|
272
|
+
const related = renderRelatedPages(hopValue);
|
|
273
|
+
if (related) sections.push(related);
|
|
274
|
+
process.stdout.write(`${sections.join('\n\n')}\n`);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 本文を先にrenderしてfragment一致状況を取得し、タイトル直後の説明文に反映する
|
|
279
|
+
const validFragment = fragment !== null && LINE_ID_PATTERN.test(fragment);
|
|
280
|
+
const { body, matchedFragment } = renderBody(
|
|
281
|
+
page.lines ?? [],
|
|
282
|
+
validFragment ? fragment : null
|
|
283
|
+
);
|
|
284
|
+
if (fragment !== null) {
|
|
285
|
+
if (!validFragment) {
|
|
286
|
+
sections.push(`#${fragment} は行IDとしてフォーマットが正しくない`);
|
|
287
|
+
} else if (matchedFragment) {
|
|
288
|
+
sections.push(
|
|
289
|
+
`指定された行ID #${fragment} に合致する行が存在する。本文のセクション内で示す`
|
|
290
|
+
);
|
|
291
|
+
} else {
|
|
292
|
+
sections.push(
|
|
293
|
+
`指定された行ID #${fragment} に合致する行は本文に存在しなかった`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// telomere は line.updated が unix秒のうちに集計する
|
|
299
|
+
const telomere = buildTelomere(page.lines ?? []);
|
|
300
|
+
|
|
301
|
+
enrichTimestampsOf(page as Record<string, unknown>, [
|
|
302
|
+
'created',
|
|
303
|
+
'updated',
|
|
304
|
+
'accessed',
|
|
305
|
+
'lastAccessed',
|
|
306
|
+
'snapshotCreated'
|
|
307
|
+
]);
|
|
308
|
+
enrichPageUsers(page, userMap);
|
|
309
|
+
|
|
310
|
+
sections.push(renderMetadata(page));
|
|
311
|
+
|
|
312
|
+
const iconsSection = renderHumanIcons(page.icons, userMap);
|
|
313
|
+
if (iconsSection) sections.push(iconsSection);
|
|
314
|
+
|
|
315
|
+
const telomereSection = renderTelomere(telomere, userMap);
|
|
316
|
+
if (telomereSection) sections.push(telomereSection);
|
|
317
|
+
|
|
318
|
+
sections.push(`## 本文\n\n${body}`);
|
|
319
|
+
|
|
320
|
+
const related = renderRelatedPages(hopValue);
|
|
321
|
+
if (related) sections.push(related);
|
|
322
|
+
|
|
323
|
+
process.stdout.write(`${sections.join('\n\n')}\n`);
|
|
324
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildGroups,
|
|
3
|
+
dedupAndSortByPageRank,
|
|
4
|
+
type Page,
|
|
5
|
+
renderGroups
|
|
6
|
+
} from '../lib/relatedPagesFormat.ts';
|
|
7
|
+
import { fetchRelatedPages } from '../lib/relatedPages.ts';
|
|
8
|
+
|
|
9
|
+
export const browseRelatedPagesSummary =
|
|
10
|
+
'1-hop+2-hopのタイトル一覧を眺める。単独のページだけを見ていては掴みきれない文脈が浮かび上がる';
|
|
11
|
+
|
|
12
|
+
export const browseRelatedPagesHelp = `browseRelatedPages - 1-hop+2-hopのタイトル一覧を眺める。単独のページだけを見ていては掴みきれない文脈が浮かび上がる
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
cosense browseRelatedPages <pageUrl>
|
|
16
|
+
|
|
17
|
+
引数:
|
|
18
|
+
<pageUrl> 対象ページの完全なURL
|
|
19
|
+
|
|
20
|
+
出力:
|
|
21
|
+
1-hop と 2-hop の関連ページそれぞれを pageRank 降順で並べたタイトルの一覧。
|
|
22
|
+
他のコマンドと異なり、JSON ではなく Markdown 形式で出力する。
|
|
23
|
+
|
|
24
|
+
# Related Pages
|
|
25
|
+
|
|
26
|
+
## 1 hop link
|
|
27
|
+
|
|
28
|
+
- タイトル
|
|
29
|
+
- 日記 2025-01-01
|
|
30
|
+
他、48件の日記を省略します
|
|
31
|
+
|
|
32
|
+
## 2 hop link
|
|
33
|
+
|
|
34
|
+
- タイトル
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
export const browseRelatedPages = async (args: string[]): Promise<void> => {
|
|
38
|
+
if (args.length !== 1) {
|
|
39
|
+
throw new Error('Usage: cosense browseRelatedPages <pageUrl>');
|
|
40
|
+
}
|
|
41
|
+
const [url] = args as [string];
|
|
42
|
+
|
|
43
|
+
const [result1hop, result2hop] = await Promise.allSettled([
|
|
44
|
+
fetchRelatedPages(url, 1),
|
|
45
|
+
fetchRelatedPages(url, 2)
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
if (result1hop.status === 'rejected' && result2hop.status === 'rejected') {
|
|
49
|
+
throw result1hop.reason;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// seen は 1-hop と 2-hop で共有して、 1-hop に出たページが 2-hop にも再掲されないようにする
|
|
53
|
+
const seen = new Set<string>();
|
|
54
|
+
const pages1hop =
|
|
55
|
+
result1hop.status === 'fulfilled'
|
|
56
|
+
? dedupAndSortByPageRank(
|
|
57
|
+
(result1hop.value as { links1hop?: Page[] }).links1hop,
|
|
58
|
+
seen
|
|
59
|
+
)
|
|
60
|
+
: [];
|
|
61
|
+
const pages2hop =
|
|
62
|
+
result2hop.status === 'fulfilled'
|
|
63
|
+
? dedupAndSortByPageRank(
|
|
64
|
+
(result2hop.value as { links2hop?: Page[] }).links2hop,
|
|
65
|
+
seen
|
|
66
|
+
)
|
|
67
|
+
: [];
|
|
68
|
+
|
|
69
|
+
const sections: string[] = [];
|
|
70
|
+
if (pages1hop.length > 0) {
|
|
71
|
+
sections.push(`## 1 hop link\n\n${renderGroups(buildGroups(pages1hop))}`);
|
|
72
|
+
}
|
|
73
|
+
if (pages2hop.length > 0) {
|
|
74
|
+
sections.push(`## 2 hop link\n\n${renderGroups(buildGroups(pages2hop))}`);
|
|
75
|
+
}
|
|
76
|
+
if (sections.length > 0) {
|
|
77
|
+
process.stdout.write(`# Related Pages\n\n${sections.join('\n\n')}\n`);
|
|
78
|
+
}
|
|
79
|
+
};
|