@gotcos/glasses-server 6.16.1 → 6.16.2
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/.env.example +7 -0
- package/package.json +1 -1
- package/server/lib/cos-operations-meetings.ts +365 -0
- package/server/routes/meetings.ts +43 -3
package/.env.example
CHANGED
|
@@ -95,6 +95,13 @@ BIND_HOST=0.0.0.0
|
|
|
95
95
|
# Power users running the COS Starter Kit can point the glasses at their
|
|
96
96
|
# pipeline to inherit live tasks/calendar/people context. Omit for standalone.
|
|
97
97
|
# COS_SCRIPTS_DIR=/path/to/your/cos/operations/scripts
|
|
98
|
+
#
|
|
99
|
+
# G2 "Review Meetings" reads COS meeting markdown from an operations tree:
|
|
100
|
+
# {COS_OPERATIONS_DIR}/{quilt|personal|…}/meetings/YYYY-MM/*.md
|
|
101
|
+
# Prefer an explicit ops root (each COS layout can differ). If unset, the
|
|
102
|
+
# server falls back to COS_SCRIPTS_DIR/.. then to local G2 recordings only.
|
|
103
|
+
# COS_OPERATIONS_DIR=/path/to/your/cos/operations
|
|
104
|
+
# COS_MEETINGS_ROOT=/path/to/your/cos/operations # alias for COS_OPERATIONS_DIR
|
|
98
105
|
|
|
99
106
|
# Telegram session/activity notifications remain OFF even if the COS scripts
|
|
100
107
|
# directory contains .telegram_config.json. Enable export explicitly:
|
package/package.json
CHANGED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional COS operations meeting library.
|
|
3
|
+
*
|
|
4
|
+
* When configured, G2 "Review Meetings" reads markdown from a COS-style tree:
|
|
5
|
+
* {operationsDir}/{domain}/meetings/YYYY-MM/*.md
|
|
6
|
+
*
|
|
7
|
+
* Resolution order:
|
|
8
|
+
* 1. COS_OPERATIONS_DIR — explicit operations/ root (preferred)
|
|
9
|
+
* 2. COS_MEETINGS_ROOT — alias for the same path
|
|
10
|
+
* 3. COS_SCRIPTS_DIR/.. — classic Starter Kit layout (operations/scripts → operations)
|
|
11
|
+
*
|
|
12
|
+
* Standalone installs leave all of these unset and keep using MeetingStore
|
|
13
|
+
* (~/.cos-glasses/data/recordings).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
17
|
+
import { basename, join, resolve } from 'node:path'
|
|
18
|
+
import type { MeetingDetail, MeetingMeta } from './meeting-store.js'
|
|
19
|
+
import { MEETING_SOURCE_MAX_BYTES } from './meeting-store.js'
|
|
20
|
+
|
|
21
|
+
export const COS_MEETING_DOMAINS = ['quilt', 'sprocket_rocket', 'hermit_crabs', 'personal'] as const
|
|
22
|
+
|
|
23
|
+
const DOMAIN_ABBR: Record<string, string> = {
|
|
24
|
+
quilt: 'Q',
|
|
25
|
+
sprocket_rocket: 'SR',
|
|
26
|
+
hermit_crabs: 'HC',
|
|
27
|
+
personal: 'P',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DETAIL_CHUNK_ESTIMATE_CHARS = 1700
|
|
31
|
+
|
|
32
|
+
export type CosOperationsMeetingMeta = MeetingMeta & { time?: string }
|
|
33
|
+
|
|
34
|
+
function envPath(name: string): string | null {
|
|
35
|
+
const raw = process.env[name]?.trim()
|
|
36
|
+
if (!raw) return null
|
|
37
|
+
return resolve(raw)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Resolve the COS operations directory, or null in standalone mode.
|
|
41
|
+
* Reads process.env on each call so tests and Control env updates stay live. */
|
|
42
|
+
export function resolveCosOperationsDir(): string | null {
|
|
43
|
+
const explicit = envPath('COS_OPERATIONS_DIR') || envPath('COS_MEETINGS_ROOT')
|
|
44
|
+
if (explicit && existsSync(explicit)) return explicit
|
|
45
|
+
const scriptsDir = envPath('COS_SCRIPTS_DIR')
|
|
46
|
+
if (scriptsDir) {
|
|
47
|
+
const inferred = resolve(scriptsDir, '..')
|
|
48
|
+
if (existsSync(inferred)) return inferred
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function cosOperationsMeetingsConfigured(): boolean {
|
|
54
|
+
return resolveCosOperationsDir() != null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function boundedMeetingSource(content: string): { sourceContent: string; sourceTruncated: boolean } {
|
|
58
|
+
const bytes = Buffer.from(content, 'utf8')
|
|
59
|
+
if (bytes.length <= MEETING_SOURCE_MAX_BYTES) return { sourceContent: content, sourceTruncated: false }
|
|
60
|
+
let end = MEETING_SOURCE_MAX_BYTES
|
|
61
|
+
while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1
|
|
62
|
+
return { sourceContent: bytes.subarray(0, end).toString('utf8'), sourceTruncated: true }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseDurationMinutes(duration: string): number | undefined {
|
|
66
|
+
if (!duration) return undefined
|
|
67
|
+
const normalized = duration.toLowerCase()
|
|
68
|
+
const colonMatch = normalized.match(/\b(\d+):(\d{2})\b/)
|
|
69
|
+
if (colonMatch) return Number(colonMatch[1]) * 60 + Number(colonMatch[2])
|
|
70
|
+
|
|
71
|
+
let total = 0
|
|
72
|
+
const hourMatch = normalized.match(/(\d+(?:\.\d+)?)\s*(?:h|hr|hrs|hour|hours)\b/)
|
|
73
|
+
if (hourMatch) total += Math.round(Number(hourMatch[1]) * 60)
|
|
74
|
+
|
|
75
|
+
const minuteMatch = normalized.match(/(\d+)\s*(?:m|min|mins|minute|minutes)\b/)
|
|
76
|
+
if (minuteMatch) total += Number(minuteMatch[1])
|
|
77
|
+
|
|
78
|
+
if (total > 0) return total
|
|
79
|
+
|
|
80
|
+
const bareNumber = normalized.match(/^\s*(\d+)\s*$/)
|
|
81
|
+
return bareNumber ? Number(bareNumber[1]) : undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeMeetingTime(hourText: string, minuteText: string, meridiemText = ''): string | undefined {
|
|
85
|
+
let hour = Number(hourText)
|
|
86
|
+
const minute = Number(minuteText)
|
|
87
|
+
if (!Number.isInteger(hour) || !Number.isInteger(minute) || minute < 0 || minute > 59) return undefined
|
|
88
|
+
const meridiem = meridiemText.trim().toUpperCase()
|
|
89
|
+
if (meridiem) {
|
|
90
|
+
if (hour < 1 || hour > 12 || !['AM', 'PM'].includes(meridiem)) return undefined
|
|
91
|
+
if (meridiem === 'AM' && hour === 12) hour = 0
|
|
92
|
+
if (meridiem === 'PM' && hour !== 12) hour += 12
|
|
93
|
+
} else if (hour < 0 || hour > 23) {
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function extractMeetingDateTime(content: string, filename: string): { date: string; time?: string } {
|
|
100
|
+
const metadata = content.match(/\*\*Date(?::)?\*\*\s*(?:\|\s*)?((?:19|20)\d{2}-\d{2}-\d{2})(?:[ T]+(\d{1,2}):(\d{2})(?:\s*([AP]M))?)?/i)
|
|
101
|
+
if (metadata) {
|
|
102
|
+
const time = metadata[2] && metadata[3]
|
|
103
|
+
? normalizeMeetingTime(metadata[2], metadata[3], metadata[4])
|
|
104
|
+
: undefined
|
|
105
|
+
return { date: metadata[1], ...(time ? { time } : {}) }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const g2 = filename.match(/_((?:19|20)\d{2}-\d{2}-\d{2})_(\d{2})(\d{2})(?:\D|$)/)
|
|
109
|
+
if (g2) {
|
|
110
|
+
const time = normalizeMeetingTime(g2[2], g2[3])
|
|
111
|
+
return { date: g2[1], ...(time ? { time } : {}) }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const dateMatch = filename.match(/^((?:19|20)\d{2}-\d{2}-\d{2})/)
|
|
115
|
+
return { date: dateMatch ? dateMatch[1] : 'unknown' }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function compareMeetingsNewestFirst(a: CosOperationsMeetingMeta, b: CosOperationsMeetingMeta): number {
|
|
119
|
+
const aKey = `${a.date || '0000-00-00'}T${a.time || '00:00'}`
|
|
120
|
+
const bKey = `${b.date || '0000-00-00'}T${b.time || '00:00'}`
|
|
121
|
+
return bKey.localeCompare(aKey)
|
|
122
|
+
|| b.filename.localeCompare(a.filename)
|
|
123
|
+
|| b.domain.localeCompare(a.domain)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function matchRenamedMeetingFilename(
|
|
127
|
+
requestedFilename: string,
|
|
128
|
+
candidates: Array<{ filename: string; content: string }>,
|
|
129
|
+
): string | undefined {
|
|
130
|
+
const requested = extractMeetingDateTime('', requestedFilename)
|
|
131
|
+
if (requested.date === 'unknown' || !requested.time) return undefined
|
|
132
|
+
const matches = candidates.filter(candidate => {
|
|
133
|
+
const timestamp = extractMeetingDateTime(candidate.content, candidate.filename)
|
|
134
|
+
return timestamp.date === requested.date && timestamp.time === requested.time
|
|
135
|
+
})
|
|
136
|
+
return matches.length === 1 ? matches[0].filename : undefined
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseMeetingMeta(content: string, filename: string, domain: string): CosOperationsMeetingMeta {
|
|
140
|
+
const name = basename(filename, '.md')
|
|
141
|
+
const titleMatch = content.match(/^#\s+(.+)$/m)
|
|
142
|
+
let title: string
|
|
143
|
+
if (titleMatch) {
|
|
144
|
+
title = titleMatch[1].trim()
|
|
145
|
+
} else {
|
|
146
|
+
const parts = name.split('_').slice(1)
|
|
147
|
+
title = parts.join(' ').slice(0, 50)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const { date, time } = extractMeetingDateTime(content, filename)
|
|
151
|
+
const sourceMatch = content.match(/\*\*Source\*\*\s*\|\s*(.+)/i)
|
|
152
|
+
|| content.match(/\*\*Source:\*\*\s*(.+)/i)
|
|
153
|
+
const source = sourceMatch ? sourceMatch[1].replace(/\s*\|?\s*$/, '').trim() : ''
|
|
154
|
+
|
|
155
|
+
const durationMatch = content.match(/\*\*Duration\*\*\s*\|\s*(.+)/i)
|
|
156
|
+
|| content.match(/\*\*Duration:\*\*\s*(.+)/i)
|
|
157
|
+
const duration = durationMatch ? durationMatch[1].replace(/\s*\|?\s*$/, '').trim() : ''
|
|
158
|
+
const durationMinutes = parseDurationMinutes(duration)
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
filename,
|
|
162
|
+
title: title || 'Untitled Meeting',
|
|
163
|
+
date,
|
|
164
|
+
...(time ? { time } : {}),
|
|
165
|
+
domain,
|
|
166
|
+
domainAbbr: DOMAIN_ABBR[domain] || '?',
|
|
167
|
+
source,
|
|
168
|
+
duration,
|
|
169
|
+
durationMinutes,
|
|
170
|
+
month: '',
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function extractSummary(content: string): string {
|
|
175
|
+
const summaryMatch = content.match(/## Summary\s*\n([\s\S]*?)(?=\n##|\n$|$)/)
|
|
176
|
+
if (summaryMatch) return summaryMatch[1].trim().slice(0, 2000)
|
|
177
|
+
return ''
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function extractTopics(content: string): string[] {
|
|
181
|
+
const topicsMatch = content.match(/## Topics Discussed\s*\n([\s\S]*?)(?=\n##|\n$|$)/)
|
|
182
|
+
if (!topicsMatch) return []
|
|
183
|
+
return topicsMatch[1]
|
|
184
|
+
.split('\n')
|
|
185
|
+
.filter(l => l.trim().startsWith('-') || l.trim().startsWith('*'))
|
|
186
|
+
.map(l => l.replace(/^[-*]\s*/, '').trim())
|
|
187
|
+
.filter(Boolean)
|
|
188
|
+
.slice(0, 10)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function extractActionItems(content: string): Array<{ task: string; owner: string }> {
|
|
192
|
+
const actionsMatch = content.match(/## (?:Action Items|Tasks|Next Steps)\s*\n([\s\S]*?)(?=\n##|\n$|$)/)
|
|
193
|
+
if (!actionsMatch) return []
|
|
194
|
+
return actionsMatch[1]
|
|
195
|
+
.split('\n')
|
|
196
|
+
.filter(l => l.trim().startsWith('-') || l.trim().startsWith('*') || l.trim().match(/^\[/))
|
|
197
|
+
.map(l => {
|
|
198
|
+
const cleaned = l.replace(/^[-*]\s*/, '').replace(/^\[.\]\s*/, '').replace(/`\[REVIEW\]`\s*/i, '').trim()
|
|
199
|
+
const ownerMatch = cleaned.match(/\(\*\*(.+?)\*\*\)\s*$/)
|
|
200
|
+
const owner = ownerMatch ? ownerMatch[1] : ''
|
|
201
|
+
const task = ownerMatch ? cleaned.replace(ownerMatch[0], '').trim() : cleaned
|
|
202
|
+
return { task, owner }
|
|
203
|
+
})
|
|
204
|
+
.filter(i => i.task.length > 0)
|
|
205
|
+
.slice(0, 15)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function extractDecisions(content: string): string[] {
|
|
209
|
+
const match = content.match(/## Decisions(?: Made)?\s*\n([\s\S]*?)(?=\n##|\n$|$)/)
|
|
210
|
+
if (!match) return []
|
|
211
|
+
return match[1]
|
|
212
|
+
.split('\n')
|
|
213
|
+
.filter(l => l.trim().startsWith('-') || l.trim().startsWith('*'))
|
|
214
|
+
.map(l => l.replace(/^[-*]\s*/, '').trim())
|
|
215
|
+
.filter(Boolean)
|
|
216
|
+
.slice(0, 10)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function extractAttendees(content: string): string[] {
|
|
220
|
+
const match = content.match(/## Attendees\s*\n([\s\S]*?)(?=\n##|\n$|$)/)
|
|
221
|
+
if (!match) return []
|
|
222
|
+
return match[1]
|
|
223
|
+
.split('\n')
|
|
224
|
+
.filter(l => l.trim().startsWith('-') || l.trim().startsWith('*'))
|
|
225
|
+
.map(l => {
|
|
226
|
+
const cleaned = l.replace(/^[-*]\s*/, '').trim()
|
|
227
|
+
const nameMatch = cleaned.match(/^\*\*(.+?)\*\*/) || cleaned.match(/^([^(]+)/)
|
|
228
|
+
return nameMatch ? nameMatch[1].trim() : cleaned
|
|
229
|
+
})
|
|
230
|
+
.filter(Boolean)
|
|
231
|
+
.slice(0, 20)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function withMeetingListInsights(meta: CosOperationsMeetingMeta, content: string): CosOperationsMeetingMeta {
|
|
235
|
+
const summary = extractSummary(content)
|
|
236
|
+
const topics = extractTopics(content)
|
|
237
|
+
const decisions = extractDecisions(content)
|
|
238
|
+
const actionItems = extractActionItems(content)
|
|
239
|
+
const attendees = extractAttendees(content)
|
|
240
|
+
const header = `${meta.title}\n${meta.date}${meta.time ? ` ${meta.time}` : ''} • ${meta.domainAbbr} • ${meta.duration || meta.source}\n\n`
|
|
241
|
+
const detailCharEstimate = [
|
|
242
|
+
header,
|
|
243
|
+
summary,
|
|
244
|
+
topics.join('\n'),
|
|
245
|
+
decisions.join('\n'),
|
|
246
|
+
actionItems.map(a => `${a.owner ? `[${a.owner}] ` : ''}${a.task}`).join('\n'),
|
|
247
|
+
attendees.join(', '),
|
|
248
|
+
].join('\n\n').trim().length
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
...meta,
|
|
252
|
+
detailCharEstimate,
|
|
253
|
+
estimatedDetailPages: Math.max(1, Math.ceil(detailCharEstimate / DETAIL_CHUNK_ESTIMATE_CHARS)),
|
|
254
|
+
topicCount: topics.length,
|
|
255
|
+
decisionCount: decisions.length,
|
|
256
|
+
actionCount: actionItems.length,
|
|
257
|
+
attendeeCount: attendees.length,
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function listCosOperationsMeetings(options: {
|
|
262
|
+
limit?: number
|
|
263
|
+
domain?: string
|
|
264
|
+
} = {}): CosOperationsMeetingMeta[] {
|
|
265
|
+
const operationsDir = resolveCosOperationsDir()
|
|
266
|
+
if (!operationsDir) return []
|
|
267
|
+
|
|
268
|
+
const limit = Math.min(Math.max(options.limit ?? 20, 1), 50)
|
|
269
|
+
const domainFilter = options.domain || 'all'
|
|
270
|
+
const domains = domainFilter === 'all'
|
|
271
|
+
? [...COS_MEETING_DOMAINS]
|
|
272
|
+
: COS_MEETING_DOMAINS.includes(domainFilter as typeof COS_MEETING_DOMAINS[number])
|
|
273
|
+
? [domainFilter]
|
|
274
|
+
: []
|
|
275
|
+
|
|
276
|
+
const allMeetings: CosOperationsMeetingMeta[] = []
|
|
277
|
+
|
|
278
|
+
for (const domain of domains) {
|
|
279
|
+
const meetingsBase = join(operationsDir, domain, 'meetings')
|
|
280
|
+
try {
|
|
281
|
+
const months = readdirSync(meetingsBase)
|
|
282
|
+
.filter(d => /^\d{4}-\d{2}$/.test(d))
|
|
283
|
+
.sort()
|
|
284
|
+
.reverse()
|
|
285
|
+
.slice(0, 3)
|
|
286
|
+
|
|
287
|
+
for (const month of months) {
|
|
288
|
+
const monthDir = join(meetingsBase, month)
|
|
289
|
+
try {
|
|
290
|
+
const files = readdirSync(monthDir)
|
|
291
|
+
.filter(f => f.endsWith('.md'))
|
|
292
|
+
.sort()
|
|
293
|
+
.reverse()
|
|
294
|
+
|
|
295
|
+
for (const file of files) {
|
|
296
|
+
try {
|
|
297
|
+
const filepath = join(monthDir, file)
|
|
298
|
+
const content = readFileSync(filepath, 'utf-8')
|
|
299
|
+
const meta = withMeetingListInsights(parseMeetingMeta(content.slice(0, 4000), file, domain), content)
|
|
300
|
+
meta.month = month
|
|
301
|
+
allMeetings.push(meta)
|
|
302
|
+
} catch { /* skip unreadable files */ }
|
|
303
|
+
}
|
|
304
|
+
} catch { /* skip unreadable months */ }
|
|
305
|
+
}
|
|
306
|
+
} catch { /* domain has no meetings dir */ }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
allMeetings.sort(compareMeetingsNewestFirst)
|
|
310
|
+
return allMeetings.slice(0, limit)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function getCosOperationsMeetingDetail(
|
|
314
|
+
domain: string,
|
|
315
|
+
month: string,
|
|
316
|
+
filename: string,
|
|
317
|
+
): MeetingDetail | null {
|
|
318
|
+
const operationsDir = resolveCosOperationsDir()
|
|
319
|
+
if (!operationsDir) return null
|
|
320
|
+
|
|
321
|
+
if (!(COS_MEETING_DOMAINS as readonly string[]).includes(domain)) return null
|
|
322
|
+
if (!/^\d{4}-\d{2}$/.test(month) || basename(filename) !== filename || !filename.endsWith('.md')) {
|
|
323
|
+
return null
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const monthDir = join(operationsDir, domain, 'meetings', month)
|
|
327
|
+
let resolvedFilename = filename
|
|
328
|
+
let filepath = join(monthDir, resolvedFilename)
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
statSync(filepath)
|
|
332
|
+
} catch {
|
|
333
|
+
let candidates: Array<{ filename: string; content: string }> = []
|
|
334
|
+
try {
|
|
335
|
+
candidates = readdirSync(monthDir)
|
|
336
|
+
.filter(candidate => candidate.endsWith('.md'))
|
|
337
|
+
.map(candidate => ({
|
|
338
|
+
filename: candidate,
|
|
339
|
+
content: readFileSync(join(monthDir, candidate), 'utf8').slice(0, 4000),
|
|
340
|
+
}))
|
|
341
|
+
} catch {
|
|
342
|
+
return null
|
|
343
|
+
}
|
|
344
|
+
const renamed = matchRenamedMeetingFilename(filename, candidates)
|
|
345
|
+
if (!renamed) return null
|
|
346
|
+
resolvedFilename = renamed
|
|
347
|
+
filepath = join(monthDir, resolvedFilename)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const content = readFileSync(filepath, 'utf-8')
|
|
351
|
+
const meta = parseMeetingMeta(content, resolvedFilename, domain)
|
|
352
|
+
meta.month = month
|
|
353
|
+
const source = boundedMeetingSource(content)
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
...meta,
|
|
357
|
+
summary: extractSummary(content),
|
|
358
|
+
topics: extractTopics(content),
|
|
359
|
+
decisions: extractDecisions(content),
|
|
360
|
+
actionItems: extractActionItems(content),
|
|
361
|
+
attendees: extractAttendees(content),
|
|
362
|
+
transcript: content,
|
|
363
|
+
...source,
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
//
|
|
2
|
-
// directory. No COS operations paths, classifiers, or user-specific stores.
|
|
1
|
+
// Meeting archive: COS operations tree when configured, else standalone recordings.
|
|
3
2
|
|
|
4
3
|
import { Router } from 'express'
|
|
5
4
|
import { getMeetingStore, MeetingStore, MeetingStoreError } from '../lib/meeting-store.js'
|
|
5
|
+
import {
|
|
6
|
+
cosOperationsMeetingsConfigured,
|
|
7
|
+
getCosOperationsMeetingDetail,
|
|
8
|
+
listCosOperationsMeetings,
|
|
9
|
+
resolveCosOperationsDir,
|
|
10
|
+
} from '../lib/cos-operations-meetings.js'
|
|
6
11
|
|
|
7
12
|
export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): Router {
|
|
8
13
|
const router = Router()
|
|
@@ -14,7 +19,18 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
|
|
|
14
19
|
const limit = Number.isFinite(rawLimit) ? rawLimit : 20
|
|
15
20
|
const domain = typeof req.query.domain === 'string' ? req.query.domain : 'all'
|
|
16
21
|
res.set('Cache-Control', 'private, no-store')
|
|
17
|
-
|
|
22
|
+
|
|
23
|
+
if (cosOperationsMeetingsConfigured()) {
|
|
24
|
+
const meetings = listCosOperationsMeetings({ limit, domain })
|
|
25
|
+
res.json({
|
|
26
|
+
meetings,
|
|
27
|
+
source: 'cos_operations',
|
|
28
|
+
operationsDir: resolveCosOperationsDir(),
|
|
29
|
+
})
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
res.json({ meetings: store.list({ limit, domain }), source: 'standalone_recordings' })
|
|
18
34
|
} catch (error) {
|
|
19
35
|
sendMeetingStoreError(res, error)
|
|
20
36
|
}
|
|
@@ -32,6 +48,17 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
|
|
|
32
48
|
return
|
|
33
49
|
}
|
|
34
50
|
res.set('Cache-Control', 'private, no-store')
|
|
51
|
+
|
|
52
|
+
if (cosOperationsMeetingsConfigured()) {
|
|
53
|
+
const detail = getCosOperationsMeetingDetail(domain, month, filename)
|
|
54
|
+
if (detail) {
|
|
55
|
+
res.json(detail)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
// Fall through to standalone store for G2-local recordings that share
|
|
59
|
+
// the same API shape when ops lookup misses.
|
|
60
|
+
}
|
|
61
|
+
|
|
35
62
|
res.json(store.detail(domain, month, filename))
|
|
36
63
|
} catch (error) {
|
|
37
64
|
sendMeetingStoreError(res, error)
|
|
@@ -43,6 +70,19 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
|
|
|
43
70
|
router.get('/meetings/:domain/:month/:filename', (req, res) => {
|
|
44
71
|
try {
|
|
45
72
|
res.set('Cache-Control', 'private, no-store')
|
|
73
|
+
|
|
74
|
+
if (cosOperationsMeetingsConfigured()) {
|
|
75
|
+
const detail = getCosOperationsMeetingDetail(
|
|
76
|
+
req.params.domain,
|
|
77
|
+
req.params.month,
|
|
78
|
+
req.params.filename,
|
|
79
|
+
)
|
|
80
|
+
if (detail) {
|
|
81
|
+
res.json(detail)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
46
86
|
res.json(store.detail(req.params.domain, req.params.month, req.params.filename))
|
|
47
87
|
} catch (error) {
|
|
48
88
|
sendMeetingStoreError(res, error)
|