@jackwener/opencli 1.8.0 → 1.8.1
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 +8 -49
- package/README.zh-CN.md +8 -52
- package/cli-manifest.json +1796 -191
- package/clis/_atlassian/shared.js +577 -0
- package/clis/_atlassian/shared.test.js +170 -0
- package/clis/bilibili/comment.js +125 -0
- package/clis/bilibili/comment.test.js +153 -0
- package/clis/bilibili/comments.js +116 -21
- package/clis/bilibili/comments.test.js +77 -18
- package/clis/bilibili/subtitle.js +76 -31
- package/clis/bilibili/subtitle.test.js +156 -9
- package/clis/bilibili/utils.js +63 -5
- package/clis/bilibili/utils.test.js +45 -1
- package/clis/chess/analyze.js +35 -0
- package/clis/chess/analyze.test.js +79 -0
- package/clis/chess/game.js +114 -0
- package/clis/chess/game.test.js +178 -0
- package/clis/chess/games.js +67 -0
- package/clis/chess/games.test.js +164 -0
- package/clis/chess/stats.js +32 -0
- package/clis/chess/stats.test.js +79 -0
- package/clis/chess/utils.js +170 -0
- package/clis/chess/utils.test.js +230 -0
- package/clis/confluence/commands.test.js +195 -0
- package/clis/confluence/create.js +39 -0
- package/clis/confluence/page.js +23 -0
- package/clis/confluence/search.js +34 -0
- package/clis/confluence/shared.js +173 -0
- package/clis/confluence/update.js +38 -0
- package/clis/douyin/hashtag.js +84 -23
- package/clis/douyin/hashtag.test.js +113 -0
- package/clis/geogebra/add-circle.js +46 -0
- package/clis/geogebra/add-line.js +35 -0
- package/clis/geogebra/add-point.js +27 -0
- package/clis/geogebra/add-polygon.js +25 -0
- package/clis/geogebra/eval.js +35 -0
- package/clis/geogebra/geogebra.test.js +175 -0
- package/clis/geogebra/hexagon.js +62 -0
- package/clis/geogebra/info.js +72 -0
- package/clis/geogebra/list.js +35 -0
- package/clis/geogebra/triangle.js +60 -0
- package/clis/geogebra/utils.js +271 -0
- package/clis/jira/attachments.js +28 -0
- package/clis/jira/commands.test.js +287 -0
- package/clis/jira/comments.js +28 -0
- package/clis/jira/issue.js +28 -0
- package/clis/jira/links.js +28 -0
- package/clis/jira/search.js +47 -0
- package/clis/jira/shared.js +256 -0
- package/clis/linkedin/job-detail.js +167 -0
- package/clis/linkedin/job-detail.test.js +38 -0
- package/clis/linkedin/jobs-preferences.js +113 -0
- package/clis/linkedin/jobs-preferences.test.js +43 -0
- package/clis/linkedin/post-analytics.js +74 -0
- package/clis/linkedin/post-analytics.test.js +40 -0
- package/clis/linkedin/posts-core.js +241 -0
- package/clis/linkedin/posts.js +22 -0
- package/clis/linkedin/posts.test.js +40 -0
- package/clis/linkedin/profile-analytics.js +104 -0
- package/clis/linkedin/profile-analytics.test.js +67 -0
- package/clis/linkedin/profile-experience.js +671 -0
- package/clis/linkedin/profile-experience.test.js +152 -0
- package/clis/linkedin/profile-projects.js +311 -0
- package/clis/linkedin/profile-projects.test.js +111 -0
- package/clis/linkedin/profile-read.js +148 -0
- package/clis/linkedin/profile-read.test.js +77 -0
- package/clis/linkedin/services-read.js +213 -0
- package/clis/linkedin/services-read.test.js +105 -0
- package/clis/linkedin/shared.js +124 -0
- package/clis/linkedin/timeline.js +14 -7
- package/clis/notebooklm/add-source.js +269 -0
- package/clis/notebooklm/add-source.test.js +97 -0
- package/clis/notebooklm/create.js +76 -0
- package/clis/notebooklm/create.test.js +58 -0
- package/clis/notebooklm/generate-audio.js +91 -0
- package/clis/notebooklm/generate-audio.test.js +63 -0
- package/clis/notebooklm/generate-slides.js +106 -0
- package/clis/notebooklm/generate-slides.test.js +75 -0
- package/clis/notebooklm/open.test.js +10 -10
- package/clis/notebooklm/rpc.js +20 -6
- package/clis/notebooklm/rpc.test.js +27 -1
- package/clis/notebooklm/utils.js +100 -24
- package/clis/notebooklm/utils.test.js +60 -1
- package/clis/notebooklm/write-note.js +103 -0
- package/clis/notebooklm/write-note.test.js +70 -0
- package/clis/pixiv/detail.js +41 -34
- package/clis/pixiv/detail.test.js +93 -0
- package/clis/pixiv/user.js +36 -31
- package/clis/pixiv/user.test.js +100 -0
- package/clis/pixiv/utils.js +56 -7
- package/clis/suno/generate.js +5 -0
- package/clis/suno/generate.test.js +9 -0
- package/clis/suno/status.js +3 -2
- package/clis/suno/utils.js +33 -24
- package/clis/suno/utils.test.js +106 -0
- package/clis/twitter/followers.js +6 -2
- package/clis/twitter/followers.test.js +19 -1
- package/clis/twitter/following.js +14 -5
- package/clis/twitter/following.test.js +29 -0
- package/clis/twitter/likes.js +12 -4
- package/clis/twitter/likes.test.js +26 -1
- package/clis/twitter/list-add.js +1 -1
- package/clis/twitter/list-remove.js +1 -1
- package/clis/twitter/notifications.js +4 -4
- package/clis/twitter/post.js +62 -4
- package/clis/twitter/post.test.js +35 -3
- package/clis/twitter/profile.js +81 -28
- package/clis/twitter/profile.test.js +113 -2
- package/clis/twitter/quote.js +9 -4
- package/clis/twitter/reply.js +13 -10
- package/clis/twitter/reply.test.js +41 -0
- package/clis/twitter/search.js +1 -1
- package/clis/twitter/search.test.js +35 -0
- package/clis/twitter/shared.js +11 -0
- package/clis/twitter/shared.test.js +37 -1
- package/clis/twitter/utils.js +53 -16
- package/clis/upwork/detail.js +132 -0
- package/clis/upwork/feed.js +109 -0
- package/clis/upwork/search.js +115 -0
- package/clis/upwork/upwork.test.js +566 -0
- package/clis/upwork/utils.js +323 -0
- package/clis/weread/book-search.js +438 -0
- package/clis/weread/book-search.test.js +242 -0
- package/clis/weread/search-regression.test.js +80 -0
- package/clis/weread/search.js +17 -2
- package/clis/xiaohongshu/creator-note-detail.js +165 -28
- package/clis/xiaohongshu/creator-note-detail.test.js +186 -37
- package/clis/xiaohongshu/creator-notes.js +251 -2
- package/clis/xiaohongshu/creator-notes.test.js +79 -2
- package/clis/xiaohongshu/download.js +97 -39
- package/clis/xiaohongshu/download.test.js +201 -0
- package/clis/zhihu/answer-comments.js +2 -21
- package/clis/zhihu/answer-detail.js +2 -31
- package/clis/zhihu/collection.js +2 -14
- package/clis/zhihu/collection.test.js +4 -3
- package/clis/zhihu/question.js +1 -9
- package/clis/zhihu/question.test.js +2 -2
- package/clis/zhihu/search.js +1 -12
- package/clis/zhihu/search.test.js +2 -2
- package/clis/zhihu/text.js +29 -0
- package/clis/zhihu/text.test.js +24 -0
- package/dist/src/browser/network-cache.js +13 -1
- package/dist/src/browser/network-cache.test.js +17 -0
- package/dist/src/download/index.js +13 -1
- package/dist/src/download/index.test.js +23 -1
- package/dist/src/download/media-download.test.js +3 -1
- package/dist/src/download/progress.js +2 -2
- package/dist/src/download/progress.test.js +12 -1
- package/dist/src/output.js +11 -1
- package/dist/src/output.test.js +6 -0
- package/dist/src/registry.js +1 -0
- package/dist/src/registry.test.js +11 -0
- package/package.json +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { requireExecute, requirePayloadObject, requireString } from '../_atlassian/shared.js';
|
|
3
|
+
import { confluenceConfig, createPagePayload, normalizeConfluencePage, readPageBodyFile } from './shared.js';
|
|
4
|
+
import { atlassianRequest } from '../_atlassian/shared.js';
|
|
5
|
+
|
|
6
|
+
cli({
|
|
7
|
+
site: 'confluence',
|
|
8
|
+
name: 'create',
|
|
9
|
+
access: 'write',
|
|
10
|
+
description: 'Create a Confluence page from Markdown or storage XHTML',
|
|
11
|
+
domain: 'atlassian.net',
|
|
12
|
+
strategy: Strategy.PUBLIC,
|
|
13
|
+
browser: false,
|
|
14
|
+
args: [
|
|
15
|
+
{ name: 'space', type: 'string', required: true, help: 'Cloud space id, or Data Center space key' },
|
|
16
|
+
{ name: 'title', type: 'string', required: true, help: 'Page title' },
|
|
17
|
+
{ name: 'file', type: 'string', required: true, help: 'Markdown file path' },
|
|
18
|
+
{ name: 'parent', type: 'string', help: 'Optional parent page id' },
|
|
19
|
+
{ name: 'representation', type: 'string', default: 'markdown', choices: ['markdown', 'storage'], help: 'Input file format' },
|
|
20
|
+
{ name: 'execute', type: 'boolean', help: 'Actually create the remote page' },
|
|
21
|
+
],
|
|
22
|
+
columns: ['status', 'id', 'title', 'spaceId', 'spaceKey', 'version', 'url'],
|
|
23
|
+
func: async (args) => {
|
|
24
|
+
requireExecute(args, 'confluence create');
|
|
25
|
+
requireString(args.space, 'Confluence space');
|
|
26
|
+
requireString(args.title, 'Confluence page title');
|
|
27
|
+
const storage = await readPageBodyFile(args);
|
|
28
|
+
const config = confluenceConfig();
|
|
29
|
+
const payload = createPagePayload(config, args, storage);
|
|
30
|
+
const path = config.deployment === 'cloud' ? '/api/v2/pages' : '/rest/api/content';
|
|
31
|
+
const page = requirePayloadObject(await atlassianRequest(config, path, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
body: payload,
|
|
34
|
+
label: 'confluence create',
|
|
35
|
+
}), 'confluence create');
|
|
36
|
+
const normalized = normalizeConfluencePage(page, config);
|
|
37
|
+
return [{ ...normalized, pageStatus: normalized.status, status: 'created' }];
|
|
38
|
+
},
|
|
39
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { requireString } from '../_atlassian/shared.js';
|
|
3
|
+
import { confluenceConfig, getPage, normalizeConfluencePage } from './shared.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'confluence',
|
|
7
|
+
name: 'page',
|
|
8
|
+
access: 'read',
|
|
9
|
+
description: 'Confluence page by id with storage and Markdown body',
|
|
10
|
+
domain: 'atlassian.net',
|
|
11
|
+
strategy: Strategy.PUBLIC,
|
|
12
|
+
browser: false,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'id', positional: true, required: true, help: 'Confluence page id' },
|
|
15
|
+
],
|
|
16
|
+
columns: ['id', 'title', 'status', 'spaceId', 'spaceKey', 'version', 'url'],
|
|
17
|
+
func: async (args) => {
|
|
18
|
+
const config = confluenceConfig();
|
|
19
|
+
const id = requireString(args.id, 'Confluence page id');
|
|
20
|
+
const page = await getPage(config, id);
|
|
21
|
+
return [normalizeConfluencePage(page, config)];
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { atlassianRequest, parseLimit, queryString, requireNonEmptyRows, requireString } from '../_atlassian/shared.js';
|
|
3
|
+
import { confluenceConfig, confluenceResults, normalizeSearchResult, withSpaceCql } from './shared.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'confluence',
|
|
7
|
+
name: 'search',
|
|
8
|
+
access: 'read',
|
|
9
|
+
description: 'Search Confluence content with CQL',
|
|
10
|
+
domain: 'atlassian.net',
|
|
11
|
+
strategy: Strategy.PUBLIC,
|
|
12
|
+
browser: false,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'cql', positional: true, required: true, help: 'CQL query, e.g. "type = page and title ~ \\"RCA\\""' },
|
|
15
|
+
{ name: 'space', type: 'string', help: 'Limit search to a Confluence space key' },
|
|
16
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max results to return (1-100)' },
|
|
17
|
+
],
|
|
18
|
+
columns: ['id', 'title', 'type', 'spaceKey', 'status', 'lastModified', 'url'],
|
|
19
|
+
func: async (args) => {
|
|
20
|
+
const config = confluenceConfig();
|
|
21
|
+
const cql = withSpaceCql(requireString(args.cql, 'CQL'), args.space);
|
|
22
|
+
const limit = parseLimit(args.limit, 20, 100, 'confluence limit');
|
|
23
|
+
// CQL search is still exposed through Confluence REST v1 for Cloud;
|
|
24
|
+
// page CRUD uses v2 where available.
|
|
25
|
+
const path = `/rest/api/search${queryString({ cql, limit })}`;
|
|
26
|
+
const data = await atlassianRequest(config, path, { label: 'confluence search' });
|
|
27
|
+
const results = confluenceResults(data, 'confluence search');
|
|
28
|
+
return requireNonEmptyRows(
|
|
29
|
+
results.map((result) => normalizeSearchResult(result, config)),
|
|
30
|
+
'confluence search',
|
|
31
|
+
`No Confluence content matched "${cql}".`,
|
|
32
|
+
);
|
|
33
|
+
},
|
|
34
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import {
|
|
2
|
+
atlassianRequest,
|
|
3
|
+
getConfluenceConfig,
|
|
4
|
+
htmlToMarkdown,
|
|
5
|
+
markdownToConfluenceStorage,
|
|
6
|
+
queryString,
|
|
7
|
+
requirePayloadArray,
|
|
8
|
+
requirePayloadObject,
|
|
9
|
+
requirePayloadString,
|
|
10
|
+
readUtf8File,
|
|
11
|
+
requireString,
|
|
12
|
+
} from '../_atlassian/shared.js';
|
|
13
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
14
|
+
|
|
15
|
+
export function confluenceConfig() {
|
|
16
|
+
return getConfluenceConfig();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function confluenceUrl(config, link) {
|
|
20
|
+
if (!link) return '';
|
|
21
|
+
if (/^https?:\/\//i.test(link)) return link;
|
|
22
|
+
return `${config.baseUrl}${link.startsWith('/') ? link : `/${link}`}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pageStorageBody(page) {
|
|
26
|
+
return page?.body?.storage?.value
|
|
27
|
+
?? page?.body?.view?.value
|
|
28
|
+
?? '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalizeConfluencePage(page, config) {
|
|
32
|
+
const row = requirePayloadObject(page, 'confluence page');
|
|
33
|
+
const id = requirePayloadString(row.id, 'page id', 'confluence page');
|
|
34
|
+
const title = requirePayloadString(row.title, 'title', 'confluence page');
|
|
35
|
+
const storage = pageStorageBody(row);
|
|
36
|
+
const version = row.version?.number != null ? Number(row.version.number) : undefined;
|
|
37
|
+
const links = row._links && typeof row._links === 'object' && !Array.isArray(row._links) ? row._links : {};
|
|
38
|
+
const webui = links.webui ?? links.tinyui ?? '';
|
|
39
|
+
return {
|
|
40
|
+
id,
|
|
41
|
+
title,
|
|
42
|
+
status: String(row.status ?? ''),
|
|
43
|
+
spaceId: row.spaceId != null ? String(row.spaceId) : undefined,
|
|
44
|
+
spaceKey: row.space?.key ? String(row.space.key) : undefined,
|
|
45
|
+
parentId: row.parentId != null ? String(row.parentId) : undefined,
|
|
46
|
+
version,
|
|
47
|
+
createdAt: row.createdAt ? String(row.createdAt) : undefined,
|
|
48
|
+
updatedAt: row.version?.createdAt ?? row.version?.when ?? undefined,
|
|
49
|
+
url: confluenceUrl(config, webui),
|
|
50
|
+
body: {
|
|
51
|
+
storage,
|
|
52
|
+
markdown: htmlToMarkdown(storage),
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function getPage(config, pageId) {
|
|
58
|
+
if (config.deployment === 'cloud') {
|
|
59
|
+
const page = await atlassianRequest(config, `/api/v2/pages/${encodeURIComponent(pageId)}${queryString({ 'body-format': 'storage' })}`, {
|
|
60
|
+
label: `confluence page ${pageId}`,
|
|
61
|
+
});
|
|
62
|
+
return requirePayloadObject(page, `confluence page ${pageId}`);
|
|
63
|
+
}
|
|
64
|
+
const page = await atlassianRequest(config, `/rest/api/content/${encodeURIComponent(pageId)}${queryString({ expand: 'body.storage,version,space,ancestors' })}`, {
|
|
65
|
+
label: `confluence page ${pageId}`,
|
|
66
|
+
});
|
|
67
|
+
return requirePayloadObject(page, `confluence page ${pageId}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function readPageBodyFile(args) {
|
|
71
|
+
const text = await readUtf8File(args.file);
|
|
72
|
+
if (args.representation === 'storage') return text;
|
|
73
|
+
return markdownToConfluenceStorage(text);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createPagePayload(config, args, storage) {
|
|
77
|
+
const title = requireString(args.title, 'Confluence page title');
|
|
78
|
+
const space = requireString(args.space, 'Confluence space');
|
|
79
|
+
if (config.deployment === 'cloud') {
|
|
80
|
+
return {
|
|
81
|
+
spaceId: space,
|
|
82
|
+
status: 'current',
|
|
83
|
+
title,
|
|
84
|
+
...(args.parent ? { parentId: String(args.parent) } : {}),
|
|
85
|
+
body: { representation: 'storage', value: storage },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
type: 'page',
|
|
90
|
+
status: 'current',
|
|
91
|
+
title,
|
|
92
|
+
space: { key: space },
|
|
93
|
+
...(args.parent ? { ancestors: [{ id: String(args.parent) }] } : {}),
|
|
94
|
+
body: { storage: { representation: 'storage', value: storage } },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function updatePagePayload(config, current, args, storage) {
|
|
99
|
+
const page = requirePayloadObject(current, 'confluence current page');
|
|
100
|
+
const id = requirePayloadString(page.id, 'page id', 'confluence current page');
|
|
101
|
+
const title = args.title ? requireString(args.title, 'Confluence page title') : requirePayloadString(page.title, 'title', 'confluence current page');
|
|
102
|
+
const currentVersion = Number(page.version?.number);
|
|
103
|
+
if (!Number.isSafeInteger(currentVersion) || currentVersion < 1) {
|
|
104
|
+
throw new CommandExecutionError('confluence update could not determine the current page version.');
|
|
105
|
+
}
|
|
106
|
+
const nextVersion = currentVersion + 1;
|
|
107
|
+
if (config.deployment === 'cloud') {
|
|
108
|
+
return {
|
|
109
|
+
id,
|
|
110
|
+
status: 'current',
|
|
111
|
+
title,
|
|
112
|
+
body: { representation: 'storage', value: storage },
|
|
113
|
+
version: {
|
|
114
|
+
number: nextVersion,
|
|
115
|
+
...(args['version-message'] ? { message: String(args['version-message']) } : {}),
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
id,
|
|
121
|
+
type: 'page',
|
|
122
|
+
status: 'current',
|
|
123
|
+
title,
|
|
124
|
+
body: { storage: { representation: 'storage', value: storage } },
|
|
125
|
+
version: {
|
|
126
|
+
number: nextVersion,
|
|
127
|
+
...(args['version-message'] ? { message: String(args['version-message']) } : {}),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function normalizeSearchResult(result, config) {
|
|
133
|
+
const row = requirePayloadObject(result, 'confluence search result');
|
|
134
|
+
const content = row.content ?? row;
|
|
135
|
+
const contentObject = requirePayloadObject(content, 'confluence search result content');
|
|
136
|
+
const id = requirePayloadString(contentObject.id, 'content id', 'confluence search result');
|
|
137
|
+
const title = requirePayloadString(row.title ?? contentObject.title, 'title', 'confluence search result');
|
|
138
|
+
const space = row.space ?? contentObject.space ?? {};
|
|
139
|
+
return {
|
|
140
|
+
id,
|
|
141
|
+
title,
|
|
142
|
+
type: String(contentObject.type ?? row.entityType ?? ''),
|
|
143
|
+
spaceKey: String(space?.key ?? ''),
|
|
144
|
+
status: String(contentObject.status ?? ''),
|
|
145
|
+
lastModified: String(row.lastModified ?? contentObject.version?.when ?? contentObject.version?.createdAt ?? ''),
|
|
146
|
+
url: confluenceUrl(config, row.url ?? contentObject._links?.webui ?? ''),
|
|
147
|
+
excerpt: row.excerpt ? htmlToMarkdown(row.excerpt) : '',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function confluenceResults(data, label) {
|
|
152
|
+
const payload = requirePayloadObject(data, label);
|
|
153
|
+
return requirePayloadArray(payload.results, label);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function withSpaceCql(cql, space) {
|
|
157
|
+
const q = String(cql ?? '').trim();
|
|
158
|
+
const s = String(space ?? '').trim();
|
|
159
|
+
if (!s) return q;
|
|
160
|
+
const escaped = s.replace(/"/g, '\\"');
|
|
161
|
+
if (!q) return `space = "${escaped}"`;
|
|
162
|
+
return `space = "${escaped}" and (${q})`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const __test__ = {
|
|
166
|
+
createPagePayload,
|
|
167
|
+
getPage,
|
|
168
|
+
normalizeConfluencePage,
|
|
169
|
+
normalizeSearchResult,
|
|
170
|
+
readPageBodyFile,
|
|
171
|
+
updatePagePayload,
|
|
172
|
+
withSpaceCql,
|
|
173
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { atlassianRequest, requireExecute, requirePayloadObject, requireString } from '../_atlassian/shared.js';
|
|
3
|
+
import { confluenceConfig, getPage, normalizeConfluencePage, readPageBodyFile, updatePagePayload } from './shared.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'confluence',
|
|
7
|
+
name: 'update',
|
|
8
|
+
access: 'write',
|
|
9
|
+
description: 'Update a Confluence page body from Markdown or storage XHTML',
|
|
10
|
+
domain: 'atlassian.net',
|
|
11
|
+
strategy: Strategy.PUBLIC,
|
|
12
|
+
browser: false,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'id', positional: true, required: true, help: 'Confluence page id' },
|
|
15
|
+
{ name: 'file', type: 'string', required: true, help: 'Markdown file path' },
|
|
16
|
+
{ name: 'title', type: 'string', help: 'Optional replacement title; defaults to current title' },
|
|
17
|
+
{ name: 'version-message', type: 'string', help: 'Confluence version message' },
|
|
18
|
+
{ name: 'representation', type: 'string', default: 'markdown', choices: ['markdown', 'storage'], help: 'Input file format' },
|
|
19
|
+
{ name: 'execute', type: 'boolean', help: 'Actually update the remote page' },
|
|
20
|
+
],
|
|
21
|
+
columns: ['status', 'id', 'title', 'spaceId', 'spaceKey', 'version', 'url'],
|
|
22
|
+
func: async (args) => {
|
|
23
|
+
requireExecute(args, 'confluence update');
|
|
24
|
+
const config = confluenceConfig();
|
|
25
|
+
const id = requireString(args.id, 'Confluence page id');
|
|
26
|
+
const current = await getPage(config, id);
|
|
27
|
+
const storage = await readPageBodyFile(args);
|
|
28
|
+
const payload = updatePagePayload(config, current, args, storage);
|
|
29
|
+
const path = config.deployment === 'cloud' ? `/api/v2/pages/${encodeURIComponent(id)}` : `/rest/api/content/${encodeURIComponent(id)}`;
|
|
30
|
+
const page = requirePayloadObject(await atlassianRequest(config, path, {
|
|
31
|
+
method: 'PUT',
|
|
32
|
+
body: payload,
|
|
33
|
+
label: `confluence update ${id}`,
|
|
34
|
+
}), `confluence update ${id}`);
|
|
35
|
+
const normalized = normalizeConfluencePage(page, config);
|
|
36
|
+
return [{ ...normalized, pageStatus: normalized.status, status: 'updated' }];
|
|
37
|
+
},
|
|
38
|
+
});
|
package/clis/douyin/hashtag.js
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { browserFetch } from './_shared/browser-fetch.js';
|
|
3
|
-
import { ArgumentError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
|
+
|
|
5
|
+
function isPlainObject(value) {
|
|
6
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function requireListField(res, field, action) {
|
|
10
|
+
if (!isPlainObject(res)) {
|
|
11
|
+
throw new CommandExecutionError(`douyin hashtag ${action}: API returned malformed payload`);
|
|
12
|
+
}
|
|
13
|
+
const list = res[field];
|
|
14
|
+
if (list === undefined || list === null) return [];
|
|
15
|
+
if (!Array.isArray(list)) {
|
|
16
|
+
throw new CommandExecutionError(`douyin hashtag ${action}: API returned malformed "${field}"`);
|
|
17
|
+
}
|
|
18
|
+
return list;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function validateHashtagArgs(kwargs) {
|
|
22
|
+
const action = kwargs.action;
|
|
23
|
+
if (action === 'search') {
|
|
24
|
+
const keyword = String(kwargs.keyword ?? '').trim();
|
|
25
|
+
if (!keyword) {
|
|
26
|
+
throw new ArgumentError('douyin hashtag search 需要 --keyword <关键词>', '示例: opencli douyin hashtag search --keyword 美食');
|
|
27
|
+
}
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (action === 'suggest') {
|
|
31
|
+
const cover = String(kwargs.cover ?? '').trim();
|
|
32
|
+
if (!cover) {
|
|
33
|
+
throw new ArgumentError('douyin hashtag suggest 需要 --cover <cover_uri>', 'suggest 基于已上传的视频封面做 AI 推荐, 不是关键词搜索. 关键词搜索请用 `douyin hashtag search --keyword <词>`.');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
4
38
|
cli({
|
|
5
39
|
site: 'douyin',
|
|
6
40
|
name: 'hashtag',
|
|
@@ -9,43 +43,70 @@ cli({
|
|
|
9
43
|
domain: 'creator.douyin.com',
|
|
10
44
|
strategy: Strategy.COOKIE,
|
|
11
45
|
args: [
|
|
12
|
-
{ name: 'action', required: true, positional: true, choices: ['search', 'suggest', 'hot'], help: 'search=关键词搜索 suggest=AI推荐 hot=热点词' },
|
|
13
|
-
{ name: 'keyword', default: '', help: '
|
|
14
|
-
{ name: 'cover', default: '', help: '封面 URI
|
|
46
|
+
{ name: 'action', required: true, positional: true, choices: ['search', 'suggest', 'hot'], help: 'search=关键词搜索 (--keyword 必填), suggest=AI推荐 (--cover 必填), hot=热点词 (--keyword 可选)' },
|
|
47
|
+
{ name: 'keyword', default: '', help: '搜索关键词. search 必填; hot 可选; suggest 不使用 (传 --cover)' },
|
|
48
|
+
{ name: 'cover', default: '', help: '封面 URI (cover_uri). suggest 必填; 其它 action 不使用' },
|
|
15
49
|
{ name: 'limit', type: 'int', default: 10 },
|
|
16
50
|
],
|
|
17
51
|
columns: ['name', 'id', 'view_count'],
|
|
52
|
+
validateArgs: validateHashtagArgs,
|
|
18
53
|
func: async (page, kwargs) => {
|
|
54
|
+
validateHashtagArgs(kwargs);
|
|
19
55
|
const action = kwargs.action;
|
|
20
56
|
if (action === 'search') {
|
|
21
|
-
const
|
|
57
|
+
const keyword = String(kwargs.keyword ?? '').trim();
|
|
58
|
+
const url = `https://creator.douyin.com/aweme/v1/challenge/search/?keyword=${encodeURIComponent(keyword)}&count=${kwargs.limit}&aid=1128`;
|
|
22
59
|
const res = await browserFetch(page, 'GET', url);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
60
|
+
const list = requireListField(res, 'challenge_list', 'search');
|
|
61
|
+
const rows = list.flatMap(c => {
|
|
62
|
+
const info = c?.challenge_info;
|
|
63
|
+
if (!isPlainObject(info)) return [];
|
|
64
|
+
return [{
|
|
65
|
+
name: info.cha_name,
|
|
66
|
+
id: info.cid,
|
|
67
|
+
view_count: info.view_count,
|
|
68
|
+
}];
|
|
69
|
+
});
|
|
70
|
+
if (list.length > 0 && rows.length === 0) {
|
|
71
|
+
throw new CommandExecutionError('douyin hashtag search: API returned challenges but none had stable challenge_info shape');
|
|
72
|
+
}
|
|
73
|
+
return rows;
|
|
28
74
|
}
|
|
29
75
|
if (action === 'suggest') {
|
|
30
|
-
const
|
|
76
|
+
const cover = String(kwargs.cover ?? '').trim();
|
|
77
|
+
const url = `https://creator.douyin.com/web/api/media/hashtag/rec/?cover_uri=${encodeURIComponent(cover)}&aid=1128`;
|
|
31
78
|
const res = await browserFetch(page, 'GET', url);
|
|
32
|
-
|
|
79
|
+
const list = requireListField(res, 'hashtag_list', 'suggest');
|
|
80
|
+
return list.map(h => ({ name: h?.name ?? '', id: h?.id ?? '', view_count: h?.view_count ?? 0 }));
|
|
33
81
|
}
|
|
34
82
|
if (action === 'hot') {
|
|
35
|
-
const kw = kwargs.keyword;
|
|
83
|
+
const kw = String(kwargs.keyword ?? '').trim();
|
|
36
84
|
const url = `https://creator.douyin.com/aweme/v1/hotspot/recommend/?${kw ? `keyword=${encodeURIComponent(kw)}&` : ''}aid=1128`;
|
|
37
85
|
const res = await browserFetch(page, 'GET', url);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
86
|
+
if (!isPlainObject(res)) {
|
|
87
|
+
throw new CommandExecutionError('douyin hashtag hot: API returned malformed payload');
|
|
88
|
+
}
|
|
89
|
+
const hotspotList = res.hotspot_list;
|
|
90
|
+
const allSentences = res.all_sentences;
|
|
91
|
+
if (hotspotList !== undefined && hotspotList !== null && !Array.isArray(hotspotList)) {
|
|
92
|
+
throw new CommandExecutionError('douyin hashtag hot: API returned malformed "hotspot_list"');
|
|
93
|
+
}
|
|
94
|
+
if (allSentences !== undefined && allSentences !== null && !Array.isArray(allSentences)) {
|
|
95
|
+
throw new CommandExecutionError('douyin hashtag hot: API returned malformed "all_sentences"');
|
|
96
|
+
}
|
|
97
|
+
const items = Array.isArray(hotspotList)
|
|
98
|
+
? hotspotList
|
|
99
|
+
: Array.isArray(allSentences)
|
|
100
|
+
? allSentences.map(h => ({
|
|
101
|
+
sentence: h?.word ?? '',
|
|
102
|
+
hot_value: h?.hot_value,
|
|
103
|
+
sentence_id: h?.sentence_id ?? '',
|
|
104
|
+
}))
|
|
105
|
+
: [];
|
|
45
106
|
return items.slice(0, kwargs.limit).map(h => ({
|
|
46
|
-
name: h
|
|
47
|
-
id: 'sentence_id' in h ? h.sentence_id : '',
|
|
48
|
-
view_count: h
|
|
107
|
+
name: h?.sentence ?? '',
|
|
108
|
+
id: h && 'sentence_id' in h ? h.sentence_id : '',
|
|
109
|
+
view_count: h?.hot_value ?? 0,
|
|
49
110
|
}));
|
|
50
111
|
}
|
|
51
112
|
throw new ArgumentError(`未知的 action: ${action}`);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
3
|
const { browserFetchMock } = vi.hoisted(() => ({
|
|
3
4
|
browserFetchMock: vi.fn(),
|
|
4
5
|
}));
|
|
@@ -31,6 +32,118 @@ describe('douyin hashtag', () => {
|
|
|
31
32
|
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'hashtag');
|
|
32
33
|
expect(cmd?.strategy).toBe('cookie');
|
|
33
34
|
});
|
|
35
|
+
it('registers action-specific validation so missing args fail before browser pre-navigation', () => {
|
|
36
|
+
const registry = getRegistry();
|
|
37
|
+
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
|
38
|
+
expect(cmd?.validateArgs).toBeTypeOf('function');
|
|
39
|
+
expect(() => cmd.validateArgs({ action: 'search', keyword: '', cover: '', limit: 10 }))
|
|
40
|
+
.toThrow(ArgumentError);
|
|
41
|
+
expect(() => cmd.validateArgs({ action: 'suggest', keyword: '速效救心丸', cover: '', limit: 10 }))
|
|
42
|
+
.toThrow(ArgumentError);
|
|
43
|
+
expect(() => cmd.validateArgs({ action: 'hot', keyword: '', cover: '', limit: 10 }))
|
|
44
|
+
.not.toThrow();
|
|
45
|
+
expect(browserFetchMock).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('search throws ArgumentError when --keyword is missing or blank (#1689)', async () => {
|
|
49
|
+
const registry = getRegistry();
|
|
50
|
+
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
|
51
|
+
await expect(cmd.func({}, { action: 'search', keyword: '', cover: '', limit: 10 }))
|
|
52
|
+
.rejects.toBeInstanceOf(ArgumentError);
|
|
53
|
+
await expect(cmd.func({}, { action: 'search', keyword: ' ', cover: '', limit: 10 }))
|
|
54
|
+
.rejects.toBeInstanceOf(ArgumentError);
|
|
55
|
+
expect(browserFetchMock).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('suggest throws ArgumentError when --cover is missing (#1689 root cause)', async () => {
|
|
59
|
+
const registry = getRegistry();
|
|
60
|
+
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
|
61
|
+
await expect(cmd.func({}, { action: 'suggest', keyword: '速效救心丸', cover: '', limit: 10 }))
|
|
62
|
+
.rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--cover') });
|
|
63
|
+
await expect(cmd.func({}, { action: 'suggest', keyword: '', cover: ' ', limit: 10 }))
|
|
64
|
+
.rejects.toBeInstanceOf(ArgumentError);
|
|
65
|
+
expect(browserFetchMock).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('hot accepts empty --keyword (it is optional for hot)', async () => {
|
|
69
|
+
const registry = getRegistry();
|
|
70
|
+
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
|
71
|
+
browserFetchMock.mockResolvedValueOnce({ hotspot_list: [{ sentence: '热点1', hot_value: 100, sentence_id: 'h1' }] });
|
|
72
|
+
const rows = await cmd.func({}, { action: 'hot', keyword: '', cover: '', limit: 5 });
|
|
73
|
+
expect(rows[0]).toEqual({ name: '热点1', id: 'h1', view_count: 100 });
|
|
74
|
+
const url = browserFetchMock.mock.calls[0][2];
|
|
75
|
+
expect(url).not.toContain('keyword=');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('search threads --keyword + count into the challenge/search URL', async () => {
|
|
79
|
+
const registry = getRegistry();
|
|
80
|
+
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
|
81
|
+
browserFetchMock.mockResolvedValueOnce({
|
|
82
|
+
challenge_list: [{ challenge_info: { cha_name: '美食', cid: '123', view_count: 5000 } }],
|
|
83
|
+
});
|
|
84
|
+
const rows = await cmd.func({}, { action: 'search', keyword: '美食', cover: '', limit: 10 });
|
|
85
|
+
expect(rows).toEqual([{ name: '美食', id: '123', view_count: 5000 }]);
|
|
86
|
+
const url = browserFetchMock.mock.calls[0][2];
|
|
87
|
+
expect(url).toContain('challenge/search');
|
|
88
|
+
expect(url).toContain('keyword=' + encodeURIComponent('美食'));
|
|
89
|
+
expect(url).toContain('count=10');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('suggest threads --cover into the hashtag/rec URL on success', async () => {
|
|
93
|
+
const registry = getRegistry();
|
|
94
|
+
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
|
95
|
+
browserFetchMock.mockResolvedValueOnce({
|
|
96
|
+
hashtag_list: [{ name: '推荐话题', id: 'h99', view_count: 1234 }],
|
|
97
|
+
});
|
|
98
|
+
const rows = await cmd.func({}, { action: 'suggest', keyword: '', cover: 'tos-cn-i-cover/abc', limit: 10 });
|
|
99
|
+
expect(rows).toEqual([{ name: '推荐话题', id: 'h99', view_count: 1234 }]);
|
|
100
|
+
const url = browserFetchMock.mock.calls[0][2];
|
|
101
|
+
expect(url).toContain('hashtag/rec');
|
|
102
|
+
expect(url).toContain('cover_uri=' + encodeURIComponent('tos-cn-i-cover/abc'));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('search throws CommandExecutionError when API returns a non-object payload', async () => {
|
|
106
|
+
const registry = getRegistry();
|
|
107
|
+
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
|
108
|
+
browserFetchMock.mockResolvedValueOnce(null);
|
|
109
|
+
await expect(cmd.func({}, { action: 'search', keyword: '美食', cover: '', limit: 10 }))
|
|
110
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('search throws CommandExecutionError when challenge_list has wrong shape', async () => {
|
|
114
|
+
const registry = getRegistry();
|
|
115
|
+
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
|
116
|
+
browserFetchMock.mockResolvedValueOnce({ challenge_list: 'not-an-array' });
|
|
117
|
+
await expect(cmd.func({}, { action: 'search', keyword: '美食', cover: '', limit: 10 }))
|
|
118
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('search throws CommandExecutionError when challenges return but none parse', async () => {
|
|
122
|
+
const registry = getRegistry();
|
|
123
|
+
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
|
124
|
+
browserFetchMock.mockResolvedValueOnce({
|
|
125
|
+
challenge_list: [{ challenge_info: null }, { other_field: 1 }],
|
|
126
|
+
});
|
|
127
|
+
await expect(cmd.func({}, { action: 'search', keyword: '美食', cover: '', limit: 10 }))
|
|
128
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('suggest throws CommandExecutionError when hashtag_list has wrong shape', async () => {
|
|
132
|
+
const registry = getRegistry();
|
|
133
|
+
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
|
134
|
+
browserFetchMock.mockResolvedValueOnce({ hashtag_list: 'oops' });
|
|
135
|
+
await expect(cmd.func({}, { action: 'suggest', keyword: '', cover: 'tos-cn-i-cover/x', limit: 10 }))
|
|
136
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('hot throws CommandExecutionError when hotspot_list has wrong shape', async () => {
|
|
140
|
+
const registry = getRegistry();
|
|
141
|
+
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
|
142
|
+
browserFetchMock.mockResolvedValueOnce({ hotspot_list: { malformed: true } });
|
|
143
|
+
await expect(cmd.func({}, { action: 'hot', keyword: '', cover: '', limit: 5 }))
|
|
144
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
145
|
+
});
|
|
146
|
+
|
|
34
147
|
it('parses the current hotspot recommendation shape', async () => {
|
|
35
148
|
const registry = getRegistry();
|
|
36
149
|
const command = [...registry.values()].find((cmd) => cmd.site === 'douyin' && cmd.name === 'hashtag');
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { ArgumentError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { ensureApplet, ggbEval, normalizeLabel, normalizeNumber, requireGgbSuccess } from './utils.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'geogebra',
|
|
7
|
+
name: 'add-circle',
|
|
8
|
+
access: 'write',
|
|
9
|
+
description: 'Create a circle by center+radius or center+point',
|
|
10
|
+
domain: 'www.geogebra.org',
|
|
11
|
+
strategy: Strategy.PUBLIC,
|
|
12
|
+
browser: true,
|
|
13
|
+
navigateBefore: false,
|
|
14
|
+
example: 'opencli geogebra add-circle --center A --radius 3',
|
|
15
|
+
args: [
|
|
16
|
+
{ name: 'center', required: true, help: 'Center point label (e.g. A)' },
|
|
17
|
+
{ name: 'radius', required: false, help: 'Radius value (number) or a point label on the circle' },
|
|
18
|
+
{ name: 'point', required: false, help: 'Alternative: a point label on the circle (use instead of --radius for Circle(center,point))' },
|
|
19
|
+
],
|
|
20
|
+
columns: ['label', 'center', 'radius'],
|
|
21
|
+
func: async (page, kwargs) => {
|
|
22
|
+
const center = normalizeLabel(kwargs.center, 'center');
|
|
23
|
+
if (kwargs.point && kwargs.radius !== undefined) {
|
|
24
|
+
throw new ArgumentError('Use either --point or --radius, not both');
|
|
25
|
+
}
|
|
26
|
+
const pointOnCircle = kwargs.point ? normalizeLabel(kwargs.point, 'point') : '';
|
|
27
|
+
const radiusValue = kwargs.radius;
|
|
28
|
+
|
|
29
|
+
let cmd;
|
|
30
|
+
if (pointOnCircle) {
|
|
31
|
+
cmd = `Circle(${center},${pointOnCircle})`;
|
|
32
|
+
} else if (radiusValue !== undefined) {
|
|
33
|
+
const raw = String(radiusValue).trim();
|
|
34
|
+
const num = Number(raw);
|
|
35
|
+
cmd = Number.isFinite(num)
|
|
36
|
+
? `Circle(${center},${normalizeNumber(raw, 'radius', { positive: true })})`
|
|
37
|
+
: `Circle(${center},${normalizeLabel(raw, 'radius point')})`;
|
|
38
|
+
} else {
|
|
39
|
+
throw new ArgumentError('Provide --radius (number or point label) or --point (point on circle)');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await ensureApplet(page);
|
|
43
|
+
const result = requireGgbSuccess(await ggbEval(page, cmd), `Failed to create circle: ${cmd}`);
|
|
44
|
+
return [{ label: result.label, center, radius: pointOnCircle || radiusValue }];
|
|
45
|
+
},
|
|
46
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { ArgumentError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { ensureApplet, ggbEval, normalizeLabelList, requireGgbSuccess } from './utils.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'geogebra',
|
|
7
|
+
name: 'add-line',
|
|
8
|
+
access: 'write',
|
|
9
|
+
description: 'Create a line through two points or a segment between two points',
|
|
10
|
+
domain: 'www.geogebra.org',
|
|
11
|
+
strategy: Strategy.PUBLIC,
|
|
12
|
+
browser: true,
|
|
13
|
+
navigateBefore: false,
|
|
14
|
+
example: 'opencli geogebra add-line --points A,B --type segment',
|
|
15
|
+
args: [
|
|
16
|
+
{ name: 'points', required: true, help: 'Two point labels separated by comma (e.g. "A,B")' },
|
|
17
|
+
{ name: 'type', required: false, choices: ['line', 'segment', 'ray'], default: 'line', help: 'Type: line, segment, or ray (default: line)' },
|
|
18
|
+
],
|
|
19
|
+
columns: ['label', 'type', 'points'],
|
|
20
|
+
func: async (page, kwargs) => {
|
|
21
|
+
const [a, b] = normalizeLabelList(kwargs.points, 'points', 2, 2);
|
|
22
|
+
const type = kwargs.type || 'line';
|
|
23
|
+
const geogebraCmd = {
|
|
24
|
+
line: `Line(${a},${b})`,
|
|
25
|
+
segment: `Segment(${a},${b})`,
|
|
26
|
+
ray: `Ray(${a},${b})`,
|
|
27
|
+
}[type];
|
|
28
|
+
if (!geogebraCmd) {
|
|
29
|
+
throw new ArgumentError('type must be one of: line, segment, ray');
|
|
30
|
+
}
|
|
31
|
+
await ensureApplet(page);
|
|
32
|
+
const result = requireGgbSuccess(await ggbEval(page, geogebraCmd), `Failed to create ${type}: ${geogebraCmd}`);
|
|
33
|
+
return [{ label: result.label, type, points: `${a},${b}` }];
|
|
34
|
+
},
|
|
35
|
+
});
|