@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,66 @@
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 list1hopLinksSummary = '1-hop近傍を取得する';
7
+
8
+ export const list1hopLinksHelp = `list1hopLinks - 1-hop近傍を取得する
9
+
10
+ Usage:
11
+ cosense list1hopLinks <pageUrl>
12
+
13
+ 引数:
14
+ <pageUrl> 対象ページの完全なURL
15
+
16
+ 戻り値(top-levelの主なkey):
17
+ links1hop Array<Link> 1-hop近傍のページ配列
18
+ charsCount number 対象ページの文字数
19
+ hasBackLinksOrIcons boolean 被リンク・アイコン参照があるか
20
+ kcsControlTagsLc string[] 制御タグ
21
+ pagination object ページネーション情報
22
+
23
+ 各 Link の field:
24
+ id, title, titleLc, image, descriptions(冒頭数行),
25
+ linksLc(リンク記法のtitleLc配列), linked(被リンク数), pageRank,
26
+ infoboxDefinition, infoboxDisableLinks,
27
+ created, updated, accessed, lastAccessed (string), charsCount,
28
+ user (作成者 User), lastUpdateUser (最終更新者 User | null), users (更新者リスト Array<User>),
29
+ relation 'outgoing' | 'incoming' | 'bidirectional'
30
+ outgoing 対象ページが参照しているページ(正リンク)
31
+ incoming 対象ページを参照しているページ(逆リンク)
32
+ bidirectional 双方向リンク
33
+
34
+ User の field(user / lastUpdateUser / users[] で共通):
35
+ id string Cosense内部のID
36
+ name string? ログイン名(自己紹介ページのタイトルや、本文中のアイコン記法で使われる)
37
+ displayName string? 表示名
38
+ email string? メールアドレス
39
+
40
+ 戻り値のJSON抜粋例:
41
+ {
42
+ "links1hop": [
43
+ {
44
+ "id": "...", "title": "...", "linked": 4, "pageRank": 14,
45
+ "descriptions": ["..."], "linksLc": ["..."], "relation": "outgoing"
46
+ }
47
+ ]
48
+ }
49
+ `;
50
+
51
+ export const list1hopLinks = async (args: string[]): Promise<void> => {
52
+ if (args.length !== 1) {
53
+ throw new Error('Usage: cosense list1hopLinks <pageUrl>');
54
+ }
55
+ const [url] = args as [string];
56
+ const { origin, projectName } = parsePageUrl(url);
57
+ const [data, userMap] = await Promise.all([
58
+ fetchRelatedPagesWithRelations(url),
59
+ fetchUserMap(origin, projectName)
60
+ ]);
61
+ for (const page of (data as { links1hop?: unknown[] }).links1hop ?? []) {
62
+ enrichPageUsers(page as Record<string, unknown>, userMap);
63
+ enrichTimestampsOf(page as Record<string, unknown>);
64
+ }
65
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
66
+ };
@@ -0,0 +1,53 @@
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 list2hopLinksSummary = '2-hop近傍を取得する';
7
+
8
+ export const list2hopLinksHelp = `list2hopLinks - 2-hop近傍を取得する
9
+
10
+ Usage:
11
+ cosense list2hopLinks <pageUrl>
12
+
13
+ 引数:
14
+ <pageUrl> 対象ページの完全なURL
15
+
16
+ 戻り値(top-levelの主なkey):
17
+ links2hop Array<Link> 2-hop近傍のページ配列
18
+ hiddenHeadwordsLc string[] 非表示のヘッドワード
19
+ pagination object ページネーション情報
20
+
21
+ 各 Link の field:
22
+ id, title, titleLc, image, descriptions, linksLc, linked, pageRank,
23
+ infoboxDefinition, infoboxDisableLinks,
24
+ created, updated, accessed, lastAccessed (string), charsCount,
25
+ user (作成者 User), lastUpdateUser (最終更新者 User | null), users (更新者リスト Array<User>)
26
+
27
+ User の field(user / lastUpdateUser / users[] で共通):
28
+ id string Cosense内部のID
29
+ name string? ログイン名(自己紹介ページのタイトルや、本文中のアイコン記法で使われる)
30
+ displayName string? 表示名
31
+ email string? メールアドレス
32
+
33
+ 注意:
34
+ - 直接の1-hop近傍は含まれない
35
+ - list1hopLinks と異なり、relation field は付与されない
36
+ `;
37
+
38
+ export const list2hopLinks = async (args: string[]): Promise<void> => {
39
+ if (args.length !== 1) {
40
+ throw new Error('Usage: cosense list2hopLinks <pageUrl>');
41
+ }
42
+ const [url] = args as [string];
43
+ const { origin, projectName } = parsePageUrl(url);
44
+ const [data, userMap] = await Promise.all([
45
+ fetchRelatedPages(url, 2),
46
+ fetchUserMap(origin, projectName)
47
+ ]);
48
+ for (const page of (data as { links2hop?: unknown[] }).links2hop ?? []) {
49
+ enrichPageUsers(page as Record<string, unknown>, userMap);
50
+ enrichTimestampsOf(page as Record<string, unknown>);
51
+ }
52
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
53
+ };
@@ -0,0 +1,164 @@
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 listPagesSummary = 'プロジェクトのページ一覧を取得する';
8
+
9
+ export const listPagesHelp = `listPages - プロジェクトのページ一覧を取得する
10
+
11
+ Usage:
12
+ cosense listPages <projectUrl> [options]
13
+
14
+ 引数:
15
+ <projectUrl> プロジェクトのURL(例: https://scrapbox.io/shokai/)
16
+
17
+ オプション:
18
+ --sort <name> ソート順(既定: updated)
19
+ updated 更新日時降順
20
+ created 作成日時降順
21
+ accessed 最終アクセス降順
22
+ linked 被リンク数降順
23
+ views 閲覧数降順
24
+ title タイトル昇順
25
+ pinned page は常に先頭に来る
26
+ --limit <N> 1リクエストで返るページ数(既定 100、最大 1000)
27
+ --skip <N> 先頭から N 件スキップして取得(既定 0)
28
+ --filter <name> リストされるページを絞り込む。
29
+ 本文中に [name.icon] を持つページと
30
+ 指定したnameを持つユーザーがこれまでに編集したページが返る
31
+ ユーザー名で絞り込む場合は user.displayName ではなく users.name を指定する
32
+ 自分の名前は whoami コマンドで確認できる
33
+
34
+ 戻り値(top-levelの主なkey):
35
+ projectName string プロジェクト名
36
+ count number 条件に一致する総ページ数
37
+ limit number 1リクエストで返るページ数の上限(既定 100)
38
+ skip number スキップ件数(オフセット)
39
+ pages Array<Page> ページのメタデータ配列
40
+
41
+ 各 Page の主なfield:
42
+ id, title, image, descriptions,
43
+ user (作成者 User), lastUpdateUser (最終更新者 User | null), users (更新者リスト Array<User>),
44
+ pin (pinned page なら正の値), views, linked,
45
+ linesCount, charsCount,
46
+ created, updated, accessed, lastAccessed (string)
47
+
48
+ User の field(user / lastUpdateUser / users[] で共通):
49
+ id string Cosense内部のID
50
+ name string? ログイン名(自己紹介ページのタイトルや、本文中のアイコン記法で使われる)
51
+ displayName string? 表示名
52
+ email string? メールアドレス
53
+
54
+ 注意:
55
+ - ページ本文(lines)は含まれない。本文が必要なら readPage を使う
56
+ - 1000件を超える件数を1リクエストで取ることはできない。--skip でページネーションする
57
+ `;
58
+
59
+ const ALLOWED_SORTS = new Set([
60
+ 'updated',
61
+ 'created',
62
+ 'accessed',
63
+ 'linked',
64
+ 'views',
65
+ 'title'
66
+ ]);
67
+
68
+ const NON_NEGATIVE_INT = /^(?:0|[1-9]\d*)$/;
69
+
70
+ interface ParsedArgs {
71
+ url: string;
72
+ sort?: string;
73
+ limit?: string;
74
+ skip?: string;
75
+ filter?: string;
76
+ }
77
+
78
+ const parseArgs = (args: string[]): ParsedArgs => {
79
+ const usage =
80
+ 'Usage: cosense listPages <projectUrl> [--sort <name>] [--limit <N>] [--skip <N>] [--filter <name>]';
81
+ let url: string | undefined;
82
+ const parsed: Partial<ParsedArgs> = {};
83
+ for (let i = 0; i < args.length; i++) {
84
+ const arg = args[i] as string;
85
+ const next = (): string => {
86
+ const value = args[i + 1];
87
+ if (value === undefined) throw new Error(`${arg} requires a value`);
88
+ i += 1;
89
+ return value;
90
+ };
91
+ if (arg === '--sort') {
92
+ const value = next();
93
+ if (!ALLOWED_SORTS.has(value)) {
94
+ throw new Error(
95
+ `Unknown --sort value: ${value}. Allowed: ${[...ALLOWED_SORTS].join(', ')}`
96
+ );
97
+ }
98
+ parsed.sort = value;
99
+ } else if (arg === '--limit') {
100
+ const value = next();
101
+ if (!NON_NEGATIVE_INT.test(value)) {
102
+ throw new Error(
103
+ `--limit must be a non-negative integer, got: ${value}`
104
+ );
105
+ }
106
+ parsed.limit = value;
107
+ } else if (arg === '--skip') {
108
+ const value = next();
109
+ if (!NON_NEGATIVE_INT.test(value)) {
110
+ throw new Error(`--skip must be a non-negative integer, got: ${value}`);
111
+ }
112
+ parsed.skip = value;
113
+ } else if (arg === '--filter') {
114
+ const value = next();
115
+ if (value.trim() === '') {
116
+ throw new Error('--filter must not be empty');
117
+ }
118
+ parsed.filter = value;
119
+ } else if (arg.startsWith('--')) {
120
+ throw new Error(`Unknown option: ${arg}\n${usage}`);
121
+ } else if (!url) {
122
+ url = arg;
123
+ } else {
124
+ throw new Error(`Unexpected positional argument: ${arg}\n${usage}`);
125
+ }
126
+ }
127
+ if (!url) throw new Error(usage);
128
+ return { url, ...parsed };
129
+ };
130
+
131
+ interface PageEntry {
132
+ user?: { id: string } | null;
133
+ lastUpdateUser?: { id: string } | null;
134
+ users?: { id: string }[];
135
+ }
136
+
137
+ interface ListPagesData {
138
+ pages?: PageEntry[];
139
+ }
140
+
141
+ export const listPages = async (args: string[]): Promise<void> => {
142
+ const { url, sort, limit, skip, filter } = parseArgs(args);
143
+ const { origin, projectName } = parseProjectUrl(url);
144
+ const params = new URLSearchParams();
145
+ if (sort) params.set('sort', sort);
146
+ if (limit) params.set('limit', limit);
147
+ if (skip) params.set('skip', skip);
148
+ if (filter !== undefined) {
149
+ params.set('filterType', 'icon');
150
+ params.set('filterValue', filter);
151
+ }
152
+ const queryString = params.toString();
153
+ const apiUrl = `${origin}/api/pages/${projectName}/${queryString ? `?${queryString}` : ''}`;
154
+ const credential = resolveCredential(origin, projectName);
155
+ const data = (await requestJson(apiUrl, { credential })) as ListPagesData;
156
+
157
+ const userMap = await fetchUserMap(origin, projectName);
158
+ for (const page of data.pages ?? []) {
159
+ enrichPageUsers(page, userMap);
160
+ enrichTimestampsOf(page as Record<string, unknown>);
161
+ }
162
+
163
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
164
+ };
@@ -0,0 +1,67 @@
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 listProjectsSummary = '自分が参加しているprojectの一覧を取得する';
7
+
8
+ export const listProjectsHelp = `listProjects - 自分が参加しているprojectの一覧を取得する
9
+
10
+ Usage:
11
+ cosense listProjects <origin>
12
+
13
+ 引数:
14
+ <origin> Cosenseサーバーのorigin(例: https://scrapbox.io)
15
+
16
+ 戻り値(top-levelの主なkey):
17
+ projects Array<Project> updated降順
18
+
19
+ Project の field:
20
+ id string Cosense内部のID
21
+ name string project名(URLパス)
22
+ displayName string 表示名
23
+ publicVisible boolean 公開projectかどうか
24
+ loginStrategies Array<string> 認証方法(google / microsoft / saml / email 等)
25
+ plan string? 課金plan名
26
+ additionalPlans { [planName]: boolean } 追加plan
27
+ alert object? project内に表示される通知
28
+ usersCount number メンバー数
29
+ isMember boolean 自分がメンバーか
30
+ billingId string? billing ID
31
+ created string 作成時刻
32
+ updated string 更新時刻
33
+ isOwner boolean? 自分がownerか
34
+ isAdmin boolean? 自分がadminか
35
+ adminsCount number? admin数
36
+ `;
37
+
38
+ export const listProjects = async (args: string[]): Promise<void> => {
39
+ const [originArg] = args;
40
+ if (!originArg) throw new Error('Usage: cosense listProjects <origin>');
41
+ if (args.length > 1) {
42
+ throw new Error(
43
+ `Unexpected positional argument: ${args[1]}\nUsage: cosense listProjects <origin>`
44
+ );
45
+ }
46
+ const origin = parseOrigin(originArg);
47
+ const credential = resolveUserCredential(origin);
48
+ if (!credential) {
49
+ throw new Error(
50
+ `No Personal Access Token found for ${origin}. Run \`cosense login ${origin}\` to authenticate.`
51
+ );
52
+ }
53
+ const data = (await requestJson(`${origin}/api/projects`, {
54
+ credential
55
+ })) as { projects?: Record<string, unknown>[] };
56
+ if (data.projects) {
57
+ data.projects.sort((a, b) => {
58
+ const ua = typeof a.updated === 'number' ? a.updated : 0;
59
+ const ub = typeof b.updated === 'number' ? b.updated : 0;
60
+ return ub - ua;
61
+ });
62
+ }
63
+ for (const project of data.projects ?? []) {
64
+ enrichTimestampsOf(project);
65
+ }
66
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
67
+ };
@@ -0,0 +1,102 @@
1
+ import { parseOrigin } from '../lib/parseUrl.ts';
2
+ import { settingsPath, writeUserToken } from '../lib/settings.ts';
3
+
4
+ export const loginSummary = 'Personal Access Tokenを設定ファイルに保存する';
5
+
6
+ export const loginHelp = `login - Personal Access Tokenを設定ファイルに保存する
7
+
8
+ Usage:
9
+ cosense login <origin>
10
+
11
+ 引数:
12
+ <origin> Cosenseサーバーのorigin(例: https://scrapbox.io)
13
+
14
+ 動作:
15
+ - PAT発行URL(<origin>/settings/personal-access-tokens)を出力し、PAT入力を求める
16
+ - 入力されたPATを ~/.cosense/settings.json の users[] に書き込む
17
+ - 同じoriginの既存entryは上書きされる
18
+ - 設定ファイルとディレクトリは存在しなければ作成する(dir 0700, file 0600)
19
+ - interactive terminal(TTY)でのみ動作する
20
+
21
+ 環境変数:
22
+ COSENSE_PAT 設定されていれば、ファイルに保存された認証情報より優先される
23
+ `;
24
+
25
+ const readMaskedLine = async (): Promise<string> => {
26
+ if (!process.stdin.isTTY) {
27
+ throw new Error(
28
+ 'cosense login must be run in an interactive terminal (TTY)'
29
+ );
30
+ }
31
+ process.stdin.setRawMode(true);
32
+ process.stdin.resume();
33
+ const chars: string[] = [];
34
+ const cleanup = (): void => {
35
+ process.stdin.setRawMode(false);
36
+ process.stdin.pause();
37
+ };
38
+ try {
39
+ for await (const chunk of process.stdin) {
40
+ const text = (chunk as Buffer).toString('utf8');
41
+ for (const ch of text) {
42
+ const code = ch.charCodeAt(0);
43
+ if (code === 13 || code === 10) {
44
+ process.stdout.write('\n');
45
+ return chars.join('');
46
+ }
47
+ if (code === 3 || code === 4) {
48
+ cleanup();
49
+ process.stdout.write('\n');
50
+ process.exit(130);
51
+ }
52
+ if (code === 127 || code === 8) {
53
+ if (chars.length > 0) {
54
+ chars.pop();
55
+ process.stdout.write('\b \b');
56
+ }
57
+ continue;
58
+ }
59
+ if (code < 32) continue;
60
+ chars.push(ch);
61
+ process.stdout.write('*');
62
+ }
63
+ }
64
+ } finally {
65
+ cleanup();
66
+ }
67
+ process.stdout.write('\n');
68
+ return chars.join('');
69
+ };
70
+
71
+ export const login = async (args: string[]): Promise<void> => {
72
+ const [originArg, ...rest] = args;
73
+ if (!originArg) throw new Error('Usage: cosense login <origin>');
74
+ if (rest.length > 0) {
75
+ throw new Error(`Unexpected argument: ${rest[0]}`);
76
+ }
77
+ const origin = parseOrigin(originArg);
78
+
79
+ if (!process.stdin.isTTY) {
80
+ throw new Error(
81
+ 'cosense login must be run in an interactive terminal (TTY)'
82
+ );
83
+ }
84
+
85
+ process.stdout.write(
86
+ [
87
+ `Personal Access Token (PAT) を発行する:`,
88
+ ` ${origin}/settings/personal-access-tokens`,
89
+ ``,
90
+ `発行したPATを以下に貼り付けてEnter(入力は * でマスクされる):`,
91
+ `PAT: `
92
+ ].join('\n')
93
+ );
94
+
95
+ const token = (await readMaskedLine()).trim();
96
+ if (!token) {
97
+ throw new Error('PAT is empty');
98
+ }
99
+
100
+ writeUserToken(origin, token);
101
+ process.stdout.write(`Saved PAT for ${origin} to ${settingsPath}\n`);
102
+ };