@helpfeel/cosense-cli 1.17.1 → 1.18.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/package.json +3 -3
- package/src/commands/browsePageChanges.ts +136 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@helpfeel/cosense-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.0",
|
|
4
4
|
"description": "Cosense (旧Scrapbox) のページを読み・調べ・編集するAgent Skillのハーネス",
|
|
5
5
|
"homepage": "https://github.com/helpfeel/cosense-cli",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@types/node": "24.19.2",
|
|
31
31
|
"npm-run-all": "4.1.5",
|
|
32
|
-
"oxfmt": "0.
|
|
33
|
-
"oxlint": "1.
|
|
32
|
+
"oxfmt": "0.73.0",
|
|
33
|
+
"oxlint": "1.88.0",
|
|
34
34
|
"typescript": "7.0.2"
|
|
35
35
|
},
|
|
36
36
|
"engines": {
|
|
@@ -13,20 +13,25 @@ import {
|
|
|
13
13
|
import { resolveCredential, type Credential } from '../lib/settings.ts';
|
|
14
14
|
|
|
15
15
|
export const browsePageChangesSummary =
|
|
16
|
-
'
|
|
16
|
+
'ページの最近の編集履歴(commit)をpageId起点で取得し、誰がいつ何をどう変えたかを自然言語で説明する。タイトル変更(リネーム)も検出する';
|
|
17
17
|
|
|
18
|
-
export const browsePageChangesHelp = `browsePageChanges -
|
|
18
|
+
export const browsePageChangesHelp = `browsePageChanges - ページの最近の編集履歴(commit)をpageId起点で取得し自然言語で説明する
|
|
19
19
|
|
|
20
20
|
Usage:
|
|
21
|
-
cosense browsePageChanges <projectUrl> <pageId> [--since <commitId>]
|
|
21
|
+
cosense browsePageChanges <projectUrl> <pageId> [--since <commitId> | [--before <commitId>] [--all]]
|
|
22
22
|
|
|
23
23
|
引数:
|
|
24
|
-
<projectUrl>
|
|
25
|
-
<pageId>
|
|
26
|
-
--since <commitId>
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
<projectUrl> プロジェクトのURL(例: https://scrapbox.io/shokai)
|
|
25
|
+
<pageId> ページの不変ID。browsePage / readPage の出力に含まれる
|
|
26
|
+
--since <commitId> 指定した commitId より後の変更だけを対象にする(カーソル)。
|
|
27
|
+
browsePage / readPage 出力の commitId を控えておき、それを渡すと
|
|
28
|
+
「前回読んだ後に何が変わったか」だけを取得できる。
|
|
29
|
+
--before <commitId> 指定した commitId より前の変更を、新しい方から最大100件対象にする。
|
|
30
|
+
前回の出力の「さらに前の変更を読むコマンド」をそのまま実行すると続きを読める。
|
|
31
|
+
--all 保持中の全履歴を対象にする。履歴の長いページでは要求が何回にも分かれる。
|
|
32
|
+
--before と併用すると、その commitId より前の全履歴になる。
|
|
33
|
+
どれも省略すると、最新の変更を新しい方から最大100件対象にする。
|
|
34
|
+
--since は --before / --all と併用できない。
|
|
30
35
|
|
|
31
36
|
なぜpageId起点か:
|
|
32
37
|
ページタイトルはURLになるため、共同編集者がタイトルを変更すると旧タイトルでは読めなくなる。
|
|
@@ -34,8 +39,11 @@ Usage:
|
|
|
34
39
|
|
|
35
40
|
出力形式(Markdown plain text。JSONではない):
|
|
36
41
|
# ページ変更履歴
|
|
37
|
-
pageId /
|
|
38
|
-
|
|
42
|
+
pageId / 範囲 / commit数 / title change(範囲内の最後の変更後タイトル、無ければ なし)
|
|
43
|
+
範囲外のタイトル変更は検出しないので、無しでもリネーム済みの事がある。
|
|
44
|
+
範囲が最新の変更を含む時(--before 無し)は、タイトルが変わっていれば新URLと新Readable linkも出力する。
|
|
45
|
+
--before 付きの title change は当時のタイトルで、現在のタイトルとは限らないのでURLは出さない。
|
|
46
|
+
既定と --before(--all 無し)で範囲より前にも変更がある時は「さらに前の変更を読むコマンド」に、続きを読む完全なコマンドを示す
|
|
39
47
|
## 変更
|
|
40
48
|
時系列の変更イベントを「<日時>\t<誰>が<何を>」の形(TAB区切り)で列挙する。
|
|
41
49
|
同一行への連続した編集は1件にまとめ、変更前→最終のテキストを示す。
|
|
@@ -73,22 +81,32 @@ interface ParsedArgs {
|
|
|
73
81
|
projectUrl: string;
|
|
74
82
|
pageId: string;
|
|
75
83
|
since: string | undefined;
|
|
84
|
+
before: string | undefined;
|
|
85
|
+
all: boolean;
|
|
76
86
|
}
|
|
77
87
|
|
|
78
88
|
const parseArgs = (args: string[]): ParsedArgs => {
|
|
79
89
|
const usage =
|
|
80
|
-
'Usage: cosense browsePageChanges <projectUrl> <pageId> [--since <commitId>]';
|
|
90
|
+
'Usage: cosense browsePageChanges <projectUrl> <pageId> [--since <commitId> | [--before <commitId>] [--all]]';
|
|
81
91
|
let since: string | undefined;
|
|
92
|
+
let before: string | undefined;
|
|
93
|
+
let all = false;
|
|
82
94
|
const positional: string[] = [];
|
|
83
95
|
for (let i = 0; i < args.length; i += 1) {
|
|
84
96
|
const arg = args[i] as string;
|
|
85
|
-
if (arg === '--since') {
|
|
97
|
+
if (arg === '--since' || arg === '--before') {
|
|
86
98
|
const value = args[i + 1];
|
|
87
99
|
if (value === undefined || value.startsWith('--')) {
|
|
88
|
-
throw new Error(
|
|
100
|
+
throw new Error(`${arg} requires a commitId\n${usage}`);
|
|
101
|
+
}
|
|
102
|
+
if (arg === '--since') {
|
|
103
|
+
since = value;
|
|
104
|
+
} else {
|
|
105
|
+
before = value;
|
|
89
106
|
}
|
|
90
|
-
since = value;
|
|
91
107
|
i += 1;
|
|
108
|
+
} else if (arg === '--all') {
|
|
109
|
+
all = true;
|
|
92
110
|
} else if (arg.startsWith('--')) {
|
|
93
111
|
throw new Error(`Unknown option: ${arg}\n${usage}`);
|
|
94
112
|
} else {
|
|
@@ -98,10 +116,15 @@ const parseArgs = (args: string[]): ParsedArgs => {
|
|
|
98
116
|
if (positional.length !== 2) {
|
|
99
117
|
throw new Error(usage);
|
|
100
118
|
}
|
|
119
|
+
if (since !== undefined && (before !== undefined || all)) {
|
|
120
|
+
throw new Error(`--since cannot be used with --before or --all\n${usage}`);
|
|
121
|
+
}
|
|
101
122
|
return {
|
|
102
123
|
projectUrl: positional[0] as string,
|
|
103
124
|
pageId: positional[1] as string,
|
|
104
|
-
since
|
|
125
|
+
since,
|
|
126
|
+
before,
|
|
127
|
+
all
|
|
105
128
|
};
|
|
106
129
|
};
|
|
107
130
|
|
|
@@ -119,17 +142,18 @@ const resolveUserName = (
|
|
|
119
142
|
const quote = (text: string | undefined): string => `「${text ?? ''}」`;
|
|
120
143
|
|
|
121
144
|
// 1回の応答は件数と大きさで打ち切られ、続きがある時はhasMoreが真になる
|
|
122
|
-
|
|
145
|
+
// headより後のcommitを古い順に、headを最後のcommitに付け替えて全部取る
|
|
146
|
+
const fetchCommitsAfter = async (
|
|
123
147
|
apiUrl: string,
|
|
124
|
-
since: string
|
|
148
|
+
since: string,
|
|
125
149
|
credential: Credential | undefined
|
|
126
150
|
): Promise<Commit[]> => {
|
|
127
151
|
const commits: Commit[] = [];
|
|
128
152
|
let head = since;
|
|
129
153
|
let continuing = false;
|
|
130
|
-
const requestedHeads = new Set<string>(
|
|
154
|
+
const requestedHeads = new Set<string>([since]);
|
|
131
155
|
while (true) {
|
|
132
|
-
const url =
|
|
156
|
+
const url = `${apiUrl}?head=${encodeURIComponent(head)}`;
|
|
133
157
|
const data = (await requestJson(url, { credential })) as CommitsResponse;
|
|
134
158
|
const batch = data.commits ?? [];
|
|
135
159
|
const first = batch[0];
|
|
@@ -138,7 +162,7 @@ const fetchAllCommits = async (
|
|
|
138
162
|
throw new Error(`no commits follow head "${head}"`);
|
|
139
163
|
}
|
|
140
164
|
// headに続かない応答を辿ると、同じ応答を取り続けて終わらないので止める
|
|
141
|
-
if (
|
|
165
|
+
if (first && first.parentId !== head) {
|
|
142
166
|
throw new Error(`commits do not follow head "${head}"`);
|
|
143
167
|
}
|
|
144
168
|
// spreadで足すと、1回の応答が大きい時に引数の上限を超える
|
|
@@ -158,6 +182,56 @@ const fetchAllCommits = async (
|
|
|
158
182
|
}
|
|
159
183
|
};
|
|
160
184
|
|
|
185
|
+
// 最新のcommit(beforeがあればその親)から親を辿った新しい順の応答を、allなら全部、でなければ1回分だけ取る
|
|
186
|
+
// olderCommitIdは、取った範囲より前に変更が残っている時の、続きを取るbefore
|
|
187
|
+
const fetchCommitsBefore = async (
|
|
188
|
+
apiUrl: string,
|
|
189
|
+
{ before, all }: { before: string | undefined; all: boolean },
|
|
190
|
+
credential: Credential | undefined
|
|
191
|
+
): Promise<{ commits: Commit[]; olderCommitId: string | undefined }> => {
|
|
192
|
+
const commits: Commit[] = [];
|
|
193
|
+
let cursor = before;
|
|
194
|
+
// 前の応答の最後のcommitの親。続きの応答の先頭はこれでなければならない
|
|
195
|
+
let expectedFirstId: string | null | undefined;
|
|
196
|
+
const requestedCursors = new Set<string>(before ? [before] : []);
|
|
197
|
+
while (true) {
|
|
198
|
+
const url = cursor
|
|
199
|
+
? `${apiUrl}?before=${encodeURIComponent(cursor)}`
|
|
200
|
+
: apiUrl;
|
|
201
|
+
const data = (await requestJson(url, { credential })) as CommitsResponse;
|
|
202
|
+
const batch = data.commits ?? [];
|
|
203
|
+
const first = batch[0];
|
|
204
|
+
// 古いserverはhead無しの応答を古い順に返す。隣り合う2件の親子の向きで見分け、古い100件を最新として出さない
|
|
205
|
+
const second = batch[1];
|
|
206
|
+
if (first && second && second.parentId === first.id) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
'the server returned commits from the oldest. This version of cosense-cli needs a server that returns them from the latest'
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (expectedFirstId !== undefined) {
|
|
212
|
+
if (!first) {
|
|
213
|
+
throw new Error(`no commits precede "${cursor}"`);
|
|
214
|
+
}
|
|
215
|
+
if (first.id !== expectedFirstId) {
|
|
216
|
+
throw new Error(`commits do not precede "${cursor}"`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const commit of batch) commits.push(commit);
|
|
220
|
+
const last = batch.at(-1);
|
|
221
|
+
if (!data.hasMore) return { commits, olderCommitId: undefined };
|
|
222
|
+
if (!last?.id) {
|
|
223
|
+
throw new Error('hasMore is true but the last commit has no id');
|
|
224
|
+
}
|
|
225
|
+
if (!all) return { commits, olderCommitId: last.id };
|
|
226
|
+
if (requestedCursors.has(last.id)) {
|
|
227
|
+
throw new Error(`commit "${last.id}" appears again as before`);
|
|
228
|
+
}
|
|
229
|
+
requestedCursors.add(last.id);
|
|
230
|
+
cursor = last.id;
|
|
231
|
+
expectedFirstId = last.parentId ?? null;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
161
235
|
// 時系列の変更イベント。同一行への連続updateは1件に畳む
|
|
162
236
|
type ChangeEvent =
|
|
163
237
|
| { kind: 'title'; userIds: string[]; created?: number; title: string }
|
|
@@ -243,15 +317,45 @@ const renderEvent = (event: ChangeEvent, userMap: UserMap): string => {
|
|
|
243
317
|
}
|
|
244
318
|
};
|
|
245
319
|
|
|
320
|
+
const fetchCommits = async (
|
|
321
|
+
apiUrl: string,
|
|
322
|
+
{ since, before, all }: Pick<ParsedArgs, 'since' | 'before' | 'all'>,
|
|
323
|
+
credential: Credential | undefined
|
|
324
|
+
): Promise<{ commits: Commit[]; olderCommitId: string | undefined }> => {
|
|
325
|
+
if (since !== undefined) {
|
|
326
|
+
const commits = await fetchCommitsAfter(apiUrl, since, credential);
|
|
327
|
+
return { commits, olderCommitId: undefined };
|
|
328
|
+
}
|
|
329
|
+
const { commits, olderCommitId } = await fetchCommitsBefore(
|
|
330
|
+
apiUrl,
|
|
331
|
+
{ before, all },
|
|
332
|
+
credential
|
|
333
|
+
);
|
|
334
|
+
// 新しい順の応答を時系列に戻す。同一行への連続updateの畳み方が順序に依存する
|
|
335
|
+
commits.reverse();
|
|
336
|
+
return { commits, olderCommitId };
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const describeRange = (
|
|
340
|
+
{ since, before, all }: Pick<ParsedArgs, 'since' | 'before' | 'all'>,
|
|
341
|
+
count: number
|
|
342
|
+
): string => {
|
|
343
|
+
if (since !== undefined) return `commit ${since} より後`;
|
|
344
|
+
if (all && before !== undefined) return `commit ${before} より前の全履歴`;
|
|
345
|
+
if (all) return '全履歴';
|
|
346
|
+
if (before !== undefined) return `commit ${before} より前 ${count}件`;
|
|
347
|
+
return `最新 ${count}件`;
|
|
348
|
+
};
|
|
349
|
+
|
|
246
350
|
export const browsePageChanges = async (args: string[]): Promise<void> => {
|
|
247
|
-
const { projectUrl, pageId, since } = parseArgs(args);
|
|
351
|
+
const { projectUrl, pageId, since, before, all } = parseArgs(args);
|
|
248
352
|
const { origin, projectName } = parseProjectUrlStrict(projectUrl);
|
|
249
353
|
|
|
250
354
|
const apiUrl = `${origin}/api/commits/${projectName}/${pageId}`;
|
|
251
355
|
const credential = resolveCredential(origin, projectName);
|
|
252
356
|
|
|
253
|
-
const [commits, userMap] = await Promise.all([
|
|
254
|
-
|
|
357
|
+
const [{ commits, olderCommitId }, userMap] = await Promise.all([
|
|
358
|
+
fetchCommits(apiUrl, { since, before, all }, credential),
|
|
255
359
|
fetchUserMap(origin, projectName)
|
|
256
360
|
]);
|
|
257
361
|
|
|
@@ -264,12 +368,18 @@ export const browsePageChanges = async (args: string[]): Promise<void> => {
|
|
|
264
368
|
|
|
265
369
|
const meta: string[] = [
|
|
266
370
|
`- pageId: ${pageId}`,
|
|
267
|
-
`- 範囲: ${since
|
|
371
|
+
`- 範囲: ${describeRange({ since, before, all }, commits.length)}`,
|
|
268
372
|
`- commit数: ${commits.length}`,
|
|
269
373
|
`- title change: ${latestTitle !== undefined ? quote(latestTitle) : 'なし'}`
|
|
270
374
|
];
|
|
375
|
+
if (olderCommitId !== undefined) {
|
|
376
|
+
meta.push(
|
|
377
|
+
`- さらに前の変更を読むコマンド: cosense browsePageChanges ${origin}/${projectName} ${pageId} --before ${olderCommitId}`
|
|
378
|
+
);
|
|
379
|
+
}
|
|
271
380
|
// タイトルが変わっていればURLも変わっている。新URLを添える
|
|
272
|
-
|
|
381
|
+
// --before 付きの範囲のタイトルは当時の物で、その後に変わっている事があるのでURLにしない
|
|
382
|
+
if (latestTitle !== undefined && before === undefined) {
|
|
273
383
|
const newUrl = `${origin}/${projectName}/${encodeTitleForUrl(latestTitle)}`;
|
|
274
384
|
meta.push(`- 新URL: ${newUrl}`);
|
|
275
385
|
const newReadableLink = `${origin}/${projectName}/${encodeTitleForReadableLink(latestTitle)}`;
|