@telepat/snoopy 0.1.4 → 0.1.8
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 +36 -24
- package/dist/src/cli/commands/doctor.js +5 -1
- package/dist/src/cli/commands/export.d.ts +8 -1
- package/dist/src/cli/commands/export.js +33 -7
- package/dist/src/cli/commands/job.d.ts +2 -2
- package/dist/src/cli/commands/job.js +94 -30
- package/dist/src/cli/commands/results.d.ts +1 -0
- package/dist/src/cli/commands/results.js +118 -0
- package/dist/src/cli/commands/startup.js +5 -2
- package/dist/src/cli/index.js +17 -6
- package/dist/src/cli/ui/consoleUi.js +0 -3
- package/dist/src/services/db/repositories/jobsRepo.d.ts +15 -0
- package/dist/src/services/db/repositories/jobsRepo.js +21 -14
- package/dist/src/services/db/repositories/runsRepo.d.ts +8 -0
- package/dist/src/services/db/repositories/runsRepo.js +64 -54
- package/dist/src/services/db/repositories/scanItemsRepo.d.ts +45 -1
- package/dist/src/services/db/repositories/scanItemsRepo.js +184 -28
- package/dist/src/services/db/sqlite.js +18 -0
- package/dist/src/services/export/csvResults.d.ts +1 -1
- package/dist/src/services/export/csvResults.js +3 -2
- package/dist/src/services/export/fileNaming.d.ts +2 -0
- package/dist/src/services/export/fileNaming.js +10 -0
- package/dist/src/services/export/jsonResults.d.ts +10 -0
- package/dist/src/services/export/jsonResults.js +16 -0
- package/dist/src/services/openrouter/client.d.ts +1 -0
- package/dist/src/services/openrouter/client.js +36 -5
- package/dist/src/services/scheduler/jobRunner.js +10 -1
- package/dist/src/services/startup/index.js +16 -28
- package/dist/src/services/startup/windowsTaskScheduler.d.ts +1 -0
- package/dist/src/services/startup/windowsTaskScheduler.js +24 -4
- package/dist/src/ui/components/JobsTable.d.ts +8 -0
- package/dist/src/ui/components/JobsTable.js +78 -0
- package/dist/src/ui/components/ResultsViewer.d.ts +11 -0
- package/dist/src/ui/components/ResultsViewer.js +81 -0
- package/dist/src/ui/components/RunsTable.d.ts +9 -0
- package/dist/src/ui/components/RunsTable.js +99 -0
- package/dist/src/ui/components/jobsTableModel.d.ts +15 -0
- package/dist/src/ui/components/jobsTableModel.js +79 -0
- package/dist/src/ui/components/resultsViewerModel.d.ts +14 -0
- package/dist/src/ui/components/resultsViewerModel.js +122 -0
- package/dist/src/ui/components/runsTableModel.d.ts +28 -0
- package/dist/src/ui/components/runsTableModel.js +109 -0
- package/package.json +5 -2
- package/dist/src/cli/commands/analytics.js.map +0 -1
- package/dist/src/cli/commands/daemon.js.map +0 -1
- package/dist/src/cli/commands/doctor.js.map +0 -1
- package/dist/src/cli/commands/errors.js.map +0 -1
- package/dist/src/cli/commands/export.js.map +0 -1
- package/dist/src/cli/commands/job.js.map +0 -1
- package/dist/src/cli/commands/logs.js.map +0 -1
- package/dist/src/cli/commands/selection.js.map +0 -1
- package/dist/src/cli/commands/settings.js.map +0 -1
- package/dist/src/cli/commands/startup.js.map +0 -1
- package/dist/src/cli/flows/jobAddFlow.js.map +0 -1
- package/dist/src/cli/flows/settingsFlow.js.map +0 -1
- package/dist/src/cli/flows/settingsFlowModel.js.map +0 -1
- package/dist/src/cli/index.js.map +0 -1
- package/dist/src/cli/ui/consoleUi.js.map +0 -1
- package/dist/src/cli/ui/time.js.map +0 -1
- package/dist/src/index.js.map +0 -1
- package/dist/src/scripts/e2eSmoke.d.ts +0 -1
- package/dist/src/scripts/e2eSmoke.js +0 -102
- package/dist/src/scripts/e2eSmoke.js.map +0 -1
- package/dist/src/services/analytics/analyticsService.js.map +0 -1
- package/dist/src/services/daemonControl.js.map +0 -1
- package/dist/src/services/db/repositories/jobsRepo.js.map +0 -1
- package/dist/src/services/db/repositories/runsRepo.js.map +0 -1
- package/dist/src/services/db/repositories/scanItemsRepo.js.map +0 -1
- package/dist/src/services/db/repositories/settingsRepo.js.map +0 -1
- package/dist/src/services/db/sqlite.js.map +0 -1
- package/dist/src/services/export/csvResults.js.map +0 -1
- package/dist/src/services/logging/logReader.js.map +0 -1
- package/dist/src/services/logging/logRotation.js.map +0 -1
- package/dist/src/services/logging/runLogger.js.map +0 -1
- package/dist/src/services/openrouter/client.js.map +0 -1
- package/dist/src/services/openrouter/prompts.js.map +0 -1
- package/dist/src/services/reddit/client.js.map +0 -1
- package/dist/src/services/scheduler/cronScheduler.js.map +0 -1
- package/dist/src/services/scheduler/jobRunner.js.map +0 -1
- package/dist/src/services/scheduler/jobRunnerStub.js.map +0 -1
- package/dist/src/services/security/secretStore.js.map +0 -1
- package/dist/src/services/startup/index.js.map +0 -1
- package/dist/src/services/startup/linuxCronFallback.js.map +0 -1
- package/dist/src/services/startup/linuxSystemd.js.map +0 -1
- package/dist/src/services/startup/macosLaunchd.js.map +0 -1
- package/dist/src/services/startup/windowsRunFallback.d.ts +0 -2
- package/dist/src/services/startup/windowsRunFallback.js +0 -17
- package/dist/src/services/startup/windowsRunFallback.js.map +0 -1
- package/dist/src/services/startup/windowsTaskScheduler.js.map +0 -1
- package/dist/src/types/job.js.map +0 -1
- package/dist/src/types/settings.js.map +0 -1
- package/dist/src/ui/components/AppFrame.js.map +0 -1
- package/dist/src/ui/components/CliHeader.js.map +0 -1
- package/dist/src/ui/components/SubredditMultiSelect.js.map +0 -1
- package/dist/src/ui/components/TextPrompt.js.map +0 -1
- package/dist/src/ui/components/YesNoSelector.js.map +0 -1
- package/dist/src/ui/components/subredditOptions.js.map +0 -1
- package/dist/src/ui/components/yesNoSelectorModel.js.map +0 -1
- package/dist/src/ui/theme.js.map +0 -1
- package/dist/src/utils/logger.js.map +0 -1
- package/dist/src/utils/notify.js.map +0 -1
- package/dist/src/utils/paths.js.map +0 -1
- package/dist/src/utils/scanLogFormatting.js.map +0 -1
|
@@ -16,6 +16,37 @@ const qualifySchema = z.object({
|
|
|
16
16
|
qualified: z.boolean(),
|
|
17
17
|
reason: z.string().min(1).max(80)
|
|
18
18
|
});
|
|
19
|
+
function toAlphaLabel(index) {
|
|
20
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
21
|
+
let value = index;
|
|
22
|
+
let label = '';
|
|
23
|
+
do {
|
|
24
|
+
label = `${alphabet[value % 26]}${label}`;
|
|
25
|
+
value = Math.floor(value / 26) - 1;
|
|
26
|
+
} while (value >= 0);
|
|
27
|
+
return label;
|
|
28
|
+
}
|
|
29
|
+
function buildAnonymizedThreadLines(thread, targetAuthor) {
|
|
30
|
+
const authorLabels = new Map();
|
|
31
|
+
let nextLabelIndex = 0;
|
|
32
|
+
return thread.map((comment) => {
|
|
33
|
+
const author = comment.author || '[deleted]';
|
|
34
|
+
let displayAuthor = author;
|
|
35
|
+
if (author !== targetAuthor) {
|
|
36
|
+
const existingLabel = authorLabels.get(author);
|
|
37
|
+
if (existingLabel) {
|
|
38
|
+
displayAuthor = existingLabel;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const generatedLabel = `User ${toAlphaLabel(nextLabelIndex)}`;
|
|
42
|
+
nextLabelIndex += 1;
|
|
43
|
+
authorLabels.set(author, generatedLabel);
|
|
44
|
+
displayAuthor = generatedLabel;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return `- (${displayAuthor}) ${comment.body}`;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
19
50
|
const QUALIFICATION_TOOL_NAME = 'submit_qualification';
|
|
20
51
|
const QUALIFICATION_TOOL = {
|
|
21
52
|
type: 'function',
|
|
@@ -215,17 +246,17 @@ export class OpenRouterClient {
|
|
|
215
246
|
return this.runQualification(input, userMessage);
|
|
216
247
|
}
|
|
217
248
|
async qualifyCommentThread(input) {
|
|
218
|
-
const threadText = input.thread
|
|
219
|
-
.map((comment) => `${comment.author}: ${comment.body}`)
|
|
220
|
-
.join('\n');
|
|
249
|
+
const threadText = buildAnonymizedThreadLines(input.thread, input.targetAuthor).join('\n');
|
|
221
250
|
const userMessage = [
|
|
222
251
|
`Post title: ${input.postTitle}`,
|
|
223
252
|
'',
|
|
253
|
+
`Post body: ${input.postBody}`,
|
|
254
|
+
'',
|
|
224
255
|
'Comment thread (chronological):',
|
|
225
256
|
threadText,
|
|
226
257
|
'',
|
|
227
|
-
`Important:
|
|
228
|
-
'
|
|
258
|
+
`Important: QUALIFY ONLY the final comment line authored by ${input.targetAuthor}.`,
|
|
259
|
+
'Treat all earlier lines as context only and do not qualify them.'
|
|
229
260
|
].join('\n');
|
|
230
261
|
return this.runQualification(input, userMessage);
|
|
231
262
|
}
|
|
@@ -308,6 +308,7 @@ export class JobRunner {
|
|
|
308
308
|
modelSettings,
|
|
309
309
|
qualificationPrompt: job.qualificationPrompt,
|
|
310
310
|
postTitle: post.title,
|
|
311
|
+
postBody: post.body,
|
|
311
312
|
targetAuthor: author,
|
|
312
313
|
thread
|
|
313
314
|
});
|
|
@@ -334,7 +335,15 @@ export class JobRunner {
|
|
|
334
335
|
promptTokens: result.promptTokens,
|
|
335
336
|
completionTokens: result.completionTokens,
|
|
336
337
|
estimatedCostUsd: this.estimateCost(result.promptTokens, result.completionTokens),
|
|
337
|
-
qualificationReason: result.reason
|
|
338
|
+
qualificationReason: result.reason,
|
|
339
|
+
commentThreadNodes: thread.map((threadComment, index) => ({
|
|
340
|
+
redditCommentId: threadComment.id,
|
|
341
|
+
parentRedditCommentId: index === 0 ? null : thread[index - 1]?.id ?? null,
|
|
342
|
+
author: threadComment.author,
|
|
343
|
+
body: threadComment.body,
|
|
344
|
+
depth: index,
|
|
345
|
+
isTarget: index === thread.length - 1
|
|
346
|
+
}))
|
|
338
347
|
});
|
|
339
348
|
runStats.itemsNew += 1;
|
|
340
349
|
if (result.qualified) {
|
|
@@ -5,8 +5,7 @@ import { execSync } from 'node:child_process';
|
|
|
5
5
|
import { hasSystemdUser, installLinuxSystemd, uninstallLinuxSystemd } from './linuxSystemd.js';
|
|
6
6
|
import { installLinuxCronFallback, uninstallLinuxCronFallback } from './linuxCronFallback.js';
|
|
7
7
|
import { installMacStartup, uninstallMacStartup } from './macosLaunchd.js';
|
|
8
|
-
import {
|
|
9
|
-
import { installWindowsTask, uninstallWindowsTask } from './windowsTaskScheduler.js';
|
|
8
|
+
import { hasWindowsTaskInstalled, installWindowsTask, uninstallWindowsTask } from './windowsTaskScheduler.js';
|
|
10
9
|
export function installStartup(commandPath) {
|
|
11
10
|
if (process.platform === 'darwin') {
|
|
12
11
|
const detail = installMacStartup(commandPath);
|
|
@@ -21,13 +20,19 @@ export function installStartup(commandPath) {
|
|
|
21
20
|
return { success: true, method: 'cron-@reboot', detail: 'crontab entry installed' };
|
|
22
21
|
}
|
|
23
22
|
if (process.platform === 'win32') {
|
|
23
|
+
// On Windows we intentionally use a single explicit method.
|
|
24
|
+
// If task creation fails, we do not silently fall back to registry persistence.
|
|
24
25
|
try {
|
|
25
26
|
installWindowsTask(commandPath);
|
|
26
27
|
return { success: true, method: 'task-scheduler', detail: 'Task Scheduler job created' };
|
|
27
28
|
}
|
|
28
|
-
catch {
|
|
29
|
-
|
|
30
|
-
return {
|
|
29
|
+
catch (error) {
|
|
30
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
31
|
+
return {
|
|
32
|
+
success: false,
|
|
33
|
+
method: 'task-scheduler',
|
|
34
|
+
detail: `Task Scheduler startup setup failed: ${message}`
|
|
35
|
+
};
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
return { success: false, method: 'unsupported', detail: 'Unsupported platform' };
|
|
@@ -42,7 +47,6 @@ export function uninstallStartup() {
|
|
|
42
47
|
}
|
|
43
48
|
if (process.platform === 'win32') {
|
|
44
49
|
uninstallWindowsTask();
|
|
45
|
-
uninstallWindowsRunFallback();
|
|
46
50
|
}
|
|
47
51
|
}
|
|
48
52
|
export function getStartupStatus() {
|
|
@@ -82,34 +86,18 @@ export function getStartupStatus() {
|
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
if (process.platform === 'win32') {
|
|
85
|
-
|
|
86
|
-
execSync('schtasks /query /tn "Snoopy\\Daemon"', { shell: 'cmd.exe', stdio: 'ignore' });
|
|
89
|
+
if (hasWindowsTaskInstalled()) {
|
|
87
90
|
return {
|
|
88
91
|
enabled: true,
|
|
89
92
|
method: 'task-scheduler',
|
|
90
93
|
detail: 'Scheduled task exists'
|
|
91
94
|
};
|
|
92
95
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
});
|
|
99
|
-
return {
|
|
100
|
-
enabled: true,
|
|
101
|
-
method: 'registry-run',
|
|
102
|
-
detail: 'Registry startup entry exists'
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
return {
|
|
107
|
-
enabled: false,
|
|
108
|
-
method: 'task-scheduler',
|
|
109
|
-
detail: 'No startup registration found'
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
}
|
|
96
|
+
return {
|
|
97
|
+
enabled: false,
|
|
98
|
+
method: 'task-scheduler',
|
|
99
|
+
detail: 'No startup registration found'
|
|
100
|
+
};
|
|
113
101
|
}
|
|
114
102
|
return {
|
|
115
103
|
enabled: false,
|
|
@@ -1,16 +1,36 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
2
|
const TASK_NAME = 'Snoopy\\Daemon';
|
|
3
|
+
function validateWindowsStartupCommandPath(commandPath) {
|
|
4
|
+
const trimmedPath = commandPath.trim();
|
|
5
|
+
const isAbsoluteWindowsPath = /^[a-zA-Z]:[\\/]/.test(trimmedPath);
|
|
6
|
+
if (!trimmedPath || !isAbsoluteWindowsPath || /["\r\n]/.test(trimmedPath)) {
|
|
7
|
+
throw new Error('Startup command path must be an absolute Windows path without quotes or newlines.');
|
|
8
|
+
}
|
|
9
|
+
}
|
|
3
10
|
export function installWindowsTask(commandPath) {
|
|
11
|
+
// Startup persistence is explicit user opt-in through startup commands.
|
|
12
|
+
// Use execFileSync argument arrays to avoid cmd.exe parsing/injection risks.
|
|
13
|
+
validateWindowsStartupCommandPath(commandPath);
|
|
4
14
|
const runTarget = `"${commandPath}" daemon run`;
|
|
5
|
-
|
|
6
|
-
|
|
15
|
+
execFileSync('schtasks', ['/create', '/tn', TASK_NAME, '/tr', runTarget, '/sc', 'onlogon', '/f'], {
|
|
16
|
+
stdio: 'pipe'
|
|
17
|
+
});
|
|
7
18
|
}
|
|
8
19
|
export function uninstallWindowsTask() {
|
|
9
20
|
try {
|
|
10
|
-
|
|
21
|
+
execFileSync('schtasks', ['/delete', '/tn', TASK_NAME, '/f'], { stdio: 'pipe' });
|
|
11
22
|
}
|
|
12
23
|
catch {
|
|
13
24
|
// Ignore when task does not exist.
|
|
14
25
|
}
|
|
15
26
|
}
|
|
27
|
+
export function hasWindowsTaskInstalled() {
|
|
28
|
+
try {
|
|
29
|
+
execFileSync('schtasks', ['/query', '/tn', TASK_NAME], { stdio: 'ignore' });
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
16
36
|
//# sourceMappingURL=windowsTaskScheduler.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { JobSummaryRow } from '../../services/db/repositories/jobsRepo.js';
|
|
3
|
+
interface JobsTableProps {
|
|
4
|
+
jobs: JobSummaryRow[];
|
|
5
|
+
onExit: () => void;
|
|
6
|
+
}
|
|
7
|
+
export declare function JobsTable({ jobs, onExit }: JobsTableProps): React.JSX.Element;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useStdout } from 'ink';
|
|
4
|
+
import { uiTheme } from '../theme.js';
|
|
5
|
+
import { AppFrame, Panel } from './AppFrame.js';
|
|
6
|
+
import { buildJobDetailLines, computeJobColumnWidths, computeScrollWindow, formatJobHeaderRow, formatJobTableRow } from './jobsTableModel.js';
|
|
7
|
+
const WINDOW_SIZE = 10;
|
|
8
|
+
const COL_SEP = ' ';
|
|
9
|
+
export function JobsTable({ jobs, onExit }) {
|
|
10
|
+
const { stdout } = useStdout();
|
|
11
|
+
const terminalRows = stdout?.rows ?? 24;
|
|
12
|
+
const maxWindow = Math.max(1, Math.min(WINDOW_SIZE, terminalRows - 8));
|
|
13
|
+
const [view, setView] = useState('list');
|
|
14
|
+
const [cursor, setCursor] = useState(0);
|
|
15
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
16
|
+
const widths = computeJobColumnWidths(jobs);
|
|
17
|
+
const header = formatJobHeaderRow(widths);
|
|
18
|
+
const { scrollTop: nextScrollTop, visibleStart, visibleEnd } = computeScrollWindow(cursor, scrollTop, jobs.length, maxWindow);
|
|
19
|
+
if (nextScrollTop !== scrollTop) {
|
|
20
|
+
setScrollTop(nextScrollTop);
|
|
21
|
+
}
|
|
22
|
+
const visibleJobs = jobs.slice(visibleStart, visibleEnd);
|
|
23
|
+
const aboveCount = visibleStart;
|
|
24
|
+
const belowCount = jobs.length - visibleEnd;
|
|
25
|
+
useInput((input, key) => {
|
|
26
|
+
if (view === 'list') {
|
|
27
|
+
if (key.upArrow) {
|
|
28
|
+
const next = Math.max(0, cursor - 1);
|
|
29
|
+
setCursor(next);
|
|
30
|
+
setScrollTop(computeScrollWindow(next, scrollTop, jobs.length, maxWindow).scrollTop);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (key.downArrow) {
|
|
34
|
+
const next = Math.min(jobs.length - 1, cursor + 1);
|
|
35
|
+
setCursor(next);
|
|
36
|
+
setScrollTop(computeScrollWindow(next, scrollTop, jobs.length, maxWindow).scrollTop);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (key.return) {
|
|
40
|
+
setView('detail');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (input === 'q' || input === 'Q') {
|
|
44
|
+
onExit();
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// detail view
|
|
49
|
+
if (key.escape || key.leftArrow) {
|
|
50
|
+
setView('list');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (input === 'q' || input === 'Q') {
|
|
54
|
+
onExit();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
if (view === 'detail') {
|
|
58
|
+
const job = jobs[cursor];
|
|
59
|
+
if (!job)
|
|
60
|
+
return _jsx(_Fragment, {});
|
|
61
|
+
const detailLines = buildJobDetailLines(job);
|
|
62
|
+
return (_jsx(AppFrame, { subtitle: "Jobs", statusText: `${cursor + 1} / ${jobs.length}`, statusTone: "info", hints: ['← back', 'q quit'], children: _jsx(Panel, { title: job.jobName, children: detailLines.map((line) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: uiTheme.ink.info, children: line.label.padEnd(12) }), _jsx(Text, { color: uiTheme.ink.textPrimary, children: line.value })] }, line.label))) }) }));
|
|
63
|
+
}
|
|
64
|
+
return (_jsx(AppFrame, { subtitle: "Jobs", statusText: `${cursor + 1} / ${jobs.length}`, statusTone: "info", hints: ['↑↓ navigate', '↵ details', 'q quit'], children: _jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, children: [_jsx(Box, { flexDirection: "row", children: header.map((cell, i) => (_jsxs(Text, { bold: true, color: uiTheme.ink.accent, children: [i > 0 ? COL_SEP : '', cell] }, i))) }), aboveCount > 0 && (_jsxs(Text, { color: uiTheme.ink.textMuted, children: [" \u2191 ", aboveCount, " more"] })), visibleJobs.map((job, idx) => {
|
|
65
|
+
const absoluteIndex = visibleStart + idx;
|
|
66
|
+
const isSelected = absoluteIndex === cursor;
|
|
67
|
+
const cells = formatJobTableRow(job, widths);
|
|
68
|
+
return (_jsx(Box, { flexDirection: "row", children: cells.map((cell, i) => {
|
|
69
|
+
const color = isSelected
|
|
70
|
+
? uiTheme.ink.focus
|
|
71
|
+
: i === 0
|
|
72
|
+
? uiTheme.ink.textPrimary
|
|
73
|
+
: uiTheme.ink.textMuted;
|
|
74
|
+
return (_jsxs(Text, { color: color, inverse: isSelected && i === 0, bold: isSelected && i === 0, children: [i > 0 ? COL_SEP : '', cell] }, i));
|
|
75
|
+
}) }, job.jobId));
|
|
76
|
+
}), belowCount > 0 && (_jsxs(Text, { color: uiTheme.ink.textMuted, children: [" \u2193 ", belowCount, " more"] }))] }) }));
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=JobsTable.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type ResultsViewerItem } from './resultsViewerModel.js';
|
|
3
|
+
interface ResultsViewerProps {
|
|
4
|
+
jobName: string;
|
|
5
|
+
jobSlug: string;
|
|
6
|
+
totalItems: number;
|
|
7
|
+
getItemAt: (index: number) => ResultsViewerItem | null;
|
|
8
|
+
onExit: () => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function ResultsViewer({ jobName, jobSlug, totalItems, getItemAt, onExit }: ResultsViewerProps): React.JSX.Element;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useMemo, useState } from 'react';
|
|
3
|
+
import { Text, useInput, useStdout } from 'ink';
|
|
4
|
+
import { uiTheme } from '../theme.js';
|
|
5
|
+
import { AppFrame, Panel } from './AppFrame.js';
|
|
6
|
+
import { buildScrollableResultLines, nextContentScrollTop, nextItemIndex } from './resultsViewerModel.js';
|
|
7
|
+
export function ResultsViewer({ jobName, jobSlug, totalItems, getItemAt, onExit }) {
|
|
8
|
+
const { stdout } = useStdout();
|
|
9
|
+
const terminalRows = stdout?.rows ?? 24;
|
|
10
|
+
const terminalCols = stdout?.columns ?? 80;
|
|
11
|
+
const [cursor, setCursor] = useState(0);
|
|
12
|
+
const [contentScrollTop, setContentScrollTop] = useState(0);
|
|
13
|
+
const item = useMemo(() => getItemAt(cursor), [cursor, getItemAt]);
|
|
14
|
+
const resultLines = useMemo(() => {
|
|
15
|
+
if (!item) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
return buildScrollableResultLines(item, Math.max(20, terminalCols - 8));
|
|
19
|
+
}, [item, terminalCols]);
|
|
20
|
+
// Most of the terminal height is allocated to one scrollable result pane.
|
|
21
|
+
const contentWindowSize = Math.max(5, terminalRows - 10);
|
|
22
|
+
const visibleContent = resultLines.slice(contentScrollTop, contentScrollTop + contentWindowSize);
|
|
23
|
+
const aboveCount = contentScrollTop;
|
|
24
|
+
const belowCount = Math.max(0, resultLines.length - (contentScrollTop + contentWindowSize));
|
|
25
|
+
const metadataStartIndex = resultLines.indexOf('Metadata');
|
|
26
|
+
useInput((input, key) => {
|
|
27
|
+
if (key.leftArrow) {
|
|
28
|
+
const next = nextItemIndex(cursor, 'left', totalItems);
|
|
29
|
+
if (next !== cursor) {
|
|
30
|
+
setCursor(next);
|
|
31
|
+
setContentScrollTop(0);
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (key.rightArrow) {
|
|
36
|
+
const next = nextItemIndex(cursor, 'right', totalItems);
|
|
37
|
+
if (next !== cursor) {
|
|
38
|
+
setCursor(next);
|
|
39
|
+
setContentScrollTop(0);
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (key.upArrow) {
|
|
44
|
+
setContentScrollTop((prev) => nextContentScrollTop(prev, 'up', resultLines.length, contentWindowSize));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (key.downArrow) {
|
|
48
|
+
setContentScrollTop((prev) => nextContentScrollTop(prev, 'down', resultLines.length, contentWindowSize));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (key.escape || input === 'q' || input === 'Q') {
|
|
52
|
+
onExit();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
if (!item) {
|
|
56
|
+
return (_jsx(AppFrame, { subtitle: "Results", statusText: "No items", statusTone: "warning", hints: ['q quit'], children: _jsx(Panel, { title: "No Results", children: _jsx(Text, { color: uiTheme.ink.warning, children: "No results available." }) }) }));
|
|
57
|
+
}
|
|
58
|
+
const qualificationMark = item.qualified ? '✓' : 'x';
|
|
59
|
+
const qualificationText = item.qualified ? 'Qualified' : 'Not Qualified';
|
|
60
|
+
const qualificationColor = item.qualified ? uiTheme.ink.success : uiTheme.ink.danger;
|
|
61
|
+
const contentLinkLabel = item.type === 'comment' ? 'Comment URL' : 'Post URL';
|
|
62
|
+
const renderLine = (line, absoluteIndex) => {
|
|
63
|
+
const isMetadataLine = metadataStartIndex !== -1 && absoluteIndex > metadataStartIndex;
|
|
64
|
+
if (isMetadataLine) {
|
|
65
|
+
const match = line.match(/^([^:]+):(\s*)(.*)$/);
|
|
66
|
+
if (match) {
|
|
67
|
+
const [, label, gap, value] = match;
|
|
68
|
+
return (_jsxs(Text, { color: uiTheme.ink.textPrimary, children: [_jsx(Text, { color: uiTheme.ink.info, children: `${label}:` }), `${gap}${value}`] }));
|
|
69
|
+
}
|
|
70
|
+
if (line.startsWith(' ')) {
|
|
71
|
+
return _jsx(Text, { color: uiTheme.ink.textMuted, children: line });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (line === 'Metadata' || line === 'Title' || line === 'Body' || line === 'Thread (root -> target)' || line === 'Target Comment Body') {
|
|
75
|
+
return (_jsx(Text, { color: uiTheme.ink.accent, bold: true, children: line }));
|
|
76
|
+
}
|
|
77
|
+
return _jsx(Text, { color: uiTheme.ink.textPrimary, children: line });
|
|
78
|
+
};
|
|
79
|
+
return (_jsxs(AppFrame, { subtitle: "Results", description: `${jobName} (${jobSlug})`, statusText: `${cursor + 1} / ${totalItems}`, statusTone: "info", hints: ['←→ result', '↑↓ scroll', 'q quit'], children: [_jsx(Text, { color: qualificationColor, children: `${qualificationMark} ${qualificationText}` }), _jsxs(Text, { color: uiTheme.ink.info, children: [`${contentLinkLabel}: `, _jsx(Text, { color: uiTheme.ink.textPrimary, children: item.url })] }), _jsxs(Panel, { title: "Result", children: [aboveCount > 0 ? _jsxs(Text, { color: uiTheme.ink.textMuted, children: ["\u2191 ", aboveCount, " more"] }) : null, visibleContent.map((line, index) => (_jsx(React.Fragment, { children: renderLine(line, contentScrollTop + index) }, `${index}:${line.slice(0, 24)}`))), belowCount > 0 ? _jsxs(Text, { color: uiTheme.ink.textMuted, children: ["\u2193 ", belowCount, " more"] }) : null] })] }));
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=ResultsViewer.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { RunRow } from '../../services/db/repositories/runsRepo.js';
|
|
3
|
+
interface RunsTableProps {
|
|
4
|
+
totalRuns: number;
|
|
5
|
+
getRunAt: (index: number) => RunRow | null;
|
|
6
|
+
onExit: () => void;
|
|
7
|
+
}
|
|
8
|
+
export declare function RunsTable({ totalRuns, getRunAt, onExit }: RunsTableProps): React.JSX.Element;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useStdout } from 'ink';
|
|
4
|
+
import { formatRunDisplayTimestamp } from '../../cli/ui/time.js';
|
|
5
|
+
import { uiTheme } from '../theme.js';
|
|
6
|
+
import { AppFrame, Panel } from './AppFrame.js';
|
|
7
|
+
import { buildDetailLines, computeColumnWidths, computeScrollWindow, formatHeaderRow, formatTableRow } from './runsTableModel.js';
|
|
8
|
+
const WINDOW_SIZE = 10;
|
|
9
|
+
const COL_SEP = ' ';
|
|
10
|
+
export function RunsTable({ totalRuns, getRunAt, onExit }) {
|
|
11
|
+
const { stdout } = useStdout();
|
|
12
|
+
const terminalRows = stdout?.rows ?? 24;
|
|
13
|
+
// Respect terminal height: cap window so we leave room for header + footer
|
|
14
|
+
const maxWindow = Math.max(1, Math.min(WINDOW_SIZE, terminalRows - 8));
|
|
15
|
+
const [view, setView] = useState('list');
|
|
16
|
+
const [cursor, setCursor] = useState(0);
|
|
17
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
18
|
+
const currentRun = getRunAt(cursor);
|
|
19
|
+
const { scrollTop: nextScrollTop, visibleStart, visibleEnd } = computeScrollWindow(cursor, scrollTop, totalRuns, maxWindow);
|
|
20
|
+
const visibleRuns = [];
|
|
21
|
+
for (let index = visibleStart; index < visibleEnd; index += 1) {
|
|
22
|
+
const run = getRunAt(index);
|
|
23
|
+
if (run) {
|
|
24
|
+
visibleRuns.push(run);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const widthSample = currentRun ? [currentRun, ...visibleRuns] : visibleRuns;
|
|
28
|
+
const widths = computeColumnWidths(widthSample);
|
|
29
|
+
const header = formatHeaderRow(widths);
|
|
30
|
+
if (nextScrollTop !== scrollTop) {
|
|
31
|
+
setScrollTop(nextScrollTop);
|
|
32
|
+
}
|
|
33
|
+
const aboveCount = visibleStart;
|
|
34
|
+
const belowCount = totalRuns - visibleEnd;
|
|
35
|
+
useInput((input, key) => {
|
|
36
|
+
if (view === 'list') {
|
|
37
|
+
if (key.upArrow) {
|
|
38
|
+
const next = Math.max(0, cursor - 1);
|
|
39
|
+
setCursor(next);
|
|
40
|
+
const win = computeScrollWindow(next, scrollTop, totalRuns, maxWindow);
|
|
41
|
+
setScrollTop(win.scrollTop);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (key.downArrow) {
|
|
45
|
+
const next = Math.min(totalRuns - 1, cursor + 1);
|
|
46
|
+
setCursor(next);
|
|
47
|
+
const win = computeScrollWindow(next, scrollTop, totalRuns, maxWindow);
|
|
48
|
+
setScrollTop(win.scrollTop);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (key.return) {
|
|
52
|
+
setView('detail');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (input === 'q' || input === 'Q') {
|
|
56
|
+
onExit();
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// detail view
|
|
61
|
+
if (key.escape || key.leftArrow || input === 'q' || input === 'Q') {
|
|
62
|
+
if (key.escape || key.leftArrow) {
|
|
63
|
+
setView('list');
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
onExit();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
if (view === 'detail') {
|
|
71
|
+
const run = currentRun;
|
|
72
|
+
if (!run)
|
|
73
|
+
return _jsx(_Fragment, {});
|
|
74
|
+
const detailLines = buildDetailLines(run);
|
|
75
|
+
const title = `${formatRunDisplayTimestamp(run)} — ${run.jobName ?? run.jobId}`;
|
|
76
|
+
return (_jsx(AppFrame, { subtitle: "Run History", statusText: `${cursor + 1} / ${totalRuns}`, statusTone: "info", hints: ['Esc / ← back', 'q quit'], children: _jsx(Panel, { title: title, children: detailLines.map((line) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: uiTheme.ink.info, children: line.label.padEnd(12) }), _jsx(Text, { color: uiTheme.ink.textPrimary, children: line.value })] }, line.label))) }) }));
|
|
77
|
+
}
|
|
78
|
+
return (_jsx(AppFrame, { subtitle: "Run History", statusText: `${cursor + 1} / ${totalRuns}`, statusTone: "info", hints: ['↑↓ navigate', '↵ details', 'q quit'], children: _jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, children: [_jsx(Box, { flexDirection: "row", children: header.map((cell, i) => (_jsxs(Text, { bold: true, color: uiTheme.ink.accent, children: [i > 0 ? COL_SEP : '', cell] }, i))) }), aboveCount > 0 && (_jsxs(Text, { color: uiTheme.ink.textMuted, children: [" \u2191 ", aboveCount, " more"] })), visibleRuns.map((run, idx) => {
|
|
79
|
+
const absoluteIndex = visibleStart + idx;
|
|
80
|
+
const isSelected = absoluteIndex === cursor;
|
|
81
|
+
const cells = formatTableRow(run, widths);
|
|
82
|
+
const statusColor = run.status === 'completed'
|
|
83
|
+
? uiTheme.ink.success
|
|
84
|
+
: run.status === 'failed'
|
|
85
|
+
? uiTheme.ink.danger
|
|
86
|
+
: uiTheme.ink.textMuted;
|
|
87
|
+
return (_jsx(Box, { flexDirection: "row", children: cells.map((cell, i) => {
|
|
88
|
+
const color = isSelected
|
|
89
|
+
? uiTheme.ink.focus
|
|
90
|
+
: i === 5 // status column
|
|
91
|
+
? statusColor
|
|
92
|
+
: i === 0
|
|
93
|
+
? uiTheme.ink.textPrimary
|
|
94
|
+
: uiTheme.ink.textMuted;
|
|
95
|
+
return (_jsxs(Text, { color: color, inverse: isSelected && i === 0, bold: isSelected && i === 0, children: [i > 0 ? COL_SEP : '', cell] }, i));
|
|
96
|
+
}) }, run.id));
|
|
97
|
+
}), belowCount > 0 && (_jsxs(Text, { color: uiTheme.ink.textMuted, children: [" \u2193 ", belowCount, " more"] }))] }) }));
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=RunsTable.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { JobSummaryRow } from '../../services/db/repositories/jobsRepo.js';
|
|
2
|
+
import type { DetailLine } from './runsTableModel.js';
|
|
3
|
+
export { computeScrollWindow } from './runsTableModel.js';
|
|
4
|
+
export type { DetailLine };
|
|
5
|
+
export interface JobColumnWidths {
|
|
6
|
+
slug: number;
|
|
7
|
+
lastRun: number;
|
|
8
|
+
scanned: number;
|
|
9
|
+
qualified: number;
|
|
10
|
+
cost: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function computeJobColumnWidths(rows: JobSummaryRow[]): JobColumnWidths;
|
|
13
|
+
export declare function formatJobTableRow(row: JobSummaryRow, widths: JobColumnWidths): string[];
|
|
14
|
+
export declare function formatJobHeaderRow(widths: JobColumnWidths): string[];
|
|
15
|
+
export declare function buildJobDetailLines(row: JobSummaryRow): DetailLine[];
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { formatLocalTimestamp } from '../../cli/ui/time.js';
|
|
2
|
+
export { computeScrollWindow } from './runsTableModel.js';
|
|
3
|
+
const COL_MIN = { slug: 8, lastRun: 10, scanned: 7, qualified: 9, cost: 9 };
|
|
4
|
+
const COL_MAX = { slug: 32, lastRun: 22, scanned: 7, qualified: 9, cost: 12 };
|
|
5
|
+
function pad(value, width) {
|
|
6
|
+
if (value.length >= width)
|
|
7
|
+
return value.slice(0, width);
|
|
8
|
+
return value + ' '.repeat(width - value.length);
|
|
9
|
+
}
|
|
10
|
+
export function computeJobColumnWidths(rows) {
|
|
11
|
+
const clamp = (v, key) => Math.min(COL_MAX[key], Math.max(COL_MIN[key], v));
|
|
12
|
+
let slug = COL_MIN.slug;
|
|
13
|
+
let lastRun = COL_MIN.lastRun;
|
|
14
|
+
for (const row of rows) {
|
|
15
|
+
const slugLen = row.jobSlug.length;
|
|
16
|
+
if (slugLen > slug)
|
|
17
|
+
slug = slugLen;
|
|
18
|
+
const dateLen = row.lastRunAt ? formatLocalTimestamp(row.lastRunAt).length : 5; // 'never'
|
|
19
|
+
if (dateLen > lastRun)
|
|
20
|
+
lastRun = dateLen;
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
slug: clamp(slug, 'slug'),
|
|
24
|
+
lastRun: clamp(lastRun, 'lastRun'),
|
|
25
|
+
scanned: COL_MIN.scanned,
|
|
26
|
+
qualified: COL_MIN.qualified,
|
|
27
|
+
cost: COL_MIN.cost
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function formatCost(totalCostUsd) {
|
|
31
|
+
if (totalCostUsd === 0)
|
|
32
|
+
return '-';
|
|
33
|
+
return `$${totalCostUsd.toFixed(6)}`;
|
|
34
|
+
}
|
|
35
|
+
export function formatJobTableRow(row, widths) {
|
|
36
|
+
const lastRun = row.lastRunAt ? formatLocalTimestamp(row.lastRunAt) : 'never';
|
|
37
|
+
const cost = formatCost(row.totalCostUsd);
|
|
38
|
+
return [
|
|
39
|
+
pad(row.jobSlug, widths.slug),
|
|
40
|
+
pad(lastRun, widths.lastRun),
|
|
41
|
+
pad(String(row.totalScanned), widths.scanned),
|
|
42
|
+
pad(String(row.totalQualified), widths.qualified),
|
|
43
|
+
pad(cost, widths.cost)
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
export function formatJobHeaderRow(widths) {
|
|
47
|
+
return [
|
|
48
|
+
pad('Job Slug', widths.slug),
|
|
49
|
+
pad('Last Run', widths.lastRun),
|
|
50
|
+
pad('Scanned', widths.scanned),
|
|
51
|
+
pad('Qualified', widths.qualified),
|
|
52
|
+
pad('Cost', widths.cost)
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
// ── Detail view ───────────────────────────────────────────────────────────────
|
|
56
|
+
export function buildJobDetailLines(row) {
|
|
57
|
+
let subreddits = '-';
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(row.subredditsJson);
|
|
60
|
+
subreddits = parsed.map((s) => `r/${s}`).join(', ');
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
subreddits = row.subredditsJson;
|
|
64
|
+
}
|
|
65
|
+
return [
|
|
66
|
+
{ label: 'Slug', value: row.jobSlug },
|
|
67
|
+
{ label: 'Name', value: row.jobName },
|
|
68
|
+
{ label: 'Enabled', value: row.enabled !== 0 ? 'Yes' : 'No' },
|
|
69
|
+
{ label: 'Description', value: row.description },
|
|
70
|
+
{ label: 'Subreddits', value: subreddits },
|
|
71
|
+
{ label: 'Schedule', value: row.scheduleCron },
|
|
72
|
+
{ label: 'Runs', value: String(row.runCount) },
|
|
73
|
+
{ label: 'Last Run', value: row.lastRunAt ? formatLocalTimestamp(row.lastRunAt) : 'never' },
|
|
74
|
+
{ label: 'Scanned', value: String(row.totalScanned) },
|
|
75
|
+
{ label: 'Qualified', value: String(row.totalQualified) },
|
|
76
|
+
{ label: 'Cost', value: formatCost(row.totalCostUsd) }
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=jobsTableModel.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CommentThreadNodeRow, ScanItemRow } from '../../services/db/repositories/scanItemsRepo.js';
|
|
2
|
+
export interface ResultsViewerItem extends ScanItemRow {
|
|
3
|
+
commentThreadNodes: CommentThreadNodeRow[];
|
|
4
|
+
}
|
|
5
|
+
export interface DetailLine {
|
|
6
|
+
label: string;
|
|
7
|
+
value: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function wrapTextBlock(value: string, width: number): string[];
|
|
10
|
+
export declare function buildMetadataLines(item: ResultsViewerItem): DetailLine[];
|
|
11
|
+
export declare function buildContentLines(item: ResultsViewerItem, width: number): string[];
|
|
12
|
+
export declare function buildScrollableResultLines(item: ResultsViewerItem, width: number): string[];
|
|
13
|
+
export declare function nextItemIndex(current: number, direction: 'left' | 'right', total: number): number;
|
|
14
|
+
export declare function nextContentScrollTop(current: number, direction: 'up' | 'down', totalLines: number, windowSize: number): number;
|