@tacone/prosey 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -26
- package/bin/prosey +363 -51
- package/package.json +1 -1
- package/src/config-resolve.test.ts +104 -0
- package/src/config-resolve.ts +17 -0
- package/src/config.test.ts +3 -1
- package/src/config.ts +27 -18
- package/src/default-config.toml +41 -19
- package/src/extract-chapters.test.ts +229 -0
- package/src/extract-chapters.ts +66 -0
- package/src/index.test.ts +12 -0
- package/src/index.ts +275 -26
- package/src/pager.test.ts +1 -1
- package/src/pager.ts +2 -2
- package/src/summarize.test.ts +74 -3
- package/src/summarize.ts +18 -4
- package/src/version-check.ts +32 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
resolveSummarizeCmd,
|
|
4
|
+
resolveSummarizePrompt,
|
|
5
|
+
resolveTranscribeCmd,
|
|
6
|
+
resolveTranscribePrompt,
|
|
7
|
+
} from "./config-resolve";
|
|
8
|
+
import type { ProseyConfig } from "./config";
|
|
9
|
+
|
|
10
|
+
describe("resolveSummarizeCmd", () => {
|
|
11
|
+
test("returns summarize.command when present", () => {
|
|
12
|
+
const config: ProseyConfig = {
|
|
13
|
+
summarize: { command: "my-sum-cmd", prompt: "x" },
|
|
14
|
+
ai: { command: "ai-cmd" },
|
|
15
|
+
};
|
|
16
|
+
expect(resolveSummarizeCmd(config)).toBe("my-sum-cmd");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("falls back to ai.command when summarize.command is missing", () => {
|
|
20
|
+
const config: ProseyConfig = {
|
|
21
|
+
summarize: { prompt: "x" },
|
|
22
|
+
ai: { command: "ai-cmd" },
|
|
23
|
+
};
|
|
24
|
+
expect(resolveSummarizeCmd(config)).toBe("ai-cmd");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("returns null when no command is configured", () => {
|
|
28
|
+
const config: ProseyConfig = {};
|
|
29
|
+
expect(resolveSummarizeCmd(config)).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("resolveTranscribeCmd", () => {
|
|
34
|
+
test("returns transcribe.command when present", () => {
|
|
35
|
+
const config: ProseyConfig = {
|
|
36
|
+
transcribe: { command: "my-transcribe-cmd", prompt: "x" },
|
|
37
|
+
ai: { command: "ai-cmd" },
|
|
38
|
+
};
|
|
39
|
+
expect(resolveTranscribeCmd(config)).toBe("my-transcribe-cmd");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("falls back to ai.command when transcribe.command is missing", () => {
|
|
43
|
+
const config: ProseyConfig = {
|
|
44
|
+
ai: { command: "ai-cmd" },
|
|
45
|
+
transcribe: { prompt: "x" },
|
|
46
|
+
};
|
|
47
|
+
expect(resolveTranscribeCmd(config)).toBe("ai-cmd");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("falls back to summarize.command when both transcribe and ai are missing", () => {
|
|
51
|
+
const config: ProseyConfig = {
|
|
52
|
+
summarize: { command: "old-sum-cmd", prompt: "x" },
|
|
53
|
+
};
|
|
54
|
+
expect(resolveTranscribeCmd(config)).toBe("old-sum-cmd");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("returns null when no command is configured", () => {
|
|
58
|
+
const config: ProseyConfig = {};
|
|
59
|
+
expect(resolveTranscribeCmd(config)).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("resolveTranscribePrompt", () => {
|
|
64
|
+
test("returns transcribe.prompt when present", () => {
|
|
65
|
+
const config: ProseyConfig = {
|
|
66
|
+
transcribe: { prompt: "my-transcribe-prompt" },
|
|
67
|
+
};
|
|
68
|
+
expect(resolveTranscribePrompt(config)).toBe("my-transcribe-prompt");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("falls back to summarize.prompt when transcribe.prompt is missing", () => {
|
|
72
|
+
const config: ProseyConfig = {
|
|
73
|
+
summarize: { prompt: "my-sum-prompt" },
|
|
74
|
+
};
|
|
75
|
+
expect(resolveTranscribePrompt(config)).toBe("my-sum-prompt");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("returns null when neither transcribe nor summarize prompts exist", () => {
|
|
79
|
+
const config: ProseyConfig = {};
|
|
80
|
+
expect(resolveTranscribePrompt(config)).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("transcribe.prompt takes precedence over summarize.prompt", () => {
|
|
84
|
+
const config: ProseyConfig = {
|
|
85
|
+
transcribe: { prompt: "trans-prompt" },
|
|
86
|
+
summarize: { prompt: "sum-prompt" },
|
|
87
|
+
};
|
|
88
|
+
expect(resolveTranscribePrompt(config)).toBe("trans-prompt");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("resolveSummarizePrompt", () => {
|
|
93
|
+
test("returns summarize.prompt when present", () => {
|
|
94
|
+
const config: ProseyConfig = {
|
|
95
|
+
summarize: { prompt: "my-sum-prompt" },
|
|
96
|
+
};
|
|
97
|
+
expect(resolveSummarizePrompt(config)).toBe("my-sum-prompt");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("returns null when summarize.prompt is missing", () => {
|
|
101
|
+
const config: ProseyConfig = {};
|
|
102
|
+
expect(resolveSummarizePrompt(config)).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ProseyConfig } from "./config";
|
|
2
|
+
|
|
3
|
+
export function resolveSummarizeCmd(config: ProseyConfig): string | null {
|
|
4
|
+
return config.summarize?.command ?? config.ai?.command ?? null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function resolveSummarizePrompt(config: ProseyConfig): string | null {
|
|
8
|
+
return config.summarize?.prompt ?? null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function resolveTranscribeCmd(config: ProseyConfig): string | null {
|
|
12
|
+
return config.transcribe?.command ?? config.ai?.command ?? config.summarize?.command ?? null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveTranscribePrompt(config: ProseyConfig): string | null {
|
|
16
|
+
return config.transcribe?.prompt ?? config.summarize?.prompt ?? null;
|
|
17
|
+
}
|
package/src/config.test.ts
CHANGED
|
@@ -53,7 +53,9 @@ describe("loadConfig", () => {
|
|
|
53
53
|
expect(existsSync(tmpConfig)).toBe(true);
|
|
54
54
|
|
|
55
55
|
const content = await readFile(tmpConfig, "utf8");
|
|
56
|
+
expect(content).toContain("[ai]");
|
|
56
57
|
expect(content).toContain("[summarize]");
|
|
58
|
+
expect(content).toContain("[transcribe]");
|
|
57
59
|
expect(content).toContain("command = ");
|
|
58
60
|
});
|
|
59
61
|
|
|
@@ -63,7 +65,7 @@ describe("loadConfig", () => {
|
|
|
63
65
|
await loadConfig();
|
|
64
66
|
const config = await loadConfig();
|
|
65
67
|
expect(config.summarize?.prompt).toBeString();
|
|
66
|
-
expect(config.
|
|
68
|
+
expect(config.ai?.command).toBeString();
|
|
67
69
|
});
|
|
68
70
|
|
|
69
71
|
test("handles invalid TOML gracefully", async () => {
|
package/src/config.ts
CHANGED
|
@@ -8,17 +8,24 @@ import { load } from "js-toml";
|
|
|
8
8
|
export interface ProseyConfig {
|
|
9
9
|
pager?: string;
|
|
10
10
|
hints?: boolean;
|
|
11
|
+
ai?: {
|
|
12
|
+
command?: string;
|
|
13
|
+
};
|
|
11
14
|
summarize?: {
|
|
12
15
|
prompt?: string;
|
|
13
16
|
command?: string;
|
|
14
17
|
};
|
|
18
|
+
transcribe?: {
|
|
19
|
+
prompt?: string;
|
|
20
|
+
command?: string;
|
|
21
|
+
};
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
const FALLBACK_CONFIG_TOML = `# Default prosey configuration
|
|
18
25
|
# Created automatically on first run. Edit as needed.
|
|
19
26
|
|
|
20
27
|
# Pager command for transcript and summary output.
|
|
21
|
-
# Defaults to "auto": bat -lmd → glow → mdcat -l -p → less
|
|
28
|
+
# Defaults to "auto": bat -lmd --style plain → glow -p → mdcat -l -p → less
|
|
22
29
|
# Set to a custom command (e.g. "less -R") to override.
|
|
23
30
|
# Can also be set via the PROSEY_PAGER env var (takes precedence).
|
|
24
31
|
pager = "auto"
|
|
@@ -27,6 +34,11 @@ pager = "auto"
|
|
|
27
34
|
# Can also be set via PROSEY_HINTS env var (yes, no, 1, 0, true, false).
|
|
28
35
|
hints = true
|
|
29
36
|
|
|
37
|
+
[ai]
|
|
38
|
+
# Default command for AI operations (summarize, transcribe).
|
|
39
|
+
# Can be overridden per-section via the command key below.
|
|
40
|
+
command = "opencode run"
|
|
41
|
+
|
|
30
42
|
[summarize]
|
|
31
43
|
# Prompt sent to the command via stdin.
|
|
32
44
|
# Customize this to change how transcripts are summarized.
|
|
@@ -34,23 +46,20 @@ prompt = """
|
|
|
34
46
|
Write a comprehensive summary of the following transcription.
|
|
35
47
|
"""
|
|
36
48
|
|
|
37
|
-
# Command
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
# codex --sandbox default -p "" — full access
|
|
52
|
-
# codex --sandbox read-only -p "" — read-only
|
|
53
|
-
command = "opencode run"
|
|
49
|
+
# Command override for summarize. Uncomment to use a different command
|
|
50
|
+
# than the one specified in [ai].
|
|
51
|
+
# command = "opencode run"
|
|
52
|
+
|
|
53
|
+
[transcribe]
|
|
54
|
+
# Prompt sent to the command via stdin.
|
|
55
|
+
# Customize this to change how transcripts are formatted as markdown.
|
|
56
|
+
prompt = """
|
|
57
|
+
Convert this transcript to clean, readable markdown.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
# Command override for transcribe. Uncomment to use a different command
|
|
61
|
+
# than the one specified in [ai].
|
|
62
|
+
# command = "opencode run"
|
|
54
63
|
`;
|
|
55
64
|
|
|
56
65
|
async function readDefaultConfig(): Promise<string> {
|
package/src/default-config.toml
CHANGED
|
@@ -2,15 +2,27 @@
|
|
|
2
2
|
# Created automatically on first run. Edit as needed.
|
|
3
3
|
|
|
4
4
|
# Pager command for transcript and summary output.
|
|
5
|
-
# Defaults to "auto": bat -lmd → glow → mdcat -l -p → less
|
|
5
|
+
# Defaults to "auto": bat -lmd --style plain → glow -p → mdcat -l -p → less
|
|
6
6
|
# Set to a custom command (e.g. "less -R") to override.
|
|
7
7
|
# Can also be set via the PROSEY_PAGER env var (takes precedence).
|
|
8
8
|
pager = "auto"
|
|
9
9
|
|
|
10
|
-
# Show hints
|
|
10
|
+
# Show hints when available
|
|
11
11
|
# Can also be set via PROSEY_HINTS env var (yes, no, 1, 0, true, false).
|
|
12
12
|
hints = true
|
|
13
13
|
|
|
14
|
+
[ai]
|
|
15
|
+
|
|
16
|
+
# Default command for AI operations (summarize, transcribe).
|
|
17
|
+
# Can be overridden per-section via the command key below.
|
|
18
|
+
|
|
19
|
+
# Examples:
|
|
20
|
+
#
|
|
21
|
+
# OPENCODE_PERMISSION='{"read":"allow","write":"deny","edit":"deny","bash":"deny","glob":"deny","grep":"deny","webfetch":\"deny\",\"task\":\"deny\",\"todowrite\":\"deny\",\"websearch\":\"deny","lsp":"deny"}' opencode run --pure
|
|
22
|
+
# copilot -s --deny-all-tools --read-only
|
|
23
|
+
|
|
24
|
+
command = "OPENCODE_PERMISSION='{\"read\":\"allow\",\"write\":\"deny\",\"edit\":\"deny\",\"bash\":\"deny\",\"glob\":\"deny\",\"grep\":\"deny\",\"webfetch\":\"deny\",\"task\":\"deny\",\"todowrite\":\"deny\",\"websearch\":\"deny\",\"lsp\":\"deny\"}' opencode run --pure --variant low"
|
|
25
|
+
|
|
14
26
|
[summarize]
|
|
15
27
|
|
|
16
28
|
# Prompt sent to the command via stdin.
|
|
@@ -20,20 +32,30 @@ prompt = """
|
|
|
20
32
|
Write a comprehensive summary of the following transcription.
|
|
21
33
|
"""
|
|
22
34
|
|
|
23
|
-
# Command
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
# Command override for summarize. Uncomment to use a different command
|
|
36
|
+
# than the one specified in [ai].
|
|
37
|
+
# command = "opencode run"
|
|
38
|
+
|
|
39
|
+
[transcribe]
|
|
40
|
+
|
|
41
|
+
# Prompt sent to the command via stdin.
|
|
42
|
+
# Customize this to change how transcripts are formatted as markdown.
|
|
43
|
+
|
|
44
|
+
prompt = """
|
|
45
|
+
Format the following text using markdown. Do not rephrase.
|
|
46
|
+
|
|
47
|
+
- use headers
|
|
48
|
+
- use the INFO section to correct spellings in the text (e.g. Pi vs Pie)
|
|
49
|
+
- use the INFO Title field as the main title
|
|
50
|
+
- use timestamps (if available) as second level headers (just text, no time value)
|
|
51
|
+
- format the text appropriately adding paragraphs, blockquote where it applies
|
|
52
|
+
- aim for max 3-5 sentences (or 350-400 chars) for paragraph on average
|
|
53
|
+
- consider using bold for technical term/proper noun repeated multiple times
|
|
54
|
+
- don't overthink
|
|
55
|
+
|
|
56
|
+
Only output the formatted text without adding anything.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
# Command override for transcribe. Uncomment to use a different command
|
|
60
|
+
# than the one specified in [ai].
|
|
61
|
+
# command = "opencode run"
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { extractChapters, formatChaptersAsText, formatChaptersAsJson } from "./extract-chapters";
|
|
3
|
+
|
|
4
|
+
describe("extractChapters", () => {
|
|
5
|
+
test("extracts chapters from standard format", () => {
|
|
6
|
+
const desc = `00:00 Intro
|
|
7
|
+
01:00 Karpathy's Viral Tweet
|
|
8
|
+
02:26 The Age of Meat Computers`;
|
|
9
|
+
const chapters = extractChapters(desc);
|
|
10
|
+
expect(chapters).toEqual([
|
|
11
|
+
{ time: 0, title: "Intro" },
|
|
12
|
+
{ time: 60, title: "Karpathy's Viral Tweet" },
|
|
13
|
+
{ time: 146, title: "The Age of Meat Computers" },
|
|
14
|
+
]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("handles single-digit minutes (M:SS)", () => {
|
|
18
|
+
const desc = `0:00 Intro
|
|
19
|
+
1:30 First Topic`;
|
|
20
|
+
const chapters = extractChapters(desc);
|
|
21
|
+
expect(chapters).toEqual([
|
|
22
|
+
{ time: 0, title: "Intro" },
|
|
23
|
+
{ time: 90, title: "First Topic" },
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("handles hours (HH:MM:SS)", () => {
|
|
28
|
+
const desc = `00:00:00 Start
|
|
29
|
+
01:30:00 Midway
|
|
30
|
+
02:15:45 End`;
|
|
31
|
+
const chapters = extractChapters(desc);
|
|
32
|
+
expect(chapters).toEqual([
|
|
33
|
+
{ time: 0, title: "Start" },
|
|
34
|
+
{ time: 5400, title: "Midway" },
|
|
35
|
+
{ time: 8145, title: "End" },
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("handles brackets [MM:SS]", () => {
|
|
40
|
+
const desc = `[00:00] Intro
|
|
41
|
+
[01:00] Topic`;
|
|
42
|
+
const chapters = extractChapters(desc);
|
|
43
|
+
expect(chapters).toEqual([
|
|
44
|
+
{ time: 0, title: "Intro" },
|
|
45
|
+
{ time: 60, title: "Topic" },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("handles parentheses (MM:SS)", () => {
|
|
50
|
+
const desc = `(00:00) Intro
|
|
51
|
+
(01:00) Topic`;
|
|
52
|
+
const chapters = extractChapters(desc);
|
|
53
|
+
expect(chapters).toEqual([
|
|
54
|
+
{ time: 0, title: "Intro" },
|
|
55
|
+
{ time: 60, title: "Topic" },
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("handles dash separator", () => {
|
|
60
|
+
const desc = `00:00 - Intro
|
|
61
|
+
01:00 - Topic`;
|
|
62
|
+
const chapters = extractChapters(desc);
|
|
63
|
+
expect(chapters).toEqual([
|
|
64
|
+
{ time: 0, title: "Intro" },
|
|
65
|
+
{ time: 60, title: "Topic" },
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("handles en-dash separator", () => {
|
|
70
|
+
const desc = `00:00 – Intro\n01:00 – Topic`;
|
|
71
|
+
const chapters = extractChapters(desc);
|
|
72
|
+
expect(chapters).toEqual([
|
|
73
|
+
{ time: 0, title: "Intro" },
|
|
74
|
+
{ time: 60, title: "Topic" },
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("handles em-dash separator", () => {
|
|
79
|
+
const desc = `00:00 — Intro\n01:00 — Topic`;
|
|
80
|
+
const chapters = extractChapters(desc);
|
|
81
|
+
expect(chapters).toEqual([
|
|
82
|
+
{ time: 0, title: "Intro" },
|
|
83
|
+
{ time: 60, title: "Topic" },
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("handles colon separator", () => {
|
|
88
|
+
const desc = `00:00: Intro\n01:00: Topic`;
|
|
89
|
+
const chapters = extractChapters(desc);
|
|
90
|
+
expect(chapters).toEqual([
|
|
91
|
+
{ time: 0, title: "Intro" },
|
|
92
|
+
{ time: 60, title: "Topic" },
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("handles dot separator", () => {
|
|
97
|
+
const desc = `00:00 . Intro\n01:00 . Topic`;
|
|
98
|
+
const chapters = extractChapters(desc);
|
|
99
|
+
expect(chapters).toEqual([
|
|
100
|
+
{ time: 0, title: "Intro" },
|
|
101
|
+
{ time: 60, title: "Topic" },
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("strips leading whitespace", () => {
|
|
106
|
+
const desc = ` 00:00 Intro
|
|
107
|
+
01:00 Indented Topic`;
|
|
108
|
+
const chapters = extractChapters(desc);
|
|
109
|
+
expect(chapters).toEqual([
|
|
110
|
+
{ time: 0, title: "Intro" },
|
|
111
|
+
{ time: 60, title: "Indented Topic" },
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("sorts by time order even if input is out of order", () => {
|
|
116
|
+
const desc = `03:00 Last
|
|
117
|
+
01:00 Middle
|
|
118
|
+
00:00 First`;
|
|
119
|
+
const chapters = extractChapters(desc);
|
|
120
|
+
expect(chapters.map((c) => c.title)).toEqual(["First", "Middle", "Last"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("deduplicates by time (keeps first)", () => {
|
|
124
|
+
const desc = `01:00 First
|
|
125
|
+
01:00 Second
|
|
126
|
+
02:00 Third`;
|
|
127
|
+
const chapters = extractChapters(desc);
|
|
128
|
+
expect(chapters).toEqual([
|
|
129
|
+
{ time: 60, title: "First" },
|
|
130
|
+
{ time: 120, title: "Third" },
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("skips malformed lines", () => {
|
|
135
|
+
const desc = `This is not a chapter
|
|
136
|
+
00:00 Real Chapter
|
|
137
|
+
Just some text without a timestamp
|
|
138
|
+
01:30 Another Chapter`;
|
|
139
|
+
const chapters = extractChapters(desc);
|
|
140
|
+
expect(chapters).toEqual([
|
|
141
|
+
{ time: 0, title: "Real Chapter" },
|
|
142
|
+
{ time: 90, title: "Another Chapter" },
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("skips timestamp with no title", () => {
|
|
147
|
+
const desc = `00:00
|
|
148
|
+
01:00 Title`;
|
|
149
|
+
const chapters = extractChapters(desc);
|
|
150
|
+
expect(chapters).toEqual([{ time: 60, title: "Title" }]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("returns empty array for empty description", () => {
|
|
154
|
+
expect(extractChapters("")).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("returns empty array for description with no timestamps", () => {
|
|
158
|
+
const desc = `Hello world
|
|
159
|
+
This is a video description
|
|
160
|
+
With no timestamps at all`;
|
|
161
|
+
expect(extractChapters(desc)).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("extracts the sample format from the user", () => {
|
|
165
|
+
const desc = `00:00 Intro
|
|
166
|
+
01:00 Karpathy's Viral Tweet
|
|
167
|
+
01:41 The Overnight 11% Breakthrough
|
|
168
|
+
02:26 "The Age of Meat Computers Is Over"
|
|
169
|
+
03:03 85,000 Stars & Shopify's Results
|
|
170
|
+
04:07 Gary Tan & Applying It to Business
|
|
171
|
+
04:53 Chamath's Content Engine Use Case
|
|
172
|
+
05:32 How the System Works: The 3 Files
|
|
173
|
+
06:27 The Self-Improvement Loop Explained
|
|
174
|
+
07:42 701x Faster: 36,500 Experiments a Year
|
|
175
|
+
08:24 The 3 Must-Have Rules
|
|
176
|
+
10:11 The 3 Nice-to-Haves
|
|
177
|
+
11:51 What You Can Point It At
|
|
178
|
+
14:35 The One-Line Master Prompt
|
|
179
|
+
15:06 Live Test 1: A Faster Website (800ms to 90ms)
|
|
180
|
+
17:34 Live Test 2: Cold Email Subject Lines
|
|
181
|
+
19:55 Live Test 3: Facebook Ads on Autopilot`;
|
|
182
|
+
const chapters = extractChapters(desc);
|
|
183
|
+
expect(chapters).toHaveLength(17);
|
|
184
|
+
expect(chapters[0]).toEqual({ time: 0, title: "Intro" });
|
|
185
|
+
expect(chapters[16]).toEqual({
|
|
186
|
+
time: 1195,
|
|
187
|
+
title: "Live Test 3: Facebook Ads on Autopilot",
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("formatChaptersAsJson", () => {
|
|
193
|
+
test("formats chapters as key-value JSON", () => {
|
|
194
|
+
const chapters = [
|
|
195
|
+
{ time: 0, title: "Intro" },
|
|
196
|
+
{ time: 90, title: "Chapter 1" },
|
|
197
|
+
];
|
|
198
|
+
const result = JSON.parse(formatChaptersAsJson(chapters));
|
|
199
|
+
expect(result).toEqual({ "00:00": "Intro", "01:30": "Chapter 1" });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("formats chapters with hours", () => {
|
|
203
|
+
const chapters = [{ time: 3661, title: "After 1 Hour" }];
|
|
204
|
+
const result = JSON.parse(formatChaptersAsJson(chapters));
|
|
205
|
+
expect(result).toEqual({ "01:01:01": "After 1 Hour" });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("returns not available for empty chapters", () => {
|
|
209
|
+
expect(formatChaptersAsJson([])).toBe("not available");
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("formatChaptersAsText", () => {
|
|
214
|
+
test("formats chapters without hours", () => {
|
|
215
|
+
const chapters = [
|
|
216
|
+
{ time: 0, title: "Intro" },
|
|
217
|
+
{ time: 90, title: "Chapter 1" },
|
|
218
|
+
];
|
|
219
|
+
expect(formatChaptersAsText(chapters)).toBe("00:00 Intro\n01:30 Chapter 1");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("formats chapters with hours", () => {
|
|
223
|
+
const chapters = [
|
|
224
|
+
{ time: 0, title: "Start" },
|
|
225
|
+
{ time: 3661, title: "After 1 Hour" },
|
|
226
|
+
];
|
|
227
|
+
expect(formatChaptersAsText(chapters)).toBe("00:00 Start\n01:01:01 After 1 Hour");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface Chapter {
|
|
2
|
+
time: number;
|
|
3
|
+
title: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const lineRegex =
|
|
7
|
+
/^\s*[\[\(]?(?:(?:(\d{1,2}):)?(\d{1,2}):(\d{2}))[\]\)]?(?:\s*[-–—:.]\s*|\s+)(.+)$/;
|
|
8
|
+
|
|
9
|
+
export function extractChapters(description: string): Chapter[] {
|
|
10
|
+
const lines = description.split("\n");
|
|
11
|
+
const chapters: Chapter[] = [];
|
|
12
|
+
|
|
13
|
+
for (const line of lines) {
|
|
14
|
+
const match = line.match(lineRegex);
|
|
15
|
+
if (!match) continue;
|
|
16
|
+
|
|
17
|
+
const hours = match[1] ? parseInt(match[1], 10) : 0;
|
|
18
|
+
const minutes = parseInt(match[2]!, 10);
|
|
19
|
+
const seconds = parseInt(match[3]!, 10);
|
|
20
|
+
const title = match[4]!.trim();
|
|
21
|
+
if (!title) continue;
|
|
22
|
+
|
|
23
|
+
const time = hours * 3600 + minutes * 60 + seconds;
|
|
24
|
+
chapters.push({ time, title });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
chapters.sort((a, b) => a.time - b.time);
|
|
28
|
+
|
|
29
|
+
const seen = new Set<number>();
|
|
30
|
+
return chapters.filter((c) => {
|
|
31
|
+
if (seen.has(c.time)) return false;
|
|
32
|
+
seen.add(c.time);
|
|
33
|
+
return true;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatChaptersAsJson(chapters: Chapter[]): string {
|
|
38
|
+
if (chapters.length === 0) return "not available";
|
|
39
|
+
const obj: Record<string, string> = {};
|
|
40
|
+
for (const ch of chapters) {
|
|
41
|
+
const h = Math.floor(ch.time / 3600);
|
|
42
|
+
const m = Math.floor((ch.time % 3600) / 60);
|
|
43
|
+
const s = ch.time % 60;
|
|
44
|
+
const key =
|
|
45
|
+
h > 0
|
|
46
|
+
? `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
|
|
47
|
+
: `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
48
|
+
obj[key] = ch.title;
|
|
49
|
+
}
|
|
50
|
+
return JSON.stringify(obj);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function formatChaptersAsText(chapters: Chapter[]): string {
|
|
54
|
+
return chapters
|
|
55
|
+
.map((c) => {
|
|
56
|
+
const h = Math.floor(c.time / 3600);
|
|
57
|
+
const m = Math.floor((c.time % 3600) / 60);
|
|
58
|
+
const s = c.time % 60;
|
|
59
|
+
const timeStr =
|
|
60
|
+
h > 0
|
|
61
|
+
? `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
|
|
62
|
+
: `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
63
|
+
return `${timeStr} ${c.title}`;
|
|
64
|
+
})
|
|
65
|
+
.join("\n");
|
|
66
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { $ } from "bun";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const BIN = join(import.meta.dir, "..", "bin", "prosey");
|
|
6
|
+
|
|
7
|
+
describe("dry-run", () => {
|
|
8
|
+
test("is listed in help text", async () => {
|
|
9
|
+
const { stdout } = await $`${BIN} --help`.quiet();
|
|
10
|
+
expect(stdout.toString()).toContain("--dry-run");
|
|
11
|
+
});
|
|
12
|
+
});
|