@clubnet/seedclub 0.2.32 → 0.2.34
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/assets/extensions/seedclub/auth.ts +21 -21
- package/assets/extensions/seedclub/commands/seedclub.ts +5 -4
- package/assets/extensions/seedclub/commands/transcript-export-helpers.js +354 -0
- package/assets/extensions/seedclub/commands/transcript-export.test.js +59 -0
- package/assets/extensions/seedclub/commands/transcript-export.ts +656 -0
- package/assets/extensions/seedclub/commands/transcript-intent.ts +6 -986
- package/assets/extensions/seedclub/commands/transcripts.ts +16 -241
- package/assets/extensions/seedclub/index.ts +2 -4
- package/assets/extensions/seedclub/tool-utils.ts +60 -22
- package/assets/extensions/seedclub/tools/media.ts +569 -0
- package/assets/extensions/seedclub/tools/meetings.ts +50 -1
- package/assets/extensions/seedclub/ui-copy.ts +7 -21
- package/package.json +3 -2
|
@@ -20,8 +20,6 @@ const DEFAULT_AUTH_BASE = "https://auth.seedclub.com";
|
|
|
20
20
|
const LOCAL_API_BASE = "http://localhost:3001";
|
|
21
21
|
const LOCAL_AUTH_BASE = "http://localhost:3000";
|
|
22
22
|
|
|
23
|
-
export type SeedclubEnvironmentMode = "local" | "prod" | "custom";
|
|
24
|
-
|
|
25
23
|
export interface StoredToken {
|
|
26
24
|
token: string;
|
|
27
25
|
email: string;
|
|
@@ -34,8 +32,6 @@ export interface StoredToken {
|
|
|
34
32
|
export interface StoredBases {
|
|
35
33
|
apiBase: string;
|
|
36
34
|
authBase: string;
|
|
37
|
-
mode: SeedclubEnvironmentMode;
|
|
38
|
-
updatedAt: string;
|
|
39
35
|
}
|
|
40
36
|
|
|
41
37
|
let _cachedApiBase: string | null = null;
|
|
@@ -50,6 +46,23 @@ function shouldPreferLocalBases(): boolean {
|
|
|
50
46
|
);
|
|
51
47
|
}
|
|
52
48
|
|
|
49
|
+
function normalizeStoredBases(value: unknown): StoredBases | null {
|
|
50
|
+
if (!value || typeof value !== "object") {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const stored = value as Partial<StoredBases>;
|
|
55
|
+
|
|
56
|
+
if (typeof stored.apiBase !== "string" || typeof stored.authBase !== "string") {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
apiBase: stored.apiBase,
|
|
62
|
+
authBase: stored.authBase,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
53
66
|
function tryReadStoredBasesSync(): StoredBases | null {
|
|
54
67
|
if (_cachedBases !== undefined) {
|
|
55
68
|
return _cachedBases;
|
|
@@ -57,13 +70,8 @@ function tryReadStoredBasesSync(): StoredBases | null {
|
|
|
57
70
|
|
|
58
71
|
try {
|
|
59
72
|
const content = readFileSync(BASES_FILE, "utf-8");
|
|
60
|
-
const stored = JSON.parse(content)
|
|
61
|
-
if (
|
|
62
|
-
!stored ||
|
|
63
|
-
typeof stored.apiBase !== "string" ||
|
|
64
|
-
typeof stored.authBase !== "string" ||
|
|
65
|
-
typeof stored.mode !== "string"
|
|
66
|
-
) {
|
|
73
|
+
const stored = normalizeStoredBases(JSON.parse(content));
|
|
74
|
+
if (!stored) {
|
|
67
75
|
_cachedBases = null;
|
|
68
76
|
return null;
|
|
69
77
|
}
|
|
@@ -158,13 +166,8 @@ export async function getStoredToken(): Promise<StoredToken | null> {
|
|
|
158
166
|
export async function getStoredBases(): Promise<StoredBases | null> {
|
|
159
167
|
try {
|
|
160
168
|
const content = await readFile(BASES_FILE, "utf-8");
|
|
161
|
-
const stored = JSON.parse(content)
|
|
162
|
-
if (
|
|
163
|
-
!stored ||
|
|
164
|
-
typeof stored.apiBase !== "string" ||
|
|
165
|
-
typeof stored.authBase !== "string" ||
|
|
166
|
-
typeof stored.mode !== "string"
|
|
167
|
-
) {
|
|
169
|
+
const stored = normalizeStoredBases(JSON.parse(content));
|
|
170
|
+
if (!stored) {
|
|
168
171
|
return null;
|
|
169
172
|
}
|
|
170
173
|
_cachedBases = stored;
|
|
@@ -183,13 +186,10 @@ export async function getToken(): Promise<string | null> {
|
|
|
183
186
|
export async function storeBases(
|
|
184
187
|
apiBase: string,
|
|
185
188
|
authBase: string,
|
|
186
|
-
mode: SeedclubEnvironmentMode = "custom",
|
|
187
189
|
): Promise<void> {
|
|
188
190
|
const stored: StoredBases = {
|
|
189
191
|
apiBase,
|
|
190
192
|
authBase,
|
|
191
|
-
mode,
|
|
192
|
-
updatedAt: new Date().toISOString(),
|
|
193
193
|
};
|
|
194
194
|
_cachedApiBase = apiBase;
|
|
195
195
|
_cachedAuthBase = authBase;
|
|
@@ -41,7 +41,6 @@ async function showSeedEnvironment(ctx: any) {
|
|
|
41
41
|
const lines = [
|
|
42
42
|
`effective api: ${getApiBase()}`,
|
|
43
43
|
`effective auth: ${getAuthBase()}`,
|
|
44
|
-
`stored mode: ${storedBases?.mode ?? "none"}`,
|
|
45
44
|
`stored api: ${storedBases?.apiBase ?? "none"}`,
|
|
46
45
|
`stored auth: ${storedBases?.authBase ?? "none"}`,
|
|
47
46
|
];
|
|
@@ -50,7 +49,7 @@ async function showSeedEnvironment(ctx: any) {
|
|
|
50
49
|
|
|
51
50
|
async function setSeedEnvironment(mode: "local" | "prod", ctx: any) {
|
|
52
51
|
const bases = getDefaultBases(mode);
|
|
53
|
-
await storeBases(bases.apiBase, bases.authBase
|
|
52
|
+
await storeBases(bases.apiBase, bases.authBase);
|
|
54
53
|
ctx.ui.notify(
|
|
55
54
|
`Seed Club environment set to ${mode}.\napi: ${bases.apiBase}\nauth: ${bases.authBase}\nReconnect if your current token belongs to a different environment.`,
|
|
56
55
|
"info",
|
|
@@ -113,13 +112,15 @@ export function registerSeedclubCommand(pi: ExtensionAPI, deps: SeedclubDeps) {
|
|
|
113
112
|
const isConnected = !!stored || hasEnvToken;
|
|
114
113
|
|
|
115
114
|
if (!isConnected) {
|
|
116
|
-
|
|
115
|
+
await deps.connect(args, ctx);
|
|
116
|
+
return;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
const user = await getCurrentUser();
|
|
120
120
|
if ("error" in user) {
|
|
121
121
|
ctx.ui.notify("Session expired. Reconnecting...", "info");
|
|
122
|
-
|
|
122
|
+
await deps.connect(args, ctx);
|
|
123
|
+
return;
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
const session = await getSessionContext();
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
function normalizeWhitespace(value) {
|
|
2
|
+
return value.replace(/[\u2018\u2019]/g, "'").replace(/\s+/g, " ").trim();
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
const NON_PERSON_QUERIES = new Set([
|
|
6
|
+
"both",
|
|
7
|
+
"all",
|
|
8
|
+
"everyone",
|
|
9
|
+
"everybody",
|
|
10
|
+
"anyone",
|
|
11
|
+
"anybody",
|
|
12
|
+
"each",
|
|
13
|
+
"each of these",
|
|
14
|
+
"all of these",
|
|
15
|
+
"these",
|
|
16
|
+
"those",
|
|
17
|
+
"this",
|
|
18
|
+
"them",
|
|
19
|
+
"the above",
|
|
20
|
+
"above",
|
|
21
|
+
"interviews",
|
|
22
|
+
"calls",
|
|
23
|
+
"meetings",
|
|
24
|
+
"conversations",
|
|
25
|
+
"episodes",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const NON_PERSON_PREFIXES = new Set([
|
|
29
|
+
"i",
|
|
30
|
+
"we",
|
|
31
|
+
"download",
|
|
32
|
+
"get",
|
|
33
|
+
"grab",
|
|
34
|
+
"pull",
|
|
35
|
+
"save",
|
|
36
|
+
"export",
|
|
37
|
+
"need",
|
|
38
|
+
"want",
|
|
39
|
+
"send",
|
|
40
|
+
"give",
|
|
41
|
+
"retrieve",
|
|
42
|
+
"latest",
|
|
43
|
+
"last",
|
|
44
|
+
"most",
|
|
45
|
+
"recent",
|
|
46
|
+
"the",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const NON_PERSON_TOKENS = new Set([
|
|
50
|
+
"i",
|
|
51
|
+
"we",
|
|
52
|
+
"me",
|
|
53
|
+
"us",
|
|
54
|
+
"need",
|
|
55
|
+
"want",
|
|
56
|
+
"download",
|
|
57
|
+
"export",
|
|
58
|
+
"get",
|
|
59
|
+
"last",
|
|
60
|
+
"latest",
|
|
61
|
+
"recent",
|
|
62
|
+
"stream",
|
|
63
|
+
"show",
|
|
64
|
+
"11am",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
function trimQuotes(value) {
|
|
68
|
+
return value.replace(/^['\"]|['\"]$/g, "");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizePersonQuery(raw) {
|
|
72
|
+
if (!raw) return undefined;
|
|
73
|
+
const cleaned = normalizeWhitespace(raw)
|
|
74
|
+
.replace(/\b(?:interviews?|calls?|meetings?|conversations?|episodes?|transcripts?|vtts?|stream|show)\b/gi, "")
|
|
75
|
+
.replace(/\b(?:each|all)\s+of\s+(?:these|those|them)\b/gi, "")
|
|
76
|
+
.replace(/\b(?:on|at|in|from)\b\s*$/i, "")
|
|
77
|
+
.trim();
|
|
78
|
+
if (!cleaned) return undefined;
|
|
79
|
+
|
|
80
|
+
const lower = cleaned.toLowerCase();
|
|
81
|
+
if (NON_PERSON_QUERIES.has(lower)) return undefined;
|
|
82
|
+
if (
|
|
83
|
+
/\b(today(?:'s|s)?|yesterday(?:'s|s)?|tomorrow(?:'s|s)?|tonight|morning|afternoon|evening|monday|mon|tuesday|tues|tue|toesday|wednesday|wed|thursday|thurs|thu|friday|fri|saturday|sat|sunday|sun|latest|recent|last)\b/.test(
|
|
84
|
+
lower,
|
|
85
|
+
)
|
|
86
|
+
) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
if (/^\d{1,2}(?::\d{2})?\s*(?:am|pm)$/.test(lower)) return undefined;
|
|
90
|
+
if (/^(?:these|those|this|them|each|the\s+above|above)$/.test(lower)) return undefined;
|
|
91
|
+
|
|
92
|
+
const parts = lower.split(/\s+/).filter(Boolean);
|
|
93
|
+
if (!parts.length) return undefined;
|
|
94
|
+
|
|
95
|
+
const firstWord = parts[0];
|
|
96
|
+
if (NON_PERSON_PREFIXES.has(firstWord)) return undefined;
|
|
97
|
+
if (parts.some((part) => NON_PERSON_TOKENS.has(part))) return undefined;
|
|
98
|
+
|
|
99
|
+
return cleaned;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function formatLocalDate(date) {
|
|
103
|
+
const year = date.getFullYear();
|
|
104
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
105
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
106
|
+
return `${year}-${month}-${day}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function addLocalDays(date, days) {
|
|
110
|
+
const next = new Date(date);
|
|
111
|
+
next.setDate(next.getDate() + days);
|
|
112
|
+
return next;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseRelativeDateHint(text) {
|
|
116
|
+
const lower = normalizeWhitespace(text).toLowerCase();
|
|
117
|
+
const today = new Date();
|
|
118
|
+
|
|
119
|
+
if (/\b(today(?:'s|s)?|tonight)\b/.test(lower)) return formatLocalDate(today);
|
|
120
|
+
if (/\byesterday(?:'s|s)?\b/.test(lower)) return formatLocalDate(addLocalDays(today, -1));
|
|
121
|
+
if (/\btomorrow(?:'s|s)?\b/.test(lower)) return formatLocalDate(addLocalDays(today, 1));
|
|
122
|
+
|
|
123
|
+
const weekdayMatch = lower.match(
|
|
124
|
+
/\b(?:(last|next)\s+)?(monday|mon|tuesday|tues|tue|toesday|wednesday|wed|thursday|thurs|thu|friday|fri|saturday|sat|sunday|sun)(?:'s|s)?\b/,
|
|
125
|
+
);
|
|
126
|
+
if (!weekdayMatch) return undefined;
|
|
127
|
+
|
|
128
|
+
const weekdayByName = {
|
|
129
|
+
sunday: 0,
|
|
130
|
+
sun: 0,
|
|
131
|
+
monday: 1,
|
|
132
|
+
mon: 1,
|
|
133
|
+
tuesday: 2,
|
|
134
|
+
tues: 2,
|
|
135
|
+
tue: 2,
|
|
136
|
+
toesday: 2,
|
|
137
|
+
wednesday: 3,
|
|
138
|
+
wed: 3,
|
|
139
|
+
thursday: 4,
|
|
140
|
+
thurs: 4,
|
|
141
|
+
thu: 4,
|
|
142
|
+
friday: 5,
|
|
143
|
+
fri: 5,
|
|
144
|
+
saturday: 6,
|
|
145
|
+
sat: 6,
|
|
146
|
+
};
|
|
147
|
+
const modifier = weekdayMatch[1];
|
|
148
|
+
const targetDay = weekdayByName[weekdayMatch[2]];
|
|
149
|
+
const currentDay = today.getDay();
|
|
150
|
+
let offset = targetDay - currentDay;
|
|
151
|
+
|
|
152
|
+
if (modifier === "next") {
|
|
153
|
+
offset = offset <= 0 ? offset + 7 : offset;
|
|
154
|
+
} else if (modifier === "last") {
|
|
155
|
+
offset = offset >= 0 ? offset - 7 : offset;
|
|
156
|
+
} else if (offset > 0) {
|
|
157
|
+
offset -= 7;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return formatLocalDate(addLocalDays(today, offset));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseDateHint(text) {
|
|
164
|
+
const iso = text.match(/\b(\d{4}-\d{2}-\d{2})\b/);
|
|
165
|
+
if (iso?.[1]) return iso[1];
|
|
166
|
+
|
|
167
|
+
const relative = parseRelativeDateHint(text);
|
|
168
|
+
if (relative) return relative;
|
|
169
|
+
|
|
170
|
+
const monthDay = text.match(/\b(?:on\s+)?(january|february|march|april|may|june|july|august|september|october|november|december)\s+(\d{1,2})(?:st|nd|rd|th)?(?:,?\s+(\d{4}))?\b/i);
|
|
171
|
+
if (!monthDay) return undefined;
|
|
172
|
+
|
|
173
|
+
const monthNames = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"];
|
|
174
|
+
const monthIndex = monthNames.indexOf(monthDay[1].toLowerCase());
|
|
175
|
+
if (monthIndex < 0) return undefined;
|
|
176
|
+
|
|
177
|
+
const day = Number.parseInt(monthDay[2], 10);
|
|
178
|
+
if (!Number.isFinite(day) || day < 1 || day > 31) return undefined;
|
|
179
|
+
|
|
180
|
+
const year = monthDay[3] ? Number.parseInt(monthDay[3], 10) : new Date().getFullYear();
|
|
181
|
+
const date = new Date(Date.UTC(year, monthIndex, day));
|
|
182
|
+
if (Number.isNaN(date.getTime())) return undefined;
|
|
183
|
+
return date.toISOString().slice(0, 10);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseIntent(input) {
|
|
187
|
+
const text = normalizeWhitespace(input);
|
|
188
|
+
const intent = {};
|
|
189
|
+
|
|
190
|
+
const betweenPair = text.match(/\bbetween\s+([\w'. -]{2,80}?)\s*(?:and|&)\s*([\w'. -]{2,80}?)(?=\s+(?:from|on|in|at|for|transcripts?|vtts?|interviews?|calls?|meetings?|date|to)\b|[?.!,]|$)/i);
|
|
191
|
+
if (betweenPair?.[1] && betweenPair?.[2]) {
|
|
192
|
+
const first = normalizePersonQuery(betweenPair[1]);
|
|
193
|
+
const second = normalizePersonQuery(betweenPair[2]);
|
|
194
|
+
const people = [first, second].filter(Boolean);
|
|
195
|
+
if (people.length) intent.people = Array.from(new Set(people));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const personWith = text.match(/\bwith\s+([\w'. -]{2,80}?)(?=\s+(?:from|on|in|at|for|transcripts?|vtts?|interviews?|calls?|meetings?|date|to)\b|$)/i);
|
|
199
|
+
if (personWith?.[1]) intent.person = normalizePersonQuery(personWith[1]);
|
|
200
|
+
|
|
201
|
+
if (!intent.person) {
|
|
202
|
+
const personFor = text.match(/\bfor\s+([\w'. -]{2,80}?)(?=\s+(?:from|on|in|at|transcripts?|vtts?|interviews?|calls?|meetings?|date|to)\b|$)/i);
|
|
203
|
+
if (personFor?.[1]) intent.person = normalizePersonQuery(personFor[1]);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!intent.person) {
|
|
207
|
+
const verbBeforeTranscript = text.match(
|
|
208
|
+
/\b(?:download|get|export|save|pull|grab|retrieve|fetch|send|give)\s+([a-z][\w'.-]*(?:\s+[a-z][\w'.-]*){0,3})\s+transcripts?\b/i,
|
|
209
|
+
);
|
|
210
|
+
if (verbBeforeTranscript?.[1]) {
|
|
211
|
+
intent.person = normalizePersonQuery(verbBeforeTranscript[1]);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!intent.person) {
|
|
216
|
+
const beforeTranscript = text.match(/\b([a-z][\w'.-]*(?:\s+[a-z][\w'.-]*){0,3})\s+transcripts?\b/i);
|
|
217
|
+
if (beforeTranscript?.[1]) {
|
|
218
|
+
const candidate = normalizePersonQuery(beforeTranscript[1]);
|
|
219
|
+
if (candidate && !/\b(?:need|want|download|get|last|latest)\b/i.test(candidate)) {
|
|
220
|
+
intent.person = candidate;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if ((!intent.people || !intent.people.length) && intent.person) {
|
|
226
|
+
intent.people = [intent.person];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const latestN = text.match(/\b(?:last|latest|most recent)\s+(\d{1,2})\b/i);
|
|
230
|
+
if (latestN?.[1]) {
|
|
231
|
+
intent.latestCount = Math.max(1, Math.min(20, Number.parseInt(latestN[1], 10)));
|
|
232
|
+
} else if (/\b(?:last|latest|most recent)\b/i.test(text)) {
|
|
233
|
+
intent.latestCount = 1;
|
|
234
|
+
} else if (/\brecent\b/i.test(text)) {
|
|
235
|
+
intent.latestCount = 5;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
intent.date = parseDateHint(text);
|
|
239
|
+
if ((intent.people?.length ?? 0) > 1 && /\b(?:both|between|across|and|&)\b/i.test(text)) {
|
|
240
|
+
intent.date = undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const explicitTimeMatch = text.match(/\b(?:at|time)\s+(\d{1,2}(?::\d{2})?\s*(?:am|pm)|(?:[01]?\d|2[0-3]):[0-5]\d)\b/i);
|
|
244
|
+
const bareTimeMatch = text.match(/\b((?:[1-9]|1[0-2])(?::\d{2})?\s*(?:am|pm)|(?:[01]?\d|2[0-3]):[0-5]\d)\b/i);
|
|
245
|
+
const timeMatch = explicitTimeMatch ?? bareTimeMatch;
|
|
246
|
+
if (timeMatch?.[1]) {
|
|
247
|
+
const normalizedTime = timeMatch[1].replace(/\s+/g, "").toLowerCase();
|
|
248
|
+
if (explicitTimeMatch || normalizedTime !== "11am") intent.time = normalizedTime;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const outMatch = text.match(/\b(?:to|into|in)\s+(~\/\S+|\/\S+)/i);
|
|
252
|
+
if (outMatch?.[1]) intent.outDir = trimQuotes(outMatch[1]);
|
|
253
|
+
|
|
254
|
+
return intent;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parseTimeFilter(input) {
|
|
258
|
+
if (!input) return null;
|
|
259
|
+
const value = input.trim().toLowerCase();
|
|
260
|
+
const twentyFourHour = value.match(/^([01]?\d|2[0-3]):([0-5]\d)$/);
|
|
261
|
+
if (twentyFourHour) {
|
|
262
|
+
return {
|
|
263
|
+
hour: Number.parseInt(twentyFourHour[1], 10),
|
|
264
|
+
minute: Number.parseInt(twentyFourHour[2], 10),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const match = value.match(/^(\d{1,2})(?::(\d{2}))?(am|pm)$/);
|
|
269
|
+
if (!match) return null;
|
|
270
|
+
let hour = Number.parseInt(match[1], 10);
|
|
271
|
+
const minute = match[2] ? Number.parseInt(match[2], 10) : undefined;
|
|
272
|
+
const period = match[3];
|
|
273
|
+
if (hour < 1 || hour > 12) return null;
|
|
274
|
+
if (minute !== undefined && (minute < 0 || minute > 59)) return null;
|
|
275
|
+
if (period === "am") {
|
|
276
|
+
hour = hour === 12 ? 0 : hour;
|
|
277
|
+
} else {
|
|
278
|
+
hour = hour === 12 ? 12 : hour + 12;
|
|
279
|
+
}
|
|
280
|
+
return { hour, minute };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function normalizeFuzzyText(value) {
|
|
284
|
+
return value
|
|
285
|
+
.toLowerCase()
|
|
286
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
287
|
+
.replace(/\s+/g, " ")
|
|
288
|
+
.trim();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function exactNormalizedTextMatch(query, target) {
|
|
292
|
+
const q = normalizeFuzzyText(query);
|
|
293
|
+
const t = normalizeFuzzyText(target);
|
|
294
|
+
if (!q || !t) return false;
|
|
295
|
+
if (q === t) return true;
|
|
296
|
+
return new RegExp(`(^| )${q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}( |$)`).test(t);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function normalizeTranscriptText(value) {
|
|
300
|
+
return value.replace(/\r\n/g, "\n").trim();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function isLikelyVtt(value) {
|
|
304
|
+
const text = value.trim();
|
|
305
|
+
if (!text) return false;
|
|
306
|
+
return /^WEBVTT\b/i.test(text) || /\d{2}:\d{2}:\d{2}[\.,]\d{3}\s+-->\s+\d{2}:\d{2}:\d{2}[\.,]\d{3}/.test(text);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function toTimestamp(totalSeconds) {
|
|
310
|
+
const ms = Math.max(0, Math.floor(totalSeconds * 1000));
|
|
311
|
+
const h = Math.floor(ms / 3600000);
|
|
312
|
+
const m = Math.floor((ms % 3600000) / 60000);
|
|
313
|
+
const s = Math.floor((ms % 60000) / 1000);
|
|
314
|
+
const milli = ms % 1000;
|
|
315
|
+
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(milli).padStart(3, "0")}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function textToSyntheticVtt(text) {
|
|
319
|
+
const normalized = normalizeTranscriptText(text);
|
|
320
|
+
if (!normalized) return null;
|
|
321
|
+
|
|
322
|
+
const sections = normalized
|
|
323
|
+
.split(/\n\s*\n+/)
|
|
324
|
+
.map((value) => value.trim())
|
|
325
|
+
.filter(Boolean)
|
|
326
|
+
.slice(0, 500);
|
|
327
|
+
if (!sections.length) return null;
|
|
328
|
+
|
|
329
|
+
let cursor = 0;
|
|
330
|
+
const cues = [];
|
|
331
|
+
for (const section of sections) {
|
|
332
|
+
const charCount = section.length;
|
|
333
|
+
const duration = Math.max(2, Math.min(10, Math.ceil(charCount / 28)));
|
|
334
|
+
const start = toTimestamp(cursor);
|
|
335
|
+
const end = toTimestamp(cursor + duration);
|
|
336
|
+
cues.push(`${start} --> ${end}\n${section}`);
|
|
337
|
+
cursor += duration;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return `WEBVTT\n\n${cues.join("\n\n")}`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export {
|
|
344
|
+
exactNormalizedTextMatch,
|
|
345
|
+
isLikelyVtt,
|
|
346
|
+
normalizeFuzzyText,
|
|
347
|
+
normalizePersonQuery,
|
|
348
|
+
normalizeWhitespace,
|
|
349
|
+
parseDateHint,
|
|
350
|
+
parseIntent,
|
|
351
|
+
parseRelativeDateHint,
|
|
352
|
+
parseTimeFilter,
|
|
353
|
+
textToSyntheticVtt,
|
|
354
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
exactNormalizedTextMatch,
|
|
5
|
+
normalizePersonQuery,
|
|
6
|
+
parseDateHint,
|
|
7
|
+
parseIntent,
|
|
8
|
+
parseTimeFilter,
|
|
9
|
+
textToSyntheticVtt,
|
|
10
|
+
} from "./transcript-export-helpers.js";
|
|
11
|
+
|
|
12
|
+
test("normalizePersonQuery strips transcript artifact words", () => {
|
|
13
|
+
assert.equal(normalizePersonQuery("Vibhu transcripts"), "Vibhu");
|
|
14
|
+
assert.equal(normalizePersonQuery("recent captions"), undefined);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("parseDateHint handles explicit calendar dates", () => {
|
|
18
|
+
assert.equal(parseDateHint("captions for Vibhu on March 2, 2026"), "2026-03-02");
|
|
19
|
+
assert.equal(parseDateHint("2026-04-24 transcripts"), "2026-04-24");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("parseIntent captures person, date, explicit time, and output path", () => {
|
|
23
|
+
const intent = parseIntent("download Vibhu transcripts on March 2, 2026 at 14:00 to ~/Downloads/custom");
|
|
24
|
+
assert.deepEqual(intent, {
|
|
25
|
+
person: "Vibhu",
|
|
26
|
+
people: ["Vibhu"],
|
|
27
|
+
date: "2026-03-02",
|
|
28
|
+
time: "14:00",
|
|
29
|
+
outDir: "~/Downloads/custom",
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("parseIntent does not treat bare 11am program mention as a time filter", () => {
|
|
34
|
+
const intent = parseIntent("download Vibhu transcripts from 11am");
|
|
35
|
+
assert.equal(intent.person, "Vibhu");
|
|
36
|
+
assert.equal(intent.time, undefined);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("parseTimeFilter supports 24 hour and 12 hour formats", () => {
|
|
40
|
+
assert.deepEqual(parseTimeFilter("14:00"), { hour: 14, minute: 0 });
|
|
41
|
+
assert.deepEqual(parseTimeFilter("2pm"), { hour: 14, minute: undefined });
|
|
42
|
+
assert.equal(parseTimeFilter("25:00"), null);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("exactNormalizedTextMatch avoids partial-name false positives", () => {
|
|
46
|
+
assert.equal(exactNormalizedTextMatch("dan", "Jordan Walker on 11am"), false);
|
|
47
|
+
assert.equal(exactNormalizedTextMatch("rob", "Robert Leshner on 11am"), false);
|
|
48
|
+
assert.equal(exactNormalizedTextMatch("vibhu", "Vibhu Norby on 11am"), true);
|
|
49
|
+
assert.equal(exactNormalizedTextMatch("vibhu norby", "Vibhu Norby on 11am"), true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("textToSyntheticVtt generates a valid VTT envelope from transcript text", () => {
|
|
53
|
+
const vtt = textToSyntheticVtt("Host: Welcome back.\n\nGuest: Thanks for having me.");
|
|
54
|
+
assert.ok(vtt);
|
|
55
|
+
assert.match(vtt, /^WEBVTT\b/);
|
|
56
|
+
assert.match(vtt, /-->/);
|
|
57
|
+
assert.match(vtt, /Host: Welcome back\./);
|
|
58
|
+
assert.match(vtt, /Guest: Thanks for having me\./);
|
|
59
|
+
});
|