@jackwener/opencli 1.6.8 → 1.6.9
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 +2 -0
- package/README.zh-CN.md +2 -1
- package/dist/clis/jianyu/search.d.ts +14 -0
- package/dist/clis/jianyu/search.js +135 -0
- package/dist/clis/jianyu/search.test.d.ts +1 -0
- package/dist/clis/jianyu/search.test.js +23 -0
- package/dist/clis/quark/ls.d.ts +1 -0
- package/dist/clis/quark/ls.js +63 -0
- package/dist/clis/quark/mkdir.d.ts +1 -0
- package/dist/clis/quark/mkdir.js +36 -0
- package/dist/clis/quark/mv.d.ts +1 -0
- package/dist/clis/quark/mv.js +53 -0
- package/dist/clis/quark/rename.d.ts +1 -0
- package/dist/clis/quark/rename.js +26 -0
- package/dist/clis/quark/rm.d.ts +1 -0
- package/dist/clis/quark/rm.js +24 -0
- package/dist/clis/quark/save.d.ts +1 -0
- package/dist/clis/quark/save.js +80 -0
- package/dist/clis/quark/share-tree.d.ts +1 -0
- package/dist/clis/quark/share-tree.js +45 -0
- package/dist/clis/quark/utils.d.ts +50 -0
- package/dist/clis/quark/utils.js +146 -0
- package/dist/clis/quark/utils.test.d.ts +1 -0
- package/dist/clis/quark/utils.test.js +58 -0
- package/dist/clis/twitter/reply.js +3 -8
- package/dist/clis/twitter/reply.test.js +5 -5
- package/dist/clis/xiaohongshu/note.js +8 -3
- package/dist/clis/xiaohongshu/note.test.js +11 -0
- package/dist/clis/zhihu/answer.d.ts +1 -0
- package/dist/clis/zhihu/answer.js +194 -0
- package/dist/clis/zhihu/answer.test.d.ts +1 -0
- package/dist/clis/zhihu/answer.test.js +81 -0
- package/dist/clis/zhihu/comment.d.ts +1 -0
- package/dist/clis/zhihu/comment.js +335 -0
- package/dist/clis/zhihu/comment.test.d.ts +1 -0
- package/dist/clis/zhihu/comment.test.js +54 -0
- package/dist/clis/zhihu/favorite.d.ts +1 -0
- package/dist/clis/zhihu/favorite.js +224 -0
- package/dist/clis/zhihu/favorite.test.d.ts +1 -0
- package/dist/clis/zhihu/favorite.test.js +196 -0
- package/dist/clis/zhihu/follow.d.ts +1 -0
- package/dist/clis/zhihu/follow.js +80 -0
- package/dist/clis/zhihu/follow.test.d.ts +1 -0
- package/dist/clis/zhihu/follow.test.js +45 -0
- package/dist/clis/zhihu/like.d.ts +1 -0
- package/dist/clis/zhihu/like.js +91 -0
- package/dist/clis/zhihu/like.test.d.ts +1 -0
- package/dist/clis/zhihu/like.test.js +64 -0
- package/dist/clis/zhihu/target.d.ts +24 -0
- package/dist/clis/zhihu/target.js +91 -0
- package/dist/clis/zhihu/target.test.d.ts +1 -0
- package/dist/clis/zhihu/target.test.js +77 -0
- package/dist/clis/zhihu/write-shared.d.ts +32 -0
- package/dist/clis/zhihu/write-shared.js +221 -0
- package/dist/clis/zhihu/write-shared.test.d.ts +1 -0
- package/dist/clis/zhihu/write-shared.test.js +175 -0
- package/dist/src/browser/bridge.d.ts +2 -0
- package/dist/src/browser/bridge.js +30 -24
- package/dist/src/browser/daemon-client.d.ts +17 -8
- package/dist/src/browser/daemon-client.js +12 -13
- package/dist/src/browser/daemon-client.test.js +32 -25
- package/dist/src/browser/index.d.ts +2 -1
- package/dist/src/browser/index.js +1 -1
- package/dist/src/browser.test.js +2 -3
- package/dist/src/cli.js +3 -3
- package/dist/src/clis/binance/commands.test.d.ts +1 -0
- package/dist/src/clis/binance/commands.test.js +54 -0
- package/dist/src/commanderAdapter.js +19 -6
- package/dist/src/diagnostic.d.ts +1 -0
- package/dist/src/diagnostic.js +64 -2
- package/dist/src/diagnostic.test.js +91 -1
- package/dist/src/doctor.d.ts +2 -0
- package/dist/src/doctor.js +59 -31
- package/dist/src/doctor.test.js +89 -16
- package/dist/src/execution.js +1 -13
- package/dist/src/explore.js +1 -1
- package/dist/src/generate.d.ts +2 -5
- package/dist/src/generate.js +2 -5
- package/dist/src/plugin.d.ts +2 -1
- package/dist/src/plugin.js +25 -8
- package/dist/src/plugin.test.js +16 -1
- package/package.json +3 -3
- package/dist/src/browser/discover.d.ts +0 -15
- package/dist/src/browser/discover.js +0 -19
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { CliError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { assertAllowedKinds, parseTarget } from './target.js';
|
|
4
|
+
import { buildResultRow, requireExecute, resolveCurrentUserIdentity, resolvePayload } from './write-shared.js';
|
|
5
|
+
const COMMENT_AUTHOR_SCOPE_SELECTOR = '.CommentItemV2-head, .CommentItem-head, .CommentItemV2-meta, .CommentItem-meta, .CommentItemV2-metaSibling, [data-comment-author], [itemprop="author"]';
|
|
6
|
+
cli({
|
|
7
|
+
site: 'zhihu',
|
|
8
|
+
name: 'comment',
|
|
9
|
+
description: 'Create a top-level comment on a Zhihu answer or article',
|
|
10
|
+
domain: 'zhihu.com',
|
|
11
|
+
strategy: Strategy.UI,
|
|
12
|
+
browser: true,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' },
|
|
15
|
+
{ name: 'text', positional: true, help: 'Comment text' },
|
|
16
|
+
{ name: 'file', help: 'Comment text file path' },
|
|
17
|
+
{ name: 'execute', type: 'boolean', help: 'Actually perform the write action' },
|
|
18
|
+
],
|
|
19
|
+
columns: ['status', 'outcome', 'message', 'target_type', 'target', 'author_identity', 'created_url', 'created_proof'],
|
|
20
|
+
func: async (page, kwargs) => {
|
|
21
|
+
if (!page)
|
|
22
|
+
throw new CommandExecutionError('Browser session required for zhihu comment');
|
|
23
|
+
requireExecute(kwargs);
|
|
24
|
+
const rawTarget = String(kwargs.target);
|
|
25
|
+
const target = assertAllowedKinds('comment', parseTarget(rawTarget));
|
|
26
|
+
const payload = await resolvePayload(kwargs);
|
|
27
|
+
await page.goto(target.url);
|
|
28
|
+
const authorIdentity = await resolveCurrentUserIdentity(page);
|
|
29
|
+
const entryPath = await page.evaluate(`(() => {
|
|
30
|
+
const targetKind = ${JSON.stringify(target.kind)};
|
|
31
|
+
const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
|
|
32
|
+
const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
|
|
33
|
+
const restoredDraft = !!document.querySelector('[contenteditable="true"][data-draft-restored], textarea[data-draft-restored]');
|
|
34
|
+
let scope = document;
|
|
35
|
+
if (targetKind === 'answer') {
|
|
36
|
+
const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
|
|
37
|
+
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
|
|
38
|
+
if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
|
|
39
|
+
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
|
|
40
|
+
const href = link.getAttribute('href') || '';
|
|
41
|
+
return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
if (!block) return { entryPathSafe: false, wrongAnswer: true };
|
|
45
|
+
scope = block;
|
|
46
|
+
} else {
|
|
47
|
+
scope =
|
|
48
|
+
document.querySelector('article')
|
|
49
|
+
|| document.querySelector('.Post-Main')
|
|
50
|
+
|| document.querySelector('[itemprop="articleBody"]')
|
|
51
|
+
|| document;
|
|
52
|
+
}
|
|
53
|
+
const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
|
|
54
|
+
const container = editor.closest('form, .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement;
|
|
55
|
+
const replyHint = editor.getAttribute('data-reply-to') || '';
|
|
56
|
+
const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
|
|
57
|
+
const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem'));
|
|
58
|
+
return { editor, container, replyHint, text, nestedReply };
|
|
59
|
+
}).filter((candidate) => candidate.container && !candidate.nestedReply);
|
|
60
|
+
return {
|
|
61
|
+
entryPathSafe: topLevelCandidates.length === 1
|
|
62
|
+
&& !restoredDraft
|
|
63
|
+
&& !topLevelCandidates[0].replyHint
|
|
64
|
+
&& !String(topLevelCandidates[0].text || '').trim(),
|
|
65
|
+
wrongAnswer: false,
|
|
66
|
+
};
|
|
67
|
+
})()`);
|
|
68
|
+
if (entryPath.wrongAnswer) {
|
|
69
|
+
throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
|
|
70
|
+
}
|
|
71
|
+
if (!entryPath.entryPathSafe) {
|
|
72
|
+
throw new CliError('ACTION_NOT_AVAILABLE', 'Comment entry path was not proven side-effect free');
|
|
73
|
+
}
|
|
74
|
+
const beforeSubmitSnapshot = await page.evaluate(`(() => {
|
|
75
|
+
const normalize = (value) => value.replace(/\\s+/g, ' ').trim();
|
|
76
|
+
const targetKind = ${JSON.stringify(target.kind)};
|
|
77
|
+
const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
|
|
78
|
+
const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
|
|
79
|
+
let scope = document;
|
|
80
|
+
if (targetKind === 'answer') {
|
|
81
|
+
const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
|
|
82
|
+
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
|
|
83
|
+
if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
|
|
84
|
+
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
|
|
85
|
+
const href = link.getAttribute('href') || '';
|
|
86
|
+
return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
if (!block) return { wrongAnswer: true, rows: [], commentLinks: [] };
|
|
90
|
+
scope = block;
|
|
91
|
+
} else {
|
|
92
|
+
scope =
|
|
93
|
+
document.querySelector('article')
|
|
94
|
+
|| document.querySelector('.Post-Main')
|
|
95
|
+
|| document.querySelector('[itemprop="articleBody"]')
|
|
96
|
+
|| document;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
wrongAnswer: false,
|
|
100
|
+
rows: Array.from(scope.querySelectorAll('[data-comment-id], .CommentItem')).map((node) => ({
|
|
101
|
+
id: node.getAttribute('data-comment-id') || '',
|
|
102
|
+
text: normalize(node.textContent || ''),
|
|
103
|
+
})),
|
|
104
|
+
commentLinks: Array.from(scope.querySelectorAll('a[href*="/comment/"]'))
|
|
105
|
+
.map((node) => node.getAttribute('href') || '')
|
|
106
|
+
.filter(Boolean),
|
|
107
|
+
};
|
|
108
|
+
})()`);
|
|
109
|
+
if (beforeSubmitSnapshot.wrongAnswer) {
|
|
110
|
+
throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
|
|
111
|
+
}
|
|
112
|
+
const composer = await page.evaluate(`(async () => {
|
|
113
|
+
const targetKind = ${JSON.stringify(target.kind)};
|
|
114
|
+
const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
|
|
115
|
+
const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
|
|
116
|
+
let scope = document;
|
|
117
|
+
if (targetKind === 'answer') {
|
|
118
|
+
const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
|
|
119
|
+
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
|
|
120
|
+
if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
|
|
121
|
+
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
|
|
122
|
+
const href = link.getAttribute('href') || '';
|
|
123
|
+
return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
if (!block) return { composerState: 'wrong_answer' };
|
|
127
|
+
scope = block;
|
|
128
|
+
} else {
|
|
129
|
+
scope =
|
|
130
|
+
document.querySelector('article')
|
|
131
|
+
|| document.querySelector('.Post-Main')
|
|
132
|
+
|| document.querySelector('[itemprop="articleBody"]')
|
|
133
|
+
|| document;
|
|
134
|
+
}
|
|
135
|
+
const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
|
|
136
|
+
const container = editor.closest('form, .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement;
|
|
137
|
+
const replyHint = editor.getAttribute('data-reply-to') || '';
|
|
138
|
+
const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
|
|
139
|
+
const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem'));
|
|
140
|
+
return { editor, container, replyHint, text, nestedReply };
|
|
141
|
+
}).filter((candidate) => candidate.container && !candidate.nestedReply);
|
|
142
|
+
if (topLevelCandidates.length !== 1) return { composerState: 'unsafe' };
|
|
143
|
+
return {
|
|
144
|
+
composerState: !topLevelCandidates[0].replyHint && !topLevelCandidates[0].text.trim() ? 'fresh_top_level' : 'unsafe',
|
|
145
|
+
};
|
|
146
|
+
})()`);
|
|
147
|
+
if (composer.composerState === 'wrong_answer') {
|
|
148
|
+
throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
|
|
149
|
+
}
|
|
150
|
+
if (composer.composerState !== 'fresh_top_level') {
|
|
151
|
+
throw new CliError('ACTION_NOT_AVAILABLE', 'Comment composer was not a fresh top-level composer');
|
|
152
|
+
}
|
|
153
|
+
const editorCheck = await page.evaluate(`(async () => {
|
|
154
|
+
const textToInsert = ${JSON.stringify(payload)};
|
|
155
|
+
const targetKind = ${JSON.stringify(target.kind)};
|
|
156
|
+
const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
|
|
157
|
+
const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
|
|
158
|
+
let scope = document;
|
|
159
|
+
if (targetKind === 'answer') {
|
|
160
|
+
const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
|
|
161
|
+
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
|
|
162
|
+
if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
|
|
163
|
+
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
|
|
164
|
+
const href = link.getAttribute('href') || '';
|
|
165
|
+
return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
if (!block) return { editorContent: '', mode: 'wrong_answer' };
|
|
169
|
+
scope = block;
|
|
170
|
+
} else {
|
|
171
|
+
scope =
|
|
172
|
+
document.querySelector('article')
|
|
173
|
+
|| document.querySelector('.Post-Main')
|
|
174
|
+
|| document.querySelector('[itemprop="articleBody"]')
|
|
175
|
+
|| document;
|
|
176
|
+
}
|
|
177
|
+
const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
|
|
178
|
+
const container = editor.closest('form, .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement;
|
|
179
|
+
const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem'));
|
|
180
|
+
return { editor, container, nestedReply };
|
|
181
|
+
}).filter((candidate) => candidate.container && !candidate.nestedReply);
|
|
182
|
+
if (topLevelCandidates.length !== 1) return { editorContent: '', mode: 'missing' };
|
|
183
|
+
const { editor } = topLevelCandidates[0];
|
|
184
|
+
editor.focus();
|
|
185
|
+
if ('value' in editor) {
|
|
186
|
+
editor.value = '';
|
|
187
|
+
editor.dispatchEvent(new Event('input', { bubbles: true }));
|
|
188
|
+
editor.value = textToInsert;
|
|
189
|
+
editor.dispatchEvent(new Event('input', { bubbles: true }));
|
|
190
|
+
} else {
|
|
191
|
+
editor.textContent = '';
|
|
192
|
+
document.execCommand('insertText', false, textToInsert);
|
|
193
|
+
editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: textToInsert, inputType: 'insertText' }));
|
|
194
|
+
}
|
|
195
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
196
|
+
const content = 'value' in editor ? editor.value : (editor.textContent || '');
|
|
197
|
+
const replyHint = editor.getAttribute('data-reply-to') || '';
|
|
198
|
+
return { editorContent: content, mode: replyHint ? 'reply' : 'top_level' };
|
|
199
|
+
})()`);
|
|
200
|
+
if (editorCheck.mode === 'wrong_answer') {
|
|
201
|
+
throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
|
|
202
|
+
}
|
|
203
|
+
if (editorCheck.mode !== 'top_level' || editorCheck.editorContent !== payload) {
|
|
204
|
+
throw new CliError('OUTCOME_UNKNOWN', 'Comment editor content did not exactly match the requested payload before submit');
|
|
205
|
+
}
|
|
206
|
+
const proof = await page.evaluate(`(async () => {
|
|
207
|
+
const normalize = (value) => value.replace(/\\s+/g, ' ').trim();
|
|
208
|
+
const commentAuthorScopeSelector = ${JSON.stringify(COMMENT_AUTHOR_SCOPE_SELECTOR)};
|
|
209
|
+
const readCommentAuthorSlug = (node) => {
|
|
210
|
+
const authorScopes = Array.from(node.querySelectorAll(commentAuthorScopeSelector));
|
|
211
|
+
const slugs = Array.from(new Set(authorScopes
|
|
212
|
+
.flatMap((scope) => Array.from(scope.querySelectorAll('a[href^="/people/"]')))
|
|
213
|
+
.map((link) => (link.getAttribute('href') || '').match(/^\\/people\\/([A-Za-z0-9_-]+)/)?.[1] || null)
|
|
214
|
+
.filter(Boolean)));
|
|
215
|
+
return slugs.length === 1 ? slugs[0] : null;
|
|
216
|
+
};
|
|
217
|
+
const targetKind = ${JSON.stringify(target.kind)};
|
|
218
|
+
const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
|
|
219
|
+
const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
|
|
220
|
+
let scope = document;
|
|
221
|
+
if (targetKind === 'answer') {
|
|
222
|
+
const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
|
|
223
|
+
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
|
|
224
|
+
if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
|
|
225
|
+
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
|
|
226
|
+
const href = link.getAttribute('href') || '';
|
|
227
|
+
return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
if (!block) return { proofType: 'wrong_answer' };
|
|
231
|
+
scope = block;
|
|
232
|
+
} else {
|
|
233
|
+
scope =
|
|
234
|
+
document.querySelector('article')
|
|
235
|
+
|| document.querySelector('.Post-Main')
|
|
236
|
+
|| document.querySelector('[itemprop="articleBody"]')
|
|
237
|
+
|| document;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
|
|
241
|
+
const container = editor.closest('form, [role="dialog"], .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement;
|
|
242
|
+
const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem'));
|
|
243
|
+
return { editor, container, nestedReply };
|
|
244
|
+
}).filter((candidate) => candidate.container && !candidate.nestedReply);
|
|
245
|
+
if (topLevelCandidates.length !== 1) return { proofType: 'unknown' };
|
|
246
|
+
const submitScope = topLevelCandidates[0].container || scope;
|
|
247
|
+
const submit = Array.from(submitScope.querySelectorAll('button')).find((node) => /发布|评论|发送/.test(node.textContent || ''));
|
|
248
|
+
submit && submit.click();
|
|
249
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
250
|
+
const createdLink = Array.from(scope.querySelectorAll('a[href*="/comment/"]')).find((node) => {
|
|
251
|
+
const href = node.getAttribute('href') || '';
|
|
252
|
+
return href.includes('/comment/') && !${JSON.stringify(beforeSubmitSnapshot.commentLinks ?? [])}.includes(href);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
if (createdLink) {
|
|
256
|
+
const card = createdLink.closest('[data-comment-id], .CommentItem, li');
|
|
257
|
+
const authorSlug = card ? readCommentAuthorSlug(card) : null;
|
|
258
|
+
const contentNode =
|
|
259
|
+
card?.querySelector('[data-comment-content], .RichContent-inner, .CommentItemV2-content, .CommentContent')
|
|
260
|
+
|| card;
|
|
261
|
+
const text = normalize(contentNode?.textContent || '');
|
|
262
|
+
const nestedReply = Boolean(card?.closest('ul ul, ol ol, li li') || card?.parentElement?.closest('[data-comment-id], .CommentItem'));
|
|
263
|
+
return {
|
|
264
|
+
proofType: 'stable_url',
|
|
265
|
+
createdUrl: new URL(createdLink.getAttribute('href') || '', location.origin).href,
|
|
266
|
+
commentScope: nestedReply ? 'nested_reply' : 'top_level_only',
|
|
267
|
+
authorIdentity: authorSlug,
|
|
268
|
+
targetMatches: text === normalize(${JSON.stringify(payload)}),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const currentUserSlug = ${JSON.stringify(authorIdentity)};
|
|
273
|
+
const beforeIds = new Set(${JSON.stringify((beforeSubmitSnapshot.rows ?? []).map((row) => row.id).filter(Boolean))});
|
|
274
|
+
const beforeTexts = new Set(${JSON.stringify((beforeSubmitSnapshot.rows ?? []).map((row) => row.text).filter(Boolean))});
|
|
275
|
+
const normalizedPayload = normalize(${JSON.stringify(payload)});
|
|
276
|
+
const after = Array.from(scope.querySelectorAll('[data-comment-id], .CommentItem')).map((node) => {
|
|
277
|
+
return {
|
|
278
|
+
id: node.getAttribute('data-comment-id') || '',
|
|
279
|
+
text: normalize(node.textContent || ''),
|
|
280
|
+
authorSlug: readCommentAuthorSlug(node),
|
|
281
|
+
topLevel: !node.closest('ul ul, ol ol, li li') && !node.parentElement?.closest('[data-comment-id], .CommentItem'),
|
|
282
|
+
};
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const matching = after.filter((row) =>
|
|
286
|
+
!beforeIds.has(row.id)
|
|
287
|
+
&& row.authorSlug === currentUserSlug
|
|
288
|
+
&& row.topLevel
|
|
289
|
+
&& row.text === normalizedPayload
|
|
290
|
+
&& !beforeTexts.has(row.text)
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
return matching.length === 1
|
|
294
|
+
? {
|
|
295
|
+
proofType: 'fallback',
|
|
296
|
+
createdProof: {
|
|
297
|
+
proof_type: 'comment_fallback',
|
|
298
|
+
author_scope: 'current_user',
|
|
299
|
+
target_scope: 'requested_target',
|
|
300
|
+
comment_scope: 'top_level_only',
|
|
301
|
+
content_match: 'exact_normalized',
|
|
302
|
+
observed_after_submit: true,
|
|
303
|
+
present_in_pre_submit_snapshot: false,
|
|
304
|
+
new_matching_entries: 1,
|
|
305
|
+
post_submit_matching_entries: after.filter((row) =>
|
|
306
|
+
row.authorSlug === currentUserSlug && row.topLevel && row.text === normalizedPayload
|
|
307
|
+
).length,
|
|
308
|
+
snapshot_scope: ${JSON.stringify(target.kind === 'answer'
|
|
309
|
+
? 'stabilized_expanded_target_answer_comment_list'
|
|
310
|
+
: 'stabilized_expanded_target_article_comment_list')},
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
: { proofType: 'unknown' };
|
|
314
|
+
})()`);
|
|
315
|
+
if (proof.proofType === 'wrong_answer') {
|
|
316
|
+
throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
|
|
317
|
+
}
|
|
318
|
+
if (proof.proofType === 'fallback') {
|
|
319
|
+
return buildResultRow(`Commented on ${target.kind}`, target.kind, rawTarget, 'created', {
|
|
320
|
+
author_identity: authorIdentity,
|
|
321
|
+
created_proof: proof.createdProof,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
if (proof.proofType !== 'stable_url') {
|
|
325
|
+
throw new CliError('OUTCOME_UNKNOWN', 'Comment submit was dispatched, but the created object could not be proven safely');
|
|
326
|
+
}
|
|
327
|
+
if (proof.commentScope !== 'top_level_only' || proof.authorIdentity !== authorIdentity || !proof.targetMatches) {
|
|
328
|
+
throw new CliError('OUTCOME_UNKNOWN', 'Stable comment URL was found, but authorship or top-level scope could not be proven safely');
|
|
329
|
+
}
|
|
330
|
+
return buildResultRow(`Commented on ${target.kind}`, target.kind, rawTarget, 'created', {
|
|
331
|
+
author_identity: authorIdentity,
|
|
332
|
+
created_url: proof.createdUrl,
|
|
333
|
+
});
|
|
334
|
+
},
|
|
335
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './comment.js';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './comment.js';
|
|
4
|
+
describe('zhihu comment', () => {
|
|
5
|
+
it('rejects composer paths that are not proven side-effect free', async () => {
|
|
6
|
+
const cmd = getRegistry().get('zhihu/comment');
|
|
7
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
8
|
+
const page = {
|
|
9
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
evaluate: vi.fn()
|
|
11
|
+
.mockResolvedValueOnce({ slug: 'alice' })
|
|
12
|
+
.mockResolvedValueOnce({ entryPathSafe: false })
|
|
13
|
+
.mockResolvedValueOnce({ wrongAnswer: false, rows: [], commentLinks: [] }),
|
|
14
|
+
};
|
|
15
|
+
await expect(cmd.func(page, { target: 'answer:1:2', text: 'hello', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
|
|
16
|
+
});
|
|
17
|
+
it('requires exact editor replacement before accepting fallback proof', async () => {
|
|
18
|
+
const cmd = getRegistry().get('zhihu/comment');
|
|
19
|
+
const page = {
|
|
20
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
evaluate: vi.fn()
|
|
22
|
+
.mockResolvedValueOnce({ slug: 'alice' })
|
|
23
|
+
.mockResolvedValueOnce({ entryPathSafe: true })
|
|
24
|
+
.mockResolvedValueOnce({ wrongAnswer: false, rows: [], commentLinks: [] })
|
|
25
|
+
.mockResolvedValueOnce({ composerState: 'fresh_top_level' })
|
|
26
|
+
.mockResolvedValueOnce({ editorContent: 'hello', mode: 'top_level' })
|
|
27
|
+
.mockResolvedValueOnce({
|
|
28
|
+
proofType: 'fallback',
|
|
29
|
+
createdProof: {
|
|
30
|
+
proof_type: 'comment_fallback',
|
|
31
|
+
author_scope: 'current_user',
|
|
32
|
+
target_scope: 'requested_target',
|
|
33
|
+
comment_scope: 'top_level_only',
|
|
34
|
+
content_match: 'exact_normalized',
|
|
35
|
+
observed_after_submit: true,
|
|
36
|
+
present_in_pre_submit_snapshot: false,
|
|
37
|
+
new_matching_entries: 1,
|
|
38
|
+
post_submit_matching_entries: 1,
|
|
39
|
+
snapshot_scope: 'stabilized_expanded_target_comment_list',
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
await expect(cmd.func(page, { target: 'answer:1:2', text: 'hello', execute: true })).resolves.toEqual([
|
|
44
|
+
expect.objectContaining({ outcome: 'created', author_identity: 'alice', created_proof: expect.any(Object) }),
|
|
45
|
+
]);
|
|
46
|
+
expect(page.evaluate.mock.calls[1][0]).toContain('topLevelCandidates.length === 1');
|
|
47
|
+
expect(page.evaluate.mock.calls[1][0]).not.toContain('commentTrigger');
|
|
48
|
+
expect(page.evaluate.mock.calls[2][0]).toContain("node.getAttribute('data-answerid')");
|
|
49
|
+
expect(page.evaluate.mock.calls[2][0]).toContain("node.getAttribute('data-zop-question-answer')");
|
|
50
|
+
expect(page.evaluate.mock.calls[5][0]).toContain('const readCommentAuthorSlug = (node) =>');
|
|
51
|
+
expect(page.evaluate.mock.calls[5][0]).toContain('const commentAuthorScopeSelector = ".CommentItemV2-head, .CommentItem-head, .CommentItemV2-meta, .CommentItem-meta, .CommentItemV2-metaSibling, [data-comment-author], [itemprop=\\"author\\"]"');
|
|
52
|
+
expect(page.evaluate.mock.calls[5][0]).not.toContain("card?.querySelector('a[href^=\"/people/\"]')");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { CliError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { assertAllowedKinds, parseTarget } from './target.js';
|
|
4
|
+
import { buildResultRow, requireExecute } from './write-shared.js';
|
|
5
|
+
function rowKey(row) {
|
|
6
|
+
return row.id || `name:${normalizeCollectionName(row.name)}`;
|
|
7
|
+
}
|
|
8
|
+
function normalizeCollectionName(value) {
|
|
9
|
+
return value
|
|
10
|
+
.replace(/\s+/g, ' ')
|
|
11
|
+
.replace(/\s+\d+\s*(条内容|个内容|items?)$/i, '')
|
|
12
|
+
.replace(/\s+(公开|私密|默认)$/i, '')
|
|
13
|
+
.trim();
|
|
14
|
+
}
|
|
15
|
+
cli({
|
|
16
|
+
site: 'zhihu',
|
|
17
|
+
name: 'favorite',
|
|
18
|
+
description: 'Favorite a Zhihu answer or article into a specific collection',
|
|
19
|
+
domain: 'zhihu.com',
|
|
20
|
+
strategy: Strategy.UI,
|
|
21
|
+
browser: true,
|
|
22
|
+
args: [
|
|
23
|
+
{ name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' },
|
|
24
|
+
{ name: 'collection', help: 'Collection name' },
|
|
25
|
+
{ name: 'collection-id', help: 'Stable collection id' },
|
|
26
|
+
{ name: 'execute', type: 'boolean', help: 'Actually perform the write action' },
|
|
27
|
+
],
|
|
28
|
+
columns: ['status', 'outcome', 'message', 'target_type', 'target', 'collection_name', 'collection_id'],
|
|
29
|
+
func: async (page, kwargs) => {
|
|
30
|
+
if (!page)
|
|
31
|
+
throw new CommandExecutionError('Browser session required for zhihu favorite');
|
|
32
|
+
requireExecute(kwargs);
|
|
33
|
+
const rawTarget = String(kwargs.target);
|
|
34
|
+
const target = assertAllowedKinds('favorite', parseTarget(rawTarget));
|
|
35
|
+
const collectionName = typeof kwargs.collection === 'string' ? kwargs.collection : undefined;
|
|
36
|
+
const collectionId = typeof kwargs['collection-id'] === 'string' ? kwargs['collection-id'] : undefined;
|
|
37
|
+
if ((collectionName ? 1 : 0) + (collectionId ? 1 : 0) !== 1) {
|
|
38
|
+
throw new CliError('INVALID_INPUT', 'Use exactly one of --collection or --collection-id');
|
|
39
|
+
}
|
|
40
|
+
await page.goto(target.url);
|
|
41
|
+
const preflight = await page.evaluate(`(async () => {
|
|
42
|
+
const targetKind = ${JSON.stringify(target.kind)};
|
|
43
|
+
const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
|
|
44
|
+
const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
|
|
45
|
+
const wantedName = ${JSON.stringify(collectionName ?? null)};
|
|
46
|
+
const wantedId = ${JSON.stringify(collectionId ?? null)};
|
|
47
|
+
|
|
48
|
+
let scope = document;
|
|
49
|
+
if (targetKind === 'answer') {
|
|
50
|
+
const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
|
|
51
|
+
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
|
|
52
|
+
if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
|
|
53
|
+
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
|
|
54
|
+
const href = link.getAttribute('href') || '';
|
|
55
|
+
return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
if (!block) return { wrongAnswer: true, chooserRows: [] };
|
|
59
|
+
scope = block;
|
|
60
|
+
} else {
|
|
61
|
+
scope =
|
|
62
|
+
document.querySelector('article')
|
|
63
|
+
|| document.querySelector('.Post-Main')
|
|
64
|
+
|| document.querySelector('[itemprop="articleBody"]')
|
|
65
|
+
|| document;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const favoriteButton = Array.from(scope.querySelectorAll('button')).find((node) => /收藏/.test(node.textContent || ''));
|
|
69
|
+
if (!favoriteButton) return { wrongAnswer: false, missingChooser: true, chooserRows: [] };
|
|
70
|
+
favoriteButton.click();
|
|
71
|
+
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
72
|
+
|
|
73
|
+
const chooserRows = Array.from(document.querySelectorAll('[role="dialog"] li, [role="dialog"] [role="checkbox"], [role="dialog"] button'))
|
|
74
|
+
.map((node) => {
|
|
75
|
+
const text = (node.textContent || '').trim();
|
|
76
|
+
const id = node.getAttribute('data-id') || node.getAttribute('data-collection-id') || '';
|
|
77
|
+
const selected = node.getAttribute('aria-checked') === 'true'
|
|
78
|
+
|| node.getAttribute('aria-pressed') === 'true'
|
|
79
|
+
|| /已选|已收藏/.test(text);
|
|
80
|
+
return text ? { id, name: text, selected } : null;
|
|
81
|
+
})
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
wrongAnswer: false,
|
|
86
|
+
missingChooser: chooserRows.length === 0,
|
|
87
|
+
chooserRows,
|
|
88
|
+
targetRowId: wantedId,
|
|
89
|
+
targetRowName: wantedName,
|
|
90
|
+
};
|
|
91
|
+
})()`);
|
|
92
|
+
if (preflight.wrongAnswer) {
|
|
93
|
+
throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
|
|
94
|
+
}
|
|
95
|
+
if (preflight.missingChooser) {
|
|
96
|
+
throw new CliError('ACTION_NOT_AVAILABLE', 'Favorite chooser did not open on the requested target');
|
|
97
|
+
}
|
|
98
|
+
const matchingRows = preflight.chooserRows.filter((row) => (collectionId
|
|
99
|
+
? row.id === collectionId
|
|
100
|
+
: normalizeCollectionName(row.name) === normalizeCollectionName(collectionName || '')));
|
|
101
|
+
if (collectionId && !matchingRows.some((row) => row.id === collectionId)) {
|
|
102
|
+
throw new CliError('ACTION_NOT_AVAILABLE', 'Favorite chooser could not confirm the requested stable collection id');
|
|
103
|
+
}
|
|
104
|
+
if (!collectionId && matchingRows.length !== 1) {
|
|
105
|
+
throw new CliError('ACTION_NOT_AVAILABLE', 'Favorite chooser could not prove that the requested collection name is globally unique');
|
|
106
|
+
}
|
|
107
|
+
const targetRow = matchingRows[0];
|
|
108
|
+
const targetRowKey = rowKey(targetRow);
|
|
109
|
+
const selectedBefore = preflight.chooserRows.filter((row) => row.selected).map(rowKey);
|
|
110
|
+
const verify = await page.evaluate(`(async () => {
|
|
111
|
+
const targetKind = ${JSON.stringify(target.kind)};
|
|
112
|
+
const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
|
|
113
|
+
const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
|
|
114
|
+
const targetWasSelected = ${JSON.stringify(targetRow.selected)};
|
|
115
|
+
const wantedName = ${JSON.stringify(collectionName ?? null)};
|
|
116
|
+
const wantedId = ${JSON.stringify(collectionId ?? null)};
|
|
117
|
+
const normalizeCollectionName = (value) => String(value || '')
|
|
118
|
+
.replace(/\\s+/g, ' ')
|
|
119
|
+
.replace(/\\s+\\d+\\s*(条内容|个内容|items?)$/i, '')
|
|
120
|
+
.replace(/\\s+(公开|私密|默认)$/i, '')
|
|
121
|
+
.trim();
|
|
122
|
+
const rowKey = (row) => row.id || 'name:' + normalizeCollectionName(row.name);
|
|
123
|
+
|
|
124
|
+
const chooserSelector = '[role="dialog"] li, [role="dialog"] [role="checkbox"], [role="dialog"] button';
|
|
125
|
+
const readChooserRows = () => Array.from(document.querySelectorAll(chooserSelector))
|
|
126
|
+
.map((node) => {
|
|
127
|
+
const text = (node.textContent || '').trim();
|
|
128
|
+
const id = node.getAttribute('data-id') || node.getAttribute('data-collection-id') || '';
|
|
129
|
+
const selected = node.getAttribute('aria-checked') === 'true'
|
|
130
|
+
|| node.getAttribute('aria-pressed') === 'true'
|
|
131
|
+
|| /已选|已收藏/.test(text);
|
|
132
|
+
return text ? { id, name: text, selected } : null;
|
|
133
|
+
})
|
|
134
|
+
.filter(Boolean);
|
|
135
|
+
const waitForChooserRows = async (expectedPresent) => {
|
|
136
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
137
|
+
const rows = readChooserRows();
|
|
138
|
+
if (expectedPresent ? rows.length > 0 : rows.length === 0) return rows;
|
|
139
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
140
|
+
}
|
|
141
|
+
return readChooserRows();
|
|
142
|
+
};
|
|
143
|
+
const closeChooser = async () => {
|
|
144
|
+
const closeButton = Array.from(document.querySelectorAll('[role="dialog"] button, [role="dialog"] [role="button"]')).find((node) => {
|
|
145
|
+
const text = (node.textContent || '').trim();
|
|
146
|
+
const aria = node.getAttribute('aria-label') || '';
|
|
147
|
+
return /关闭|取消|收起/.test(text) || /关闭|cancel|close/i.test(aria);
|
|
148
|
+
});
|
|
149
|
+
closeButton && closeButton.click();
|
|
150
|
+
return waitForChooserRows(false);
|
|
151
|
+
};
|
|
152
|
+
const reopenChooser = async () => {
|
|
153
|
+
let scope = document;
|
|
154
|
+
if (targetKind === 'answer') {
|
|
155
|
+
const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
|
|
156
|
+
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
|
|
157
|
+
if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
|
|
158
|
+
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
|
|
159
|
+
const href = link.getAttribute('href') || '';
|
|
160
|
+
return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
if (!block) return [];
|
|
164
|
+
scope = block;
|
|
165
|
+
} else {
|
|
166
|
+
scope =
|
|
167
|
+
document.querySelector('article')
|
|
168
|
+
|| document.querySelector('.Post-Main')
|
|
169
|
+
|| document.querySelector('[itemprop="articleBody"]')
|
|
170
|
+
|| document;
|
|
171
|
+
}
|
|
172
|
+
const favoriteButton = Array.from(scope.querySelectorAll('button')).find((node) => /收藏/.test(node.textContent || ''));
|
|
173
|
+
favoriteButton && favoriteButton.click();
|
|
174
|
+
return waitForChooserRows(true);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
let chooserRows = readChooserRows();
|
|
178
|
+
let sawChooserClose = false;
|
|
179
|
+
if (!targetWasSelected) {
|
|
180
|
+
const row = Array.from(document.querySelectorAll('[role="dialog"] li, [role="dialog"] [role="checkbox"], [role="dialog"] button')).find((node) => {
|
|
181
|
+
const text = (node.textContent || '').trim();
|
|
182
|
+
const id = node.getAttribute('data-id') || node.getAttribute('data-collection-id') || '';
|
|
183
|
+
return wantedId ? id === wantedId : normalizeCollectionName(text) === normalizeCollectionName(wantedName);
|
|
184
|
+
});
|
|
185
|
+
row && row.click();
|
|
186
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
187
|
+
const submit = Array.from(document.querySelectorAll('[role="dialog"] button')).find((node) => /完成|确定|保存/.test(node.textContent || ''));
|
|
188
|
+
submit && submit.click();
|
|
189
|
+
chooserRows = await waitForChooserRows(false);
|
|
190
|
+
sawChooserClose = chooserRows.length === 0;
|
|
191
|
+
} else {
|
|
192
|
+
chooserRows = await closeChooser();
|
|
193
|
+
sawChooserClose = chooserRows.length === 0;
|
|
194
|
+
}
|
|
195
|
+
if (sawChooserClose) {
|
|
196
|
+
chooserRows = await reopenChooser();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
persisted: sawChooserClose && chooserRows.length > 0,
|
|
201
|
+
readbackSource: sawChooserClose && chooserRows.length > 0 ? 'reopened_chooser' : (chooserRows.length > 0 ? 'same_modal' : 'missing'),
|
|
202
|
+
selectedAfter: chooserRows.filter((row) => row.selected).map(rowKey),
|
|
203
|
+
targetSelected: chooserRows.some((row) => rowKey(row) === ${JSON.stringify(targetRowKey)} && row.selected),
|
|
204
|
+
};
|
|
205
|
+
})()`);
|
|
206
|
+
if (!verify.persisted) {
|
|
207
|
+
throw new CliError('OUTCOME_UNKNOWN', 'Favorite action may have been applied, but persisted read-back was unavailable');
|
|
208
|
+
}
|
|
209
|
+
if (verify.readbackSource !== 'reopened_chooser') {
|
|
210
|
+
throw new CliError('OUTCOME_UNKNOWN', 'Favorite state was not re-read from a reopened chooser after submit');
|
|
211
|
+
}
|
|
212
|
+
if (!verify.targetSelected) {
|
|
213
|
+
throw new CliError('OUTCOME_UNKNOWN', 'Favorite chooser remained readable, but the requested collection was not confirmed as selected');
|
|
214
|
+
}
|
|
215
|
+
if (!selectedBefore.every((row) => verify.selectedAfter.includes(row))) {
|
|
216
|
+
throw new CliError('OUTCOME_UNKNOWN', `Favorite action changed unrelated collection membership: before=${JSON.stringify(selectedBefore)} after=${JSON.stringify(verify.selectedAfter)}`);
|
|
217
|
+
}
|
|
218
|
+
const outcome = targetRow.selected ? 'already_applied' : 'applied';
|
|
219
|
+
return buildResultRow(targetRow.selected ? `Already favorited ${target.kind}` : `Favorited ${target.kind}`, target.kind, rawTarget, outcome, {
|
|
220
|
+
collection_name: collectionName ?? targetRow.name,
|
|
221
|
+
...(targetRow.id ? { collection_id: targetRow.id } : {}),
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './favorite.js';
|