@helpfeel/cosense-cli 1.5.3 → 1.7.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 +1 -1
- package/src/cli.ts +20 -0
- package/src/commands/downloadFile.ts +90 -0
- package/src/commands/readFileInfo.ts +81 -0
- package/src/commands/search1hopLinks.ts +32 -10
- package/src/commands/search2hopLinks.ts +32 -10
- package/src/commands/searchFullText.ts +57 -7
- package/src/lib/annotateRelations.ts +4 -2
- package/src/lib/parseUrl.ts +24 -0
- package/src/lib/relatedPages.ts +4 -2
- package/src/lib/request.ts +97 -7
- package/src/lib/resolveFileCredential.ts +30 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -17,6 +17,11 @@ import {
|
|
|
17
17
|
browseRelatedPagesHelp,
|
|
18
18
|
browseRelatedPagesSummary
|
|
19
19
|
} from './commands/browseRelatedPages.ts';
|
|
20
|
+
import {
|
|
21
|
+
downloadFile,
|
|
22
|
+
downloadFileHelp,
|
|
23
|
+
downloadFileSummary
|
|
24
|
+
} from './commands/downloadFile.ts';
|
|
20
25
|
import {
|
|
21
26
|
list1hopLinks,
|
|
22
27
|
list1hopLinksHelp,
|
|
@@ -43,6 +48,11 @@ import {
|
|
|
43
48
|
previewEditHelp,
|
|
44
49
|
previewEditSummary
|
|
45
50
|
} from './commands/previewEdit.ts';
|
|
51
|
+
import {
|
|
52
|
+
readFileInfo,
|
|
53
|
+
readFileInfoHelp,
|
|
54
|
+
readFileInfoSummary
|
|
55
|
+
} from './commands/readFileInfo.ts';
|
|
46
56
|
import {
|
|
47
57
|
readPage,
|
|
48
58
|
readPageHelp,
|
|
@@ -119,6 +129,16 @@ const commands: Record<string, CommandSpec> = {
|
|
|
119
129
|
help: browseRelatedPagesHelp
|
|
120
130
|
},
|
|
121
131
|
readPage: { handler: readPage, summary: readPageSummary, help: readPageHelp },
|
|
132
|
+
readFileInfo: {
|
|
133
|
+
handler: readFileInfo,
|
|
134
|
+
summary: readFileInfoSummary,
|
|
135
|
+
help: readFileInfoHelp
|
|
136
|
+
},
|
|
137
|
+
downloadFile: {
|
|
138
|
+
handler: downloadFile,
|
|
139
|
+
summary: downloadFileSummary,
|
|
140
|
+
help: downloadFileHelp
|
|
141
|
+
},
|
|
122
142
|
readProjectMembers: {
|
|
123
143
|
handler: readProjectMembers,
|
|
124
144
|
summary: readProjectMembersSummary,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { parseFileUrl } from '../lib/parseUrl.ts';
|
|
2
|
+
import { downloadToFile } from '../lib/request.ts';
|
|
3
|
+
import { resolveFileCredential } from '../lib/resolveFileCredential.ts';
|
|
4
|
+
|
|
5
|
+
export const downloadFileSummary =
|
|
6
|
+
'ファイル本体をダウンロードしてローカルに保存する';
|
|
7
|
+
|
|
8
|
+
export const downloadFileHelp = `downloadFile - ファイル本体をダウンロードしてローカルに保存する
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
cosense downloadFile <fileUrl> <outputPath> [--thumbnail] [--project <projectUrl>]
|
|
12
|
+
|
|
13
|
+
引数:
|
|
14
|
+
<fileUrl> ファイルのURL(例: https://scrapbox.io/files/5f151efbacbb17001a58f120.png)。query/hashは付けない
|
|
15
|
+
<outputPath> 保存先ファイルパス。既存ファイルは上書きする。親ディレクトリは自動作成しない
|
|
16
|
+
|
|
17
|
+
オプション:
|
|
18
|
+
--thumbnail 縮小版(thumbnail)を取得する。thumbnailが存在しないファイル(jpeg/png以外)は原本が返る
|
|
19
|
+
--project <projectUrl> Service Account認証で取得する時に、ファイルが属するprojectのURLを指定する(例: https://scrapbox.io/example)。省略時はPersonal Access Tokenを使う
|
|
20
|
+
|
|
21
|
+
出力(JSON):
|
|
22
|
+
path string 保存したファイルの絶対パス
|
|
23
|
+
contentType string | null 取得したファイルのContent-Type
|
|
24
|
+
size number 保存したファイルのbyte数
|
|
25
|
+
|
|
26
|
+
例:
|
|
27
|
+
cosense downloadFile 'https://scrapbox.io/files/5f151efbacbb17001a58f120.png' ./image.png
|
|
28
|
+
cosense downloadFile 'https://scrapbox.io/files/5f151efbacbb17001a58f120.png' /tmp/thumb.png --thumbnail
|
|
29
|
+
|
|
30
|
+
HTTPエラー:
|
|
31
|
+
401/403: 認証・権限が無い。private projectのファイルはproject member権限が必要
|
|
32
|
+
404: fileIdに対応するファイルが存在しない
|
|
33
|
+
|
|
34
|
+
注記:
|
|
35
|
+
動画ファイルはサーバー側の制限により取得できない場合がある
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
interface ParsedArgs {
|
|
39
|
+
fileUrl: string;
|
|
40
|
+
outputPath: string;
|
|
41
|
+
thumbnail: boolean;
|
|
42
|
+
project?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const parseArgs = (args: string[]): ParsedArgs => {
|
|
46
|
+
const usage =
|
|
47
|
+
'Usage: cosense downloadFile <fileUrl> <outputPath> [--thumbnail] [--project <projectUrl>]';
|
|
48
|
+
let thumbnail = false;
|
|
49
|
+
let project: string | undefined;
|
|
50
|
+
const positional: string[] = [];
|
|
51
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
52
|
+
const arg = args[i] as string;
|
|
53
|
+
if (arg === '--thumbnail') {
|
|
54
|
+
thumbnail = true;
|
|
55
|
+
} else if (arg === '--project') {
|
|
56
|
+
if (project !== undefined) {
|
|
57
|
+
throw new Error(`--project specified multiple times\n${usage}`);
|
|
58
|
+
}
|
|
59
|
+
const value = args[i + 1];
|
|
60
|
+
if (value === undefined || value.startsWith('--')) {
|
|
61
|
+
throw new Error(`--project requires a value\n${usage}`);
|
|
62
|
+
}
|
|
63
|
+
project = value;
|
|
64
|
+
i += 1;
|
|
65
|
+
} else if (arg.startsWith('--')) {
|
|
66
|
+
throw new Error(`Unknown option: ${arg}\n${usage}`);
|
|
67
|
+
} else {
|
|
68
|
+
positional.push(arg);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (positional.length !== 2) {
|
|
72
|
+
throw new Error(usage);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
fileUrl: positional[0] as string,
|
|
76
|
+
outputPath: positional[1] as string,
|
|
77
|
+
thumbnail,
|
|
78
|
+
project
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const downloadFile = async (args: string[]): Promise<void> => {
|
|
83
|
+
const { fileUrl, outputPath, thumbnail, project } = parseArgs(args);
|
|
84
|
+
const { origin, fileId } = parseFileUrl(fileUrl);
|
|
85
|
+
const credential = resolveFileCredential(origin, project);
|
|
86
|
+
let requestUrl = `${origin}/files/${fileId}`;
|
|
87
|
+
if (thumbnail) requestUrl += '?type=thumbnail';
|
|
88
|
+
const result = await downloadToFile(requestUrl, outputPath, { credential });
|
|
89
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
90
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { parseFileUrl } from '../lib/parseUrl.ts';
|
|
2
|
+
import { requestJson } from '../lib/request.ts';
|
|
3
|
+
import { resolveFileCredential } from '../lib/resolveFileCredential.ts';
|
|
4
|
+
|
|
5
|
+
export const readFileInfoSummary =
|
|
6
|
+
'ファイルのメタデータと抽出済みテキストを取得する';
|
|
7
|
+
|
|
8
|
+
export const readFileInfoHelp = `readFileInfo - ファイルのメタデータと抽出済みテキストを取得する
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
cosense readFileInfo <fileUrl> [--project <projectUrl>]
|
|
12
|
+
|
|
13
|
+
引数:
|
|
14
|
+
<fileUrl> ファイルのURL(例: https://scrapbox.io/files/5f151efbacbb17001a58f120.pdf)。query/hashは付けない
|
|
15
|
+
|
|
16
|
+
オプション:
|
|
17
|
+
--project <projectUrl> Service Account認証で取得する時に、ファイルが属するprojectのURLを指定する(例: https://scrapbox.io/example)。省略時はPersonal Access Tokenを使う
|
|
18
|
+
|
|
19
|
+
戻り値(top-levelの主なkey):
|
|
20
|
+
id string ファイルID
|
|
21
|
+
projectName string ファイルが属するproject名
|
|
22
|
+
text string? ファイルから抽出されたテキスト(画像のOCR、PDFの本文等)。先頭10000文字まで
|
|
23
|
+
originalname string? アップロード時のファイル名
|
|
24
|
+
contentType string? ファイルのContent-Type
|
|
25
|
+
size number? ファイルのbyte数
|
|
26
|
+
|
|
27
|
+
例:
|
|
28
|
+
cosense readFileInfo 'https://scrapbox.io/files/5f151efbacbb17001a58f120.pdf'
|
|
29
|
+
|
|
30
|
+
絞り込み例(jqで欲しい部分だけ抜き出す):
|
|
31
|
+
抽出済みテキストだけ:
|
|
32
|
+
cosense readFileInfo <fileUrl> | jq -r '.text'
|
|
33
|
+
|
|
34
|
+
HTTPエラー:
|
|
35
|
+
401/403: 認証・権限が無い。private projectのファイルはproject member権限が必要
|
|
36
|
+
404: fileIdに対応するファイルが存在しない
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
interface ParsedArgs {
|
|
40
|
+
fileUrl: string;
|
|
41
|
+
project?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const parseArgs = (args: string[]): ParsedArgs => {
|
|
45
|
+
const usage =
|
|
46
|
+
'Usage: cosense readFileInfo <fileUrl> [--project <projectUrl>]';
|
|
47
|
+
let project: string | undefined;
|
|
48
|
+
const positional: string[] = [];
|
|
49
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
50
|
+
const arg = args[i] as string;
|
|
51
|
+
if (arg === '--project') {
|
|
52
|
+
if (project !== undefined) {
|
|
53
|
+
throw new Error(`--project specified multiple times\n${usage}`);
|
|
54
|
+
}
|
|
55
|
+
const value = args[i + 1];
|
|
56
|
+
if (value === undefined || value.startsWith('--')) {
|
|
57
|
+
throw new Error(`--project requires a value\n${usage}`);
|
|
58
|
+
}
|
|
59
|
+
project = value;
|
|
60
|
+
i += 1;
|
|
61
|
+
} else if (arg.startsWith('--')) {
|
|
62
|
+
throw new Error(`Unknown option: ${arg}\n${usage}`);
|
|
63
|
+
} else {
|
|
64
|
+
positional.push(arg);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (positional.length !== 1) {
|
|
68
|
+
throw new Error(usage);
|
|
69
|
+
}
|
|
70
|
+
return { fileUrl: positional[0] as string, project };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const readFileInfo = async (args: string[]): Promise<void> => {
|
|
74
|
+
const { fileUrl, project } = parseArgs(args);
|
|
75
|
+
const { origin, fileId } = parseFileUrl(fileUrl);
|
|
76
|
+
const credential = resolveFileCredential(origin, project);
|
|
77
|
+
const data = await requestJson(`${origin}/api/gcs/${fileId}/info`, {
|
|
78
|
+
credential
|
|
79
|
+
});
|
|
80
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
81
|
+
};
|
|
@@ -8,11 +8,17 @@ export const search1hopLinksSummary = '1-hop近傍を全文検索でフィルタ
|
|
|
8
8
|
export const search1hopLinksHelp = `search1hopLinks - 1-hop近傍を全文検索でフィルタする
|
|
9
9
|
|
|
10
10
|
Usage:
|
|
11
|
-
cosense search1hopLinks <pageUrl> <query>
|
|
11
|
+
cosense search1hopLinks <pageUrl> <query> [--or]
|
|
12
12
|
|
|
13
13
|
引数:
|
|
14
14
|
<pageUrl> 対象ページの完全なURL
|
|
15
|
-
<query>
|
|
15
|
+
<query> 全文検索クエリ(必須。空文字は弾かれる)
|
|
16
|
+
|
|
17
|
+
オプション:
|
|
18
|
+
--or 複数語のいずれかにマッチするページを返す(既定はAND)
|
|
19
|
+
|
|
20
|
+
例:
|
|
21
|
+
cosense search1hopLinks https://scrapbox.io/shokai/カレー "うどん ラーメン" --or
|
|
16
22
|
|
|
17
23
|
戻り値(top-levelの主なkey):
|
|
18
24
|
links1hop Array<Link> query を本文に含む1-hop近傍ページ
|
|
@@ -20,19 +26,35 @@ Usage:
|
|
|
20
26
|
|
|
21
27
|
各 Link の field(list1hopLinksに加えて):
|
|
22
28
|
search 検索ハイライト情報
|
|
23
|
-
|
|
24
|
-
検索の制約:
|
|
25
|
-
- OR検索不可
|
|
26
29
|
`;
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
const parseArgs = (
|
|
32
|
+
args: string[]
|
|
33
|
+
): { url: string; query: string; or: boolean } => {
|
|
34
|
+
const usage = 'Usage: cosense search1hopLinks <pageUrl> <query> [--or]';
|
|
35
|
+
let or = false;
|
|
36
|
+
const positional: string[] = [];
|
|
37
|
+
for (const arg of args) {
|
|
38
|
+
if (arg === '--or') {
|
|
39
|
+
or = true;
|
|
40
|
+
} else if (arg.startsWith('--')) {
|
|
41
|
+
throw new Error(`Unknown option: ${arg}\n${usage}`);
|
|
42
|
+
} else {
|
|
43
|
+
positional.push(arg);
|
|
44
|
+
}
|
|
32
45
|
}
|
|
46
|
+
const [url, query] = positional;
|
|
47
|
+
if (positional.length !== 2 || !url || !query || query.trim() === '') {
|
|
48
|
+
throw new Error(usage);
|
|
49
|
+
}
|
|
50
|
+
return { url, query, or };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const search1hopLinks = async (args: string[]): Promise<void> => {
|
|
54
|
+
const { url, query, or } = parseArgs(args);
|
|
33
55
|
const { origin, projectName } = parsePageUrl(url);
|
|
34
56
|
const [data, userMap] = await Promise.all([
|
|
35
|
-
fetchRelatedPagesWithRelations(url, query),
|
|
57
|
+
fetchRelatedPagesWithRelations(url, query, or),
|
|
36
58
|
fetchUserMap(origin, projectName)
|
|
37
59
|
]);
|
|
38
60
|
for (const page of (data as { links1hop?: unknown[] }).links1hop ?? []) {
|
|
@@ -8,11 +8,17 @@ export const search2hopLinksSummary = '2-hop近傍を全文検索でフィルタ
|
|
|
8
8
|
export const search2hopLinksHelp = `search2hopLinks - 2-hop近傍を全文検索でフィルタする
|
|
9
9
|
|
|
10
10
|
Usage:
|
|
11
|
-
cosense search2hopLinks <pageUrl> <query>
|
|
11
|
+
cosense search2hopLinks <pageUrl> <query> [--or]
|
|
12
12
|
|
|
13
13
|
引数:
|
|
14
14
|
<pageUrl> 対象ページの完全なURL
|
|
15
|
-
<query>
|
|
15
|
+
<query> 全文検索クエリ(必須。空文字は弾かれる)
|
|
16
|
+
|
|
17
|
+
オプション:
|
|
18
|
+
--or 複数語のいずれかにマッチするページを返す(既定はAND)
|
|
19
|
+
|
|
20
|
+
例:
|
|
21
|
+
cosense search2hopLinks https://scrapbox.io/shokai/カレー "うどん ラーメン" --or
|
|
16
22
|
|
|
17
23
|
戻り値(top-levelの主なkey):
|
|
18
24
|
links2hop Array<Link> query を本文に含む2-hop近傍ページ
|
|
@@ -20,19 +26,35 @@ Usage:
|
|
|
20
26
|
|
|
21
27
|
各 Link の field(list2hopLinksに加えて):
|
|
22
28
|
search 検索ハイライト情報
|
|
23
|
-
|
|
24
|
-
検索の制約:
|
|
25
|
-
- OR検索不可
|
|
26
29
|
`;
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
const parseArgs = (
|
|
32
|
+
args: string[]
|
|
33
|
+
): { url: string; query: string; or: boolean } => {
|
|
34
|
+
const usage = 'Usage: cosense search2hopLinks <pageUrl> <query> [--or]';
|
|
35
|
+
let or = false;
|
|
36
|
+
const positional: string[] = [];
|
|
37
|
+
for (const arg of args) {
|
|
38
|
+
if (arg === '--or') {
|
|
39
|
+
or = true;
|
|
40
|
+
} else if (arg.startsWith('--')) {
|
|
41
|
+
throw new Error(`Unknown option: ${arg}\n${usage}`);
|
|
42
|
+
} else {
|
|
43
|
+
positional.push(arg);
|
|
44
|
+
}
|
|
32
45
|
}
|
|
46
|
+
const [url, query] = positional;
|
|
47
|
+
if (positional.length !== 2 || !url || !query || query.trim() === '') {
|
|
48
|
+
throw new Error(usage);
|
|
49
|
+
}
|
|
50
|
+
return { url, query, or };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const search2hopLinks = async (args: string[]): Promise<void> => {
|
|
54
|
+
const { url, query, or } = parseArgs(args);
|
|
33
55
|
const { origin, projectName } = parsePageUrl(url);
|
|
34
56
|
const [data, userMap] = await Promise.all([
|
|
35
|
-
fetchRelatedPages(url, 2, query),
|
|
57
|
+
fetchRelatedPages(url, 2, query, or),
|
|
36
58
|
fetchUserMap(origin, projectName)
|
|
37
59
|
]);
|
|
38
60
|
for (const page of (data as { links2hop?: unknown[] }).links2hop ?? []) {
|
|
@@ -9,12 +9,19 @@ export const searchFullTextSummary = '本文全文を対象に検索する';
|
|
|
9
9
|
export const searchFullTextHelp = `searchFullText - 本文全文を対象に検索する
|
|
10
10
|
|
|
11
11
|
Usage:
|
|
12
|
-
cosense searchFullText <projectUrl> <query>
|
|
12
|
+
cosense searchFullText <projectUrl> <query> [--or] [--sort <pageRank|updated>]
|
|
13
13
|
|
|
14
14
|
引数:
|
|
15
15
|
<projectUrl> プロジェクトのURL(例: https://scrapbox.io/shokai/)
|
|
16
16
|
<query> 検索クエリ
|
|
17
17
|
|
|
18
|
+
オプション:
|
|
19
|
+
--or 複数語のいずれかにマッチするページを返す(既定はAND)
|
|
20
|
+
--sort <pageRank|updated> 並び順(既定はpageRank)
|
|
21
|
+
|
|
22
|
+
例:
|
|
23
|
+
cosense searchFullText https://scrapbox.io/shokai/ "デザイン 設計 design UI UX プロトタイプ" --or
|
|
24
|
+
|
|
18
25
|
戻り値(top-levelの主なkey):
|
|
19
26
|
projectName string プロジェクト名
|
|
20
27
|
searchQuery string 実行されたクエリ
|
|
@@ -63,13 +70,56 @@ interface SearchFullTextData {
|
|
|
63
70
|
}[];
|
|
64
71
|
}
|
|
65
72
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
interface ParsedArgs {
|
|
74
|
+
projectUrl: string;
|
|
75
|
+
query: string;
|
|
76
|
+
or: boolean;
|
|
77
|
+
sort?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const parseArgs = (args: string[]): ParsedArgs => {
|
|
81
|
+
const usage =
|
|
82
|
+
'Usage: cosense searchFullText <projectUrl> <query> [--or] [--sort <pageRank|updated>]';
|
|
83
|
+
let or = false;
|
|
84
|
+
let sort: string | undefined;
|
|
85
|
+
const positional: string[] = [];
|
|
86
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
87
|
+
const arg = args[i] as string;
|
|
88
|
+
if (arg === '--or') {
|
|
89
|
+
or = true;
|
|
90
|
+
} else if (arg === '--sort') {
|
|
91
|
+
const value = args[i + 1];
|
|
92
|
+
if (value === undefined || value.startsWith('--')) {
|
|
93
|
+
throw new Error(`--sort requires a value\n${usage}`);
|
|
94
|
+
}
|
|
95
|
+
if (value !== 'pageRank' && value !== 'updated') {
|
|
96
|
+
throw new Error(`--sort must be pageRank or updated\n${usage}`);
|
|
97
|
+
}
|
|
98
|
+
sort = value;
|
|
99
|
+
i += 1;
|
|
100
|
+
} else if (arg.startsWith('--')) {
|
|
101
|
+
throw new Error(`Unknown option: ${arg}\n${usage}`);
|
|
102
|
+
} else {
|
|
103
|
+
positional.push(arg);
|
|
104
|
+
}
|
|
70
105
|
}
|
|
71
|
-
|
|
72
|
-
|
|
106
|
+
if (positional.length !== 2 || !positional[1]) {
|
|
107
|
+
throw new Error(usage);
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
projectUrl: positional[0] as string,
|
|
111
|
+
query: positional[1] as string,
|
|
112
|
+
or,
|
|
113
|
+
sort
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const searchFullText = async (args: string[]): Promise<void> => {
|
|
118
|
+
const { projectUrl, query, or, sort } = parseArgs(args);
|
|
119
|
+
const { origin, projectName } = parseProjectUrl(projectUrl);
|
|
120
|
+
let apiUrl = `${origin}/api/pages/${projectName}/search/query?q=${encodeURIComponent(query)}`;
|
|
121
|
+
if (or) apiUrl += '&op=or';
|
|
122
|
+
if (sort) apiUrl += `&sort=${sort}`;
|
|
73
123
|
const credential = resolveCredential(origin, projectName);
|
|
74
124
|
const data = (await requestJson(apiUrl, {
|
|
75
125
|
credential
|
|
@@ -41,10 +41,12 @@ const computeRelation = (
|
|
|
41
41
|
|
|
42
42
|
export const fetchRelatedPagesWithRelations = async (
|
|
43
43
|
url: string,
|
|
44
|
-
query?: string
|
|
44
|
+
query?: string,
|
|
45
|
+
or?: boolean
|
|
45
46
|
): Promise<RelatedPagesData> => {
|
|
46
47
|
const { origin, projectName, encodedTitle } = parsePageUrl(url);
|
|
47
|
-
|
|
48
|
+
let queryParam = query ? `?search=${encodeURIComponent(query)}` : '';
|
|
49
|
+
if (or) queryParam += queryParam ? '&op=or' : '?op=or';
|
|
48
50
|
const startPageUrl = `${origin}/api/pages/v2/${projectName}/${encodedTitle}`;
|
|
49
51
|
const relatedUrl = `${startPageUrl}/links1hop${queryParam}`;
|
|
50
52
|
const credential = resolveCredential(origin, projectName);
|
package/src/lib/parseUrl.ts
CHANGED
|
@@ -45,6 +45,30 @@ export const parseProjectUrlStrict = (input: string): ProjectUrl => {
|
|
|
45
45
|
return { origin: u.origin, projectName: m[1] as string };
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
export interface FileUrl {
|
|
49
|
+
origin: string;
|
|
50
|
+
fileId: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const parseFileUrl = (input: string): FileUrl => {
|
|
54
|
+
const u = new URL(input);
|
|
55
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
56
|
+
throw new Error(`File URL must use http: or https: scheme: ${input}`);
|
|
57
|
+
}
|
|
58
|
+
if (u.search || u.hash) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`File URL must not have query/hash (use --thumbnail to fetch thumbnail), got: ${input}`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const m = u.pathname.match(/^\/files\/([0-9a-f]{24})(?:\..*)?$/);
|
|
64
|
+
if (!m) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`File URL must be https://<host>/files/<fileId>[.<ext>], got: ${input}`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return { origin: u.origin, fileId: m[1] as string };
|
|
70
|
+
};
|
|
71
|
+
|
|
48
72
|
export const parsePageUrl = (input: string): PageUrl => {
|
|
49
73
|
const u = new URL(input);
|
|
50
74
|
const m = u.pathname.match(/^\/([^/]+)\/(.+?)\/?$/);
|
package/src/lib/relatedPages.ts
CHANGED
|
@@ -5,10 +5,12 @@ import { resolveCredential } from './settings.ts';
|
|
|
5
5
|
export const fetchRelatedPages = async (
|
|
6
6
|
url: string,
|
|
7
7
|
hop: 1 | 2,
|
|
8
|
-
query?: string
|
|
8
|
+
query?: string,
|
|
9
|
+
or?: boolean
|
|
9
10
|
): Promise<unknown> => {
|
|
10
11
|
const { origin, projectName, encodedTitle } = parsePageUrl(url);
|
|
11
|
-
|
|
12
|
+
let queryParam = query ? `?search=${encodeURIComponent(query)}` : '';
|
|
13
|
+
if (or) queryParam += queryParam ? '&op=or' : '?op=or';
|
|
12
14
|
const apiUrl = `${origin}/api/pages/v2/${projectName}/${encodedTitle}/links${hop}hop${queryParam}`;
|
|
13
15
|
const credential = resolveCredential(origin, projectName);
|
|
14
16
|
return requestJson(apiUrl, { credential });
|
package/src/lib/request.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import { createWriteStream } from 'node:fs';
|
|
2
|
+
import { rename, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import type { Credential } from './settings.ts';
|
|
7
|
+
|
|
1
8
|
export class HttpError extends Error {
|
|
2
9
|
readonly status: number;
|
|
3
10
|
readonly statusText: string;
|
|
@@ -31,20 +38,16 @@ export class HttpError extends Error {
|
|
|
31
38
|
}
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
import type { Credential } from './settings.ts';
|
|
35
|
-
|
|
36
41
|
interface RequestOptions {
|
|
37
42
|
credential?: Credential;
|
|
38
43
|
method?: 'GET' | 'POST';
|
|
39
44
|
body?: unknown;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
): Promise<unknown> => {
|
|
47
|
+
const buildCredentialHeaders = (
|
|
48
|
+
credential?: Credential
|
|
49
|
+
): Record<string, string> => {
|
|
46
50
|
const headers: Record<string, string> = {};
|
|
47
|
-
const credential = options?.credential;
|
|
48
51
|
if (credential) {
|
|
49
52
|
if (credential.type === 'serviceAccount') {
|
|
50
53
|
headers['x-service-account-access-key'] = credential.value;
|
|
@@ -52,6 +55,14 @@ export const requestJson = async (
|
|
|
52
55
|
headers['x-personal-access-token'] = credential.value;
|
|
53
56
|
}
|
|
54
57
|
}
|
|
58
|
+
return headers;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const requestJson = async (
|
|
62
|
+
url: string,
|
|
63
|
+
options?: RequestOptions
|
|
64
|
+
): Promise<unknown> => {
|
|
65
|
+
const headers = buildCredentialHeaders(options?.credential);
|
|
55
66
|
const method = options?.method ?? 'GET';
|
|
56
67
|
const init: RequestInit = { method, headers };
|
|
57
68
|
if (options?.body !== undefined) {
|
|
@@ -70,3 +81,82 @@ export const requestJson = async (
|
|
|
70
81
|
}
|
|
71
82
|
return res.json();
|
|
72
83
|
};
|
|
84
|
+
|
|
85
|
+
export interface DownloadResult {
|
|
86
|
+
path: string;
|
|
87
|
+
contentType: string | null;
|
|
88
|
+
size: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
92
|
+
|
|
93
|
+
export const downloadToFile = async (
|
|
94
|
+
url: string,
|
|
95
|
+
outputPath: string,
|
|
96
|
+
options?: { credential?: Credential }
|
|
97
|
+
): Promise<DownloadResult> => {
|
|
98
|
+
const absPath = resolve(outputPath);
|
|
99
|
+
const targetStat = await stat(absPath).catch(() => null);
|
|
100
|
+
if (targetStat?.isDirectory()) {
|
|
101
|
+
throw new Error(`<outputPath> is a directory: ${outputPath}`);
|
|
102
|
+
}
|
|
103
|
+
const parentDir = dirname(absPath);
|
|
104
|
+
const parentStat = await stat(parentDir).catch(() => null);
|
|
105
|
+
if (!parentStat?.isDirectory()) {
|
|
106
|
+
throw new Error(`Parent directory does not exist: ${parentDir}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const headers = buildCredentialHeaders(options?.credential);
|
|
110
|
+
let res = await fetch(url, { headers, redirect: 'manual' });
|
|
111
|
+
if (REDIRECT_STATUSES.has(res.status)) {
|
|
112
|
+
const location = res.headers.get('location');
|
|
113
|
+
await res.body?.cancel();
|
|
114
|
+
if (!location) {
|
|
115
|
+
throw new Error(`HTTP ${res.status} without Location header: ${url}`);
|
|
116
|
+
}
|
|
117
|
+
const redirectUrl = new URL(location, url);
|
|
118
|
+
// credential headerは別originのredirect先に転送しない
|
|
119
|
+
res = await fetch(redirectUrl);
|
|
120
|
+
if (!res.ok) {
|
|
121
|
+
await res.body?.cancel();
|
|
122
|
+
throw new Error(
|
|
123
|
+
`HTTP ${res.status} ${res.statusText} from ${redirectUrl.origin}${redirectUrl.pathname}`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
} else if (!res.ok) {
|
|
127
|
+
const body = await res.text().catch(() => '');
|
|
128
|
+
throw new HttpError({
|
|
129
|
+
status: res.status,
|
|
130
|
+
statusText: res.statusText,
|
|
131
|
+
url,
|
|
132
|
+
body
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (!res.body) {
|
|
136
|
+
throw new Error(`Empty response body: ${url}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const tmpPath = join(
|
|
140
|
+
parentDir,
|
|
141
|
+
`.${basename(absPath)}.${process.pid}.${Math.random().toString(36).slice(2)}.part`
|
|
142
|
+
);
|
|
143
|
+
try {
|
|
144
|
+
await pipeline(
|
|
145
|
+
Readable.fromWeb(res.body),
|
|
146
|
+
createWriteStream(tmpPath, { flags: 'wx' })
|
|
147
|
+
);
|
|
148
|
+
const { size } = await stat(tmpPath);
|
|
149
|
+
await rename(tmpPath, absPath);
|
|
150
|
+
return {
|
|
151
|
+
path: absPath,
|
|
152
|
+
contentType: res.headers.get('content-type'),
|
|
153
|
+
size
|
|
154
|
+
};
|
|
155
|
+
} catch (err) {
|
|
156
|
+
await rm(tmpPath, { force: true }).catch(() => {});
|
|
157
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
158
|
+
throw new Error(
|
|
159
|
+
`Failed to write ${absPath}: ${message.replaceAll(tmpPath, absPath)}`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { parseProjectUrlStrict } from './parseUrl.ts';
|
|
2
|
+
import {
|
|
3
|
+
resolveCredential,
|
|
4
|
+
resolveUserCredential,
|
|
5
|
+
type Credential
|
|
6
|
+
} from './settings.ts';
|
|
7
|
+
|
|
8
|
+
// fileUrlにはproject名が含まれないため、Service Account(project単位)を
|
|
9
|
+
// 使う時だけ--projectでprojectUrlを受け取って解決する
|
|
10
|
+
export const resolveFileCredential = (
|
|
11
|
+
fileOrigin: string,
|
|
12
|
+
projectUrl: string | undefined
|
|
13
|
+
): Credential | undefined => {
|
|
14
|
+
if (projectUrl === undefined) {
|
|
15
|
+
return resolveUserCredential(fileOrigin);
|
|
16
|
+
}
|
|
17
|
+
const { origin, projectName } = parseProjectUrlStrict(projectUrl);
|
|
18
|
+
if (origin !== fileOrigin) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`--project origin mismatch: ${origin} (--project) vs ${fileOrigin} (file URL)`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
const credential = resolveCredential(origin, projectName);
|
|
24
|
+
if (!credential) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`No credential found for --project ${projectUrl}. Run \`cosense login ${projectUrl}\` to authenticate.`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return credential;
|
|
30
|
+
};
|