@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,349 @@
1
+ import { randomBytes } from 'node:crypto';
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 previewEditSummary =
7
+ 'ページ編集opsをdry-runしてpreviewIdを取得する';
8
+
9
+ export const previewEditHelp = `previewEdit - ページ編集opsをdry-runしてpreviewIdを取得する
10
+
11
+ Usage:
12
+ cosense previewEdit <projectUrl> <pageId> < ops.json 既存ページの編集 (stdinはops JSON)
13
+ cosense previewEdit --new <projectUrl> < body.txt 新規ページ作成 (stdinはプレーンテキスト本文)
14
+ echo '<opsJSON>' | cosense previewEdit <projectUrl> <pageId>
15
+ echo '<text>' | cosense previewEdit --new <projectUrl>
16
+
17
+ 引数:
18
+ <projectUrl> プロジェクトのURL (例: https://scrapbox.io/shokai)。 末尾に余分なpathがあるとerror
19
+ <pageId> 編集対象ページのID。 readPage 出力の top-level "id" field から取得する
20
+
21
+ オプション:
22
+ --new stdin をプレーンテキスト本文として受け取り、 新規ページを作る。 改行で複数行に分割され、
23
+ 1行目が page title、 2行目以降が本文として扱われる。 ops JSON を組み立てる必要は無い
24
+
25
+ stdinから受け取る入力形式(既存ページ編集モード, JSON):
26
+ {
27
+ "ops": [
28
+ {"insertBefore": "<lineId> | _end", "text": "..."},
29
+ {"replace": "<lineId>", "text": "..."},
30
+ {"delete": "<lineId>"}
31
+ ]
32
+ }
33
+
34
+ insertBefore: <lineId> の直前に新規行を挿入する。textに改行(\\n)を含めると複数行を順に挿入する。
35
+ anchor に "_end" を指定するとページ末尾に挿入する
36
+ replace: <lineId> の本文を置き換える。textは単行のみ。改行を含むtextは拒否される
37
+ delete: <lineId> の行を削除する
38
+
39
+ 「特定行を複数行に分割したい」場合は、insertBefore で対象lineIdの直前に複数行を挿入してから、
40
+ 対象行を delete する。ops は配列順に適用されるので「先に delete してから insertBefore」 とすると
41
+ anchor が消えて 422 になる。
42
+
43
+ ops は順次適用される。同じ lineId に対する insertBefore を [A, B, C] の順で並べると、
44
+ 適用後の行順は元行の直前に [A, B, C] が並ぶ(入力順が保たれる)。
45
+
46
+ 戻り値(plain text):
47
+ previewId / expireAt / status (create or update) / project / title のヘッダー +
48
+ ops summary + 適用後 page 全体。
49
+ 変更行の頭には > (新規) または * (更新) のマーカーと末尾 # <lineId> が付く。
50
+ 既存行(変更なし)はマーカーなし。
51
+ preview は dry-run なのでこの段階ではpage URLは確定しない。 確定URLは submitEdit の出力で確認する。
52
+
53
+ submitEdit でこのpreviewIdを渡してcommitを確定する。previewIdは5分でexpireする。
54
+
55
+ HTTPエラー:
56
+ HTTP 401 認証なし
57
+ HTTP 403 非memberまたはService Account(書き込みはPAT限定)
58
+ HTTP 404 pageId に対応するpageが存在しない / pageId が不正な形式
59
+ HTTP 409 {"error":"NotFastForward","latest":...}
60
+ preview生成後にページが更新された。最新stateを再取得して ops を作り直す必要がある
61
+ HTTP 422 ops が不正/存在しないlineId/replace に多行textを渡した等
62
+
63
+ ワークフロー例(既存ページの編集):
64
+ cosense readPage https://scrapbox.io/shokai/foo > page.json
65
+ # page.json から top-level id (pageId) と編集したい行の lineId を把握する
66
+ cosense previewEdit https://scrapbox.io/shokai <pageId> < ops.json
67
+ # 出力の plain text を読み、適用後の状態を確認してから submit
68
+ cosense submitEdit https://scrapbox.io/shokai <previewId>
69
+
70
+ ワークフロー例(新規ページ作成):
71
+ printf 'ページタイトル\\n本文1行目\\n本文2行目\\n' \\
72
+ | cosense previewEdit --new https://scrapbox.io/shokai
73
+ cosense submitEdit https://scrapbox.io/shokai <previewId>
74
+ `;
75
+
76
+ interface InsertBeforeOp {
77
+ insertBefore: string;
78
+ text?: string;
79
+ }
80
+
81
+ interface ReplaceOp {
82
+ replace: string;
83
+ text?: string;
84
+ }
85
+
86
+ interface DeleteOp {
87
+ delete: string;
88
+ }
89
+
90
+ type Op = InsertBeforeOp | ReplaceOp | DeleteOp;
91
+
92
+ interface RawInsertChange {
93
+ _insert: string;
94
+ lines: { id: string; text: string };
95
+ }
96
+
97
+ interface RawUpdateChange {
98
+ _update: string;
99
+ lines: { text: string };
100
+ }
101
+
102
+ interface RawDeleteChange {
103
+ _delete: string;
104
+ }
105
+
106
+ type RawChange = RawInsertChange | RawUpdateChange | RawDeleteChange;
107
+
108
+ interface PagePreview {
109
+ title?: string;
110
+ persistent?: boolean;
111
+ lines?: { id: string; text: string }[];
112
+ }
113
+
114
+ interface PreviewResponse {
115
+ previewId: string;
116
+ expireAt: string;
117
+ pagePreview: PagePreview | null;
118
+ }
119
+
120
+ const newLineId = (): string => randomBytes(12).toString('hex');
121
+
122
+ const opKind = (op: unknown): 'insertBefore' | 'replace' | 'delete' | null => {
123
+ if (!op || typeof op !== 'object') return null;
124
+ const o = op as Record<string, unknown>;
125
+ const kinds = (['insertBefore', 'replace', 'delete'] as const).filter(
126
+ k => k in o
127
+ );
128
+ if (kinds.length !== 1) return null;
129
+ return kinds[0] ?? null;
130
+ };
131
+
132
+ interface TranslateResult {
133
+ changes: RawChange[];
134
+ newLineIds: Set<string>;
135
+ updatedLineIds: Set<string>;
136
+ }
137
+
138
+ const translateOps = (ops: unknown): TranslateResult => {
139
+ if (!Array.isArray(ops)) {
140
+ throw new Error('ops must be an Array');
141
+ }
142
+ const changes: RawChange[] = [];
143
+ const newLineIds = new Set<string>();
144
+ const updatedLineIds = new Set<string>();
145
+
146
+ for (const op of ops) {
147
+ const kind = opKind(op);
148
+ if (kind === null) {
149
+ throw new Error(
150
+ 'each op must have exactly one of insertBefore / replace / delete'
151
+ );
152
+ }
153
+ if (kind === 'insertBefore') {
154
+ const o = op as InsertBeforeOp;
155
+ if (typeof o.insertBefore !== 'string') {
156
+ throw new Error('insertBefore must be a string lineId');
157
+ }
158
+ if (typeof o.text !== 'string') {
159
+ throw new Error('insertBefore.text must be a string');
160
+ }
161
+ for (const lineText of o.text.split(/\r?\n/)) {
162
+ const id = newLineId();
163
+ changes.push({
164
+ _insert: o.insertBefore,
165
+ lines: { id, text: lineText }
166
+ });
167
+ newLineIds.add(id);
168
+ }
169
+ } else if (kind === 'replace') {
170
+ const o = op as ReplaceOp;
171
+ if (typeof o.replace !== 'string') {
172
+ throw new Error('replace must be a string lineId');
173
+ }
174
+ if (typeof o.text !== 'string') {
175
+ throw new Error('replace.text must be a string');
176
+ }
177
+ if (/\r?\n/.test(o.text)) {
178
+ throw new Error(
179
+ 'replace does not support multi-line text. To split a line into multiple lines, insertBefore the new lines first, then delete the original line.'
180
+ );
181
+ }
182
+ changes.push({ _update: o.replace, lines: { text: o.text } });
183
+ updatedLineIds.add(o.replace);
184
+ } else {
185
+ const o = op as DeleteOp;
186
+ if (typeof o.delete !== 'string') {
187
+ throw new Error('delete must be a string lineId');
188
+ }
189
+ changes.push({ _delete: o.delete });
190
+ }
191
+ }
192
+
193
+ return { changes, newLineIds, updatedLineIds };
194
+ };
195
+
196
+ const summarizeOp = (op: Op): string => {
197
+ if ('insertBefore' in op) {
198
+ const text = typeof op.text === 'string' ? op.text : '';
199
+ const lineCount = text.split(/\r?\n/).length;
200
+ return ` insertBefore ${op.insertBefore}: ${lineCount} line(s)`;
201
+ }
202
+ if ('replace' in op) {
203
+ const text = typeof op.text === 'string' ? op.text : '';
204
+ return ` replace ${op.replace}: ${JSON.stringify(text)}`;
205
+ }
206
+ return ` delete ${op.delete}`;
207
+ };
208
+
209
+ const formatPreview = (
210
+ response: PreviewResponse,
211
+ ops: Op[],
212
+ ids: { newLineIds: Set<string>; updatedLineIds: Set<string> },
213
+ projectName: string
214
+ ): string => {
215
+ const { pagePreview } = response;
216
+ const status =
217
+ pagePreview && pagePreview.persistent === false ? 'create' : 'update';
218
+ const title = pagePreview?.title ?? '';
219
+ const lines: string[] = [];
220
+ lines.push(`previewId: ${response.previewId}`);
221
+ lines.push(`expireAt: ${response.expireAt}`);
222
+ lines.push(`status: ${status}`);
223
+ lines.push(`project: ${projectName}`);
224
+ lines.push(`title: ${title}`);
225
+ lines.push('');
226
+ lines.push('ops:');
227
+ for (const op of ops) {
228
+ lines.push(summarizeOp(op));
229
+ }
230
+ lines.push('');
231
+ lines.push('page (after apply):');
232
+ for (const line of pagePreview?.lines ?? []) {
233
+ if (ids.newLineIds.has(line.id)) {
234
+ lines.push(`> ${line.text} # ${line.id}`);
235
+ } else if (ids.updatedLineIds.has(line.id)) {
236
+ lines.push(`* ${line.text} # ${line.id}`);
237
+ } else {
238
+ lines.push(` ${line.text}`);
239
+ }
240
+ }
241
+ return lines.join('\n');
242
+ };
243
+
244
+ const readStdin = async (): Promise<string> => {
245
+ const chunks: Buffer[] = [];
246
+ for await (const chunk of process.stdin) {
247
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
248
+ }
249
+ return Buffer.concat(chunks).toString('utf8');
250
+ };
251
+
252
+ interface ParsedArgs {
253
+ isNew: boolean;
254
+ projectUrl: string;
255
+ pageId: string | undefined;
256
+ }
257
+
258
+ const parseArgs = (args: string[]): ParsedArgs => {
259
+ const usage =
260
+ 'Usage: cosense previewEdit <projectUrl> <pageId> < ops.json (use --new <projectUrl> < body.txt for new pages)';
261
+ let isNew = false;
262
+ const positional: string[] = [];
263
+ for (const arg of args) {
264
+ if (arg === '--new') {
265
+ if (isNew) {
266
+ throw new Error(`Duplicate option: --new\n${usage}`);
267
+ }
268
+ isNew = true;
269
+ } else if (arg.startsWith('--')) {
270
+ throw new Error(`Unknown option: ${arg}\n${usage}`);
271
+ } else {
272
+ positional.push(arg);
273
+ }
274
+ }
275
+ if (isNew) {
276
+ if (positional.length !== 1) {
277
+ throw new Error(
278
+ 'Usage: cosense previewEdit --new <projectUrl> < body.txt'
279
+ );
280
+ }
281
+ return { isNew, projectUrl: positional[0] as string, pageId: undefined };
282
+ }
283
+ if (positional.length !== 2) {
284
+ throw new Error(usage);
285
+ }
286
+ return {
287
+ isNew,
288
+ projectUrl: positional[0] as string,
289
+ pageId: positional[1] as string
290
+ };
291
+ };
292
+
293
+ export const previewEdit = async (args: string[]): Promise<void> => {
294
+ const { isNew, projectUrl, pageId } = parseArgs(args);
295
+
296
+ if (process.stdin.isTTY) {
297
+ throw new Error(
298
+ isNew
299
+ ? 'previewEdit --new reads plain text body from stdin. Pipe it in, e.g. `printf "Title\\nbody\\n" | cosense previewEdit --new <projectUrl>`.'
300
+ : 'previewEdit reads ops JSON from stdin. Pipe it in, e.g. `cosense previewEdit <projectUrl> <pageId> < ops.json`.'
301
+ );
302
+ }
303
+
304
+ const { origin, projectName } = parseProjectUrlStrict(projectUrl);
305
+ const stdinRaw = await readStdin();
306
+ if (!stdinRaw.trim()) {
307
+ throw new Error(
308
+ isNew
309
+ ? 'stdin is empty. Pipe page body (plain text) to stdin.'
310
+ : 'stdin is empty. Pipe ops JSON to stdin.'
311
+ );
312
+ }
313
+
314
+ // --new はプレーンテキスト本文を _end への単一 _insert に変換する。 ops JSON を組み立てる
315
+ // 冗長さを避けるショートカット。 改行は translateOps が複数行 _insert に分割する。
316
+ // body 全体に .trim() を当てると意図的な空行(先頭/末尾)が消えるので、 Unix line terminator
317
+ // 慣習で末尾の単一改行 (LF または CRLF) だけ取り除く
318
+ let ops: unknown;
319
+ if (isNew) {
320
+ const body = stdinRaw.replace(/\r?\n$/, '');
321
+ ops = [{ insertBefore: '_end', text: body }];
322
+ } else {
323
+ let parsed: unknown;
324
+ try {
325
+ parsed = JSON.parse(stdinRaw);
326
+ } catch (err) {
327
+ throw new Error(
328
+ `stdin is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
329
+ );
330
+ }
331
+ ops = (parsed as { ops?: unknown }).ops;
332
+ }
333
+ const { changes, newLineIds, updatedLineIds } = translateOps(ops);
334
+
335
+ // 書き込み系 API は PAT 限定 (Service Account 拒否) なので、PAT を直接解決する
336
+ const credential = resolveUserCredential(origin);
337
+
338
+ const apiUrl = `${origin}/api/pages/v2/${projectName}/page-edit-for-ai/preview`;
339
+ const requestBody = pageId ? { pageId, changes } : { changes };
340
+ const response = (await requestJson(apiUrl, {
341
+ credential,
342
+ method: 'POST',
343
+ body: requestBody
344
+ })) as PreviewResponse;
345
+
346
+ process.stdout.write(
347
+ `${formatPreview(response, ops as Op[], { newLineIds, updatedLineIds }, projectName)}\n`
348
+ );
349
+ };
@@ -0,0 +1,148 @@
1
+ import { enrichTimestampsOf } from '../lib/enrichTimestamps.ts';
2
+ import { parsePageUrl } from '../lib/parseUrl.ts';
3
+ import { requestJson } from '../lib/request.ts';
4
+ import { fetchUserMap, enrichUser } from '../lib/resolveUsers.ts';
5
+ import { resolveCredential } from '../lib/settings.ts';
6
+
7
+ export const readPageSummary = '単一ページを読む';
8
+
9
+ export const readPageHelp = `readPage - 単一ページを読む
10
+
11
+ Usage:
12
+ cosense readPage <pageUrl>
13
+
14
+ 引数:
15
+ <pageUrl> 読むページの完全なURL(例: https://scrapbox.io/shokai/foo)
16
+
17
+ 戻り値(top-levelの主なkey):
18
+ title string ページタイトル
19
+ persistent boolean 実体のあるページなら true。false でも関連ページリストは存在する場合がある
20
+ id string ページID (persistent: true の時のみ)
21
+ commitId string 最新コミットID (persistent: true の時のみ)
22
+ lines Array<Line> 本文の行配列。Line = { id, text, user, created, updated } (id および user/timestamp 系は persistent: true の時のみ)
23
+ pageRank number ページの重要度指標。被リンクから計算される
24
+ linked number 被リンク数
25
+ views number 閲覧数
26
+ created string ページ作成時刻
27
+ updated string 最終更新時刻
28
+ accessed string 最終アクセス時刻
29
+ user User 作成者
30
+ lastUpdateUser User | null 最終更新者(null の可能性あり)
31
+ users Array<User> このページを編集した事のあるユーザー一覧
32
+ links string[] 本文中のリンク記法([title])のページタイトル
33
+ linksLc string[] links[] を正規化(小文字化+空白を _ に置換)した形式
34
+ projectLinks string[] 別プロジェクトへのリンク記法
35
+ icons string[] [name.icon] 記法で挿入されたアイコン参照のページタイトル
36
+ descriptions string[] 冒頭数行の抜粋
37
+
38
+ User の field(user / lastUpdateUser / users[] / lines[].user で共通):
39
+ id string Cosense内部のID
40
+ name string? ログイン名(自己紹介ページのタイトルや、本文中のアイコン記法で使われる)
41
+ displayName string? 表示名
42
+ email string? メールアドレス
43
+
44
+ 戻り値のJSON抜粋例:
45
+ {
46
+ "title": "shokai",
47
+ "persistent": true,
48
+ "lines": [
49
+ {
50
+ "id": "57bb9aa9c2e0ec0011d2e72b",
51
+ "text": "shokai",
52
+ "user": { "id": "5724627723541f110097c291", "name": "shokai" },
53
+ "created": "2016-08-22T14:35+09:00 (9 years ago)",
54
+ "updated": "2016-08-22T14:35+09:00 (9 years ago)"
55
+ }
56
+ ],
57
+ "pageRank": 12,
58
+ "linked": 7,
59
+ "views": 22301,
60
+ "links": ["Cosense"],
61
+ "linksLc": ["cosense"],
62
+ "projectLinks": [],
63
+ "icons": ["shokai"],
64
+ "user": { "id": "5724627723541f110097c291", "name": "shokai" },
65
+ "lastUpdateUser": { "id": "5724627723541f110097c291", "name": "shokai" }
66
+ }
67
+
68
+ 絞り込み例(jqで欲しい部分だけ抜き出す):
69
+ 各行のテキストだけ:
70
+ cosense readPage <pageUrl> | jq -r '.lines[].text'
71
+ `;
72
+
73
+ interface PageLine {
74
+ id?: string;
75
+ text?: string;
76
+ userId?: string;
77
+ user?: { id: string };
78
+ created?: number;
79
+ updated?: number;
80
+ }
81
+
82
+ interface PageData {
83
+ user?: { id: string } | null;
84
+ lastUpdateUser?: { id: string } | null;
85
+ users?: { id: string }[];
86
+ lines?: PageLine[];
87
+ }
88
+
89
+ export const readPage = async (args: string[]): Promise<void> => {
90
+ const [url] = args;
91
+ if (!url) throw new Error('Usage: cosense readPage <pageUrl>');
92
+ const { origin, projectName, encodedTitle } = parsePageUrl(url);
93
+ const apiUrl = `${origin}/api/pages/v2/${projectName}/${encodedTitle}`;
94
+ const credential = resolveCredential(origin, projectName);
95
+ const data = (await requestJson(apiUrl, { credential })) as PageData;
96
+
97
+ if ((data as { persistent?: boolean }).persistent === false) {
98
+ // 非存在ページに対してサーバーが返すテンプレートには、 仮の pageId / commitId / lines[0].id
99
+ // が含まれる。 そのまま AI に渡すと previewEdit の anchor に fake な lineId を使ってしまい
100
+ // 422 になる。 anchor に使えそうな field をここで全部削除し、 新規ページでは "_end" を
101
+ // 使うしかない状態にする
102
+ for (const field of [
103
+ 'id',
104
+ 'commitId',
105
+ 'user',
106
+ 'lastUpdateUser',
107
+ 'users',
108
+ 'linked',
109
+ 'created',
110
+ 'updated',
111
+ 'accessed',
112
+ 'lastAccessed',
113
+ 'snapshotCreated'
114
+ ]) {
115
+ delete (data as Record<string, unknown>)[field];
116
+ }
117
+ for (const line of data.lines ?? []) {
118
+ for (const field of ['id', 'userId', 'user', 'created', 'updated']) {
119
+ delete (line as Record<string, unknown>)[field];
120
+ }
121
+ }
122
+ }
123
+
124
+ const userMap = await fetchUserMap(origin, projectName);
125
+ enrichUser(data.user, userMap);
126
+ enrichUser(data.lastUpdateUser, userMap);
127
+ for (const editor of data.users ?? []) {
128
+ enrichUser(editor, userMap);
129
+ }
130
+ for (const line of data.lines ?? []) {
131
+ const userId = line.userId;
132
+ if (typeof userId === 'string' && userId !== '') {
133
+ line.user = { id: userId };
134
+ delete line.userId;
135
+ enrichUser(line.user, userMap);
136
+ }
137
+ enrichTimestampsOf(line as Record<string, unknown>, ['created', 'updated']);
138
+ }
139
+ enrichTimestampsOf(data as Record<string, unknown>, [
140
+ 'created',
141
+ 'updated',
142
+ 'accessed',
143
+ 'snapshotCreated',
144
+ 'lastAccessed'
145
+ ]);
146
+
147
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
148
+ };
@@ -0,0 +1,89 @@
1
+ import { enrichTimestampsOf } from '../lib/enrichTimestamps.ts';
2
+ import { parseProjectUrl } from '../lib/parseUrl.ts';
3
+ import { requestJson } from '../lib/request.ts';
4
+ import { resolveCredential } from '../lib/settings.ts';
5
+
6
+ export const readProjectMembersSummary = 'プロジェクトのメンバー一覧を取得する';
7
+
8
+ export const readProjectMembersHelp = `readProjectMembers - プロジェクトのメンバー一覧を取得する
9
+
10
+ Usage:
11
+ cosense readProjectMembers <projectUrl>
12
+
13
+ 引数:
14
+ <projectUrl> プロジェクトのURL(例: https://scrapbox.io/shokai)
15
+
16
+ 戻り値(top-levelの主なkey):
17
+ users Array<User> 現メンバー一覧
18
+ memberSnapshots Array<Snapshot>? 退去済みメンバーの記録
19
+
20
+ User の field:
21
+ id string Cosense内部のID
22
+ name string? ログイン名(自己紹介ページのタイトルや、本文中のアイコン記法で使われる)
23
+ displayName string? 表示名
24
+ photo string? アイコン画像URL
25
+ email string? メールアドレス
26
+ provider string? 認証プロバイダ(google / microsoft / saml 等)
27
+ created string? 作成時刻
28
+ updated string? 更新時刻
29
+
30
+ Snapshot の field:
31
+ id string snapshotのID
32
+ reason 'deleted' | 'left' 退去理由
33
+ created string 退去者snapshot作成時刻
34
+ updated string 退去者snapshot更新時刻
35
+ data { id, name?, displayName?, email? } 退去時のユーザー情報
36
+
37
+ 戻り値のJSON抜粋例:
38
+ {
39
+ "users": [
40
+ {
41
+ "id": "5724627723541f110097c291",
42
+ "name": "shokai",
43
+ "displayName": "Sho Hashimoto",
44
+ "email": "shokai@example.com",
45
+ "provider": "google",
46
+ "created": "2016-08-22T14:35+09:00 (9 years ago)",
47
+ "updated": "2026-04-25T18:31+09:00 (5 days ago)"
48
+ }
49
+ ],
50
+ "memberSnapshots": [
51
+ {
52
+ "id": "65a1...",
53
+ "reason": "left",
54
+ "created": "2023-11-14T15:33+09:00 (2 years ago)",
55
+ "updated": "2023-11-14T15:33+09:00 (2 years ago)",
56
+ "data": {
57
+ "id": "59b8...",
58
+ "name": "former-member",
59
+ "displayName": "Former Member",
60
+ "email": "former@example.com"
61
+ }
62
+ }
63
+ ]
64
+ }
65
+ `;
66
+
67
+ export const readProjectMembers = async (args: string[]): Promise<void> => {
68
+ const [url] = args;
69
+ if (!url) throw new Error('Usage: cosense readProjectMembers <projectUrl>');
70
+ if (args.length > 1) {
71
+ throw new Error(
72
+ `Unexpected positional argument: ${args[1]}\nUsage: cosense readProjectMembers <projectUrl>`
73
+ );
74
+ }
75
+ const { origin, projectName } = parseProjectUrl(url);
76
+ const apiUrl = `${origin}/api/projects/${projectName}/users`;
77
+ const credential = resolveCredential(origin, projectName);
78
+ const data = (await requestJson(apiUrl, { credential })) as {
79
+ users?: Record<string, unknown>[];
80
+ memberSnapshots?: Record<string, unknown>[];
81
+ };
82
+ for (const user of data.users ?? []) {
83
+ enrichTimestampsOf(user, ['created', 'updated']);
84
+ }
85
+ for (const snap of data.memberSnapshots ?? []) {
86
+ enrichTimestampsOf(snap, ['created', 'updated']);
87
+ }
88
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
89
+ };
@@ -0,0 +1,43 @@
1
+ import { fetchRelatedPagesWithRelations } from '../lib/annotateRelations.ts';
2
+ import { enrichTimestampsOf } from '../lib/enrichTimestamps.ts';
3
+ import { parsePageUrl } from '../lib/parseUrl.ts';
4
+ import { enrichPageUsers, fetchUserMap } from '../lib/resolveUsers.ts';
5
+
6
+ export const search1hopLinksSummary = '1-hop近傍を全文検索でフィルタする';
7
+
8
+ export const search1hopLinksHelp = `search1hopLinks - 1-hop近傍を全文検索でフィルタする
9
+
10
+ Usage:
11
+ cosense search1hopLinks <pageUrl> <query>
12
+
13
+ 引数:
14
+ <pageUrl> 対象ページの完全なURL
15
+ <query> 全文検索クエリ(必須・空文字は弾かれる)
16
+
17
+ 戻り値(top-levelの主なkey):
18
+ links1hop Array<Link> query を本文に含む1-hop近傍ページ
19
+ ほか list1hopLinks と同じ top-level key
20
+
21
+ 各 Link の field(list1hopLinksに加えて):
22
+ search 検索ハイライト情報
23
+
24
+ 検索の制約:
25
+ - OR検索不可。複数キーワードを試したい時はクエリを分解して複数回叩く
26
+ `;
27
+
28
+ export const search1hopLinks = async (args: string[]): Promise<void> => {
29
+ const [url, query] = args;
30
+ if (args.length !== 2 || !url || !query || query.trim() === '') {
31
+ throw new Error('Usage: cosense search1hopLinks <pageUrl> <query>');
32
+ }
33
+ const { origin, projectName } = parsePageUrl(url);
34
+ const [data, userMap] = await Promise.all([
35
+ fetchRelatedPagesWithRelations(url, query),
36
+ fetchUserMap(origin, projectName)
37
+ ]);
38
+ for (const page of (data as { links1hop?: unknown[] }).links1hop ?? []) {
39
+ enrichPageUsers(page as Record<string, unknown>, userMap);
40
+ enrichTimestampsOf(page as Record<string, unknown>);
41
+ }
42
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
43
+ };
@@ -0,0 +1,43 @@
1
+ import { enrichTimestampsOf } from '../lib/enrichTimestamps.ts';
2
+ import { parsePageUrl } from '../lib/parseUrl.ts';
3
+ import { fetchRelatedPages } from '../lib/relatedPages.ts';
4
+ import { enrichPageUsers, fetchUserMap } from '../lib/resolveUsers.ts';
5
+
6
+ export const search2hopLinksSummary = '2-hop近傍を全文検索でフィルタする';
7
+
8
+ export const search2hopLinksHelp = `search2hopLinks - 2-hop近傍を全文検索でフィルタする
9
+
10
+ Usage:
11
+ cosense search2hopLinks <pageUrl> <query>
12
+
13
+ 引数:
14
+ <pageUrl> 対象ページの完全なURL
15
+ <query> 全文検索クエリ(必須・空文字は弾かれる)
16
+
17
+ 戻り値(top-levelの主なkey):
18
+ links2hop Array<Link> query を本文に含む2-hop近傍ページ
19
+ ほか list2hopLinks と同じ top-level key
20
+
21
+ 各 Link の field(list2hopLinksに加えて):
22
+ search 検索ハイライト情報
23
+
24
+ 検索の制約:
25
+ - OR検索不可。複数キーワードを試したい時はクエリを分解して複数回叩く
26
+ `;
27
+
28
+ export const search2hopLinks = async (args: string[]): Promise<void> => {
29
+ const [url, query] = args;
30
+ if (args.length !== 2 || !url || !query || query.trim() === '') {
31
+ throw new Error('Usage: cosense search2hopLinks <pageUrl> <query>');
32
+ }
33
+ const { origin, projectName } = parsePageUrl(url);
34
+ const [data, userMap] = await Promise.all([
35
+ fetchRelatedPages(url, 2, query),
36
+ fetchUserMap(origin, projectName)
37
+ ]);
38
+ for (const page of (data as { links2hop?: unknown[] }).links2hop ?? []) {
39
+ enrichPageUsers(page as Record<string, unknown>, userMap);
40
+ enrichTimestampsOf(page as Record<string, unknown>);
41
+ }
42
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
43
+ };