ffx-cli 0.1.0__py3-none-any.whl
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.
- ffx/__init__.py +1 -0
- ffx/__main__.py +4 -0
- ffx/api_client.py +261 -0
- ffx/commands/__init__.py +29 -0
- ffx/commands/action_items.py +74 -0
- ffx/commands/auth.py +20 -0
- ffx/commands/brief.py +45 -0
- ffx/commands/export.py +129 -0
- ffx/commands/get.py +64 -0
- ffx/commands/list_cmd.py +68 -0
- ffx/commands/search.py +62 -0
- ffx/commands/speaker.py +84 -0
- ffx/commands/summary.py +78 -0
- ffx/commands/topics.py +82 -0
- ffx/commands/transcript.py +91 -0
- ffx/commands/week.py +121 -0
- ffx/config.py +46 -0
- ffx/models.py +107 -0
- ffx/output.py +106 -0
- ffx_cli-0.1.0.dist-info/METADATA +9 -0
- ffx_cli-0.1.0.dist-info/RECORD +23 -0
- ffx_cli-0.1.0.dist-info/WHEEL +4 -0
- ffx_cli-0.1.0.dist-info/entry_points.txt +2 -0
ffx/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
ffx/__main__.py
ADDED
ffx/api_client.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from gql import Client, gql
|
|
4
|
+
from gql.transport.httpx import HTTPXTransport
|
|
5
|
+
|
|
6
|
+
from ffx.models import (
|
|
7
|
+
ActionItem,
|
|
8
|
+
Sentence,
|
|
9
|
+
Speaker,
|
|
10
|
+
Summary,
|
|
11
|
+
Topic,
|
|
12
|
+
Transcript,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
FIREFLIES_API_URL = "https://api.fireflies.ai/graphql"
|
|
16
|
+
|
|
17
|
+
# participants is [String!] (emails), not objects
|
|
18
|
+
# analytics.speakers is the correct path (not speaker_talk_time_percentage)
|
|
19
|
+
LIST_TRANSCRIPTS_QUERY = gql("""
|
|
20
|
+
query Transcripts($limit: Int, $skip: Int, $keyword: String, $fromDate: DateTime, $toDate: DateTime, $participants: [String!]) {
|
|
21
|
+
transcripts(limit: $limit, skip: $skip, keyword: $keyword, fromDate: $fromDate, toDate: $toDate, participants: $participants) {
|
|
22
|
+
id
|
|
23
|
+
title
|
|
24
|
+
date
|
|
25
|
+
duration
|
|
26
|
+
organizer_email
|
|
27
|
+
participants
|
|
28
|
+
summary {
|
|
29
|
+
action_items keywords overview gist bullet_gist
|
|
30
|
+
shorthand_bullet outline short_summary short_overview
|
|
31
|
+
meeting_type topics_discussed
|
|
32
|
+
}
|
|
33
|
+
analytics {
|
|
34
|
+
speakers {
|
|
35
|
+
speaker_id name duration duration_pct word_count
|
|
36
|
+
words_per_minute filler_words questions
|
|
37
|
+
longest_monologue monologues_count
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
""")
|
|
43
|
+
|
|
44
|
+
GET_TRANSCRIPT_QUERY = gql("""
|
|
45
|
+
query Transcript($id: String!) {
|
|
46
|
+
transcript(id: $id) {
|
|
47
|
+
id
|
|
48
|
+
title
|
|
49
|
+
date
|
|
50
|
+
duration
|
|
51
|
+
organizer_email
|
|
52
|
+
participants
|
|
53
|
+
summary {
|
|
54
|
+
action_items keywords overview gist bullet_gist
|
|
55
|
+
shorthand_bullet outline short_summary short_overview
|
|
56
|
+
meeting_type topics_discussed
|
|
57
|
+
}
|
|
58
|
+
analytics {
|
|
59
|
+
speakers {
|
|
60
|
+
speaker_id name duration duration_pct word_count
|
|
61
|
+
words_per_minute filler_words questions
|
|
62
|
+
longest_monologue monologues_count
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
""")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
GET_TRANSCRIPT_WITH_SENTENCES_QUERY = gql("""
|
|
71
|
+
query Transcript($id: String!) {
|
|
72
|
+
transcript(id: $id) {
|
|
73
|
+
id
|
|
74
|
+
title
|
|
75
|
+
date
|
|
76
|
+
duration
|
|
77
|
+
organizer_email
|
|
78
|
+
participants
|
|
79
|
+
summary {
|
|
80
|
+
action_items keywords overview gist bullet_gist
|
|
81
|
+
shorthand_bullet outline short_summary short_overview
|
|
82
|
+
meeting_type topics_discussed
|
|
83
|
+
}
|
|
84
|
+
analytics {
|
|
85
|
+
speakers {
|
|
86
|
+
speaker_id name duration duration_pct word_count
|
|
87
|
+
words_per_minute filler_words questions
|
|
88
|
+
longest_monologue monologues_count
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
sentences {
|
|
92
|
+
index
|
|
93
|
+
speaker_name
|
|
94
|
+
speaker_id
|
|
95
|
+
text
|
|
96
|
+
raw_text
|
|
97
|
+
start_time
|
|
98
|
+
end_time
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
""")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class AuthError(Exception):
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ApiError(Exception):
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _parse_transcript(raw: dict) -> Transcript:
|
|
114
|
+
summary_raw = raw.get("summary") or {}
|
|
115
|
+
analytics_raw = raw.get("analytics") or {}
|
|
116
|
+
|
|
117
|
+
# Most summary fields come back as strings (with newlines), not lists.
|
|
118
|
+
# Only keywords is a proper list. We store raw and use .action_items_list etc.
|
|
119
|
+
summary = Summary(
|
|
120
|
+
overview=summary_raw.get("overview"),
|
|
121
|
+
short_overview=summary_raw.get("short_overview"),
|
|
122
|
+
gist=summary_raw.get("gist"),
|
|
123
|
+
short_summary=summary_raw.get("short_summary"),
|
|
124
|
+
bullet_gist=summary_raw.get("bullet_gist"),
|
|
125
|
+
shorthand_bullet=summary_raw.get("shorthand_bullet"),
|
|
126
|
+
action_items=summary_raw.get("action_items"),
|
|
127
|
+
keywords=summary_raw.get("keywords") or [],
|
|
128
|
+
topics_discussed=summary_raw.get("topics_discussed"),
|
|
129
|
+
outline=summary_raw.get("outline"),
|
|
130
|
+
meeting_type=summary_raw.get("meeting_type"),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
participants = raw.get("participants") or []
|
|
134
|
+
|
|
135
|
+
speakers = [
|
|
136
|
+
Speaker(
|
|
137
|
+
name=s.get("name", ""),
|
|
138
|
+
speaker_id=s.get("speaker_id"),
|
|
139
|
+
duration=s.get("duration", 0),
|
|
140
|
+
duration_pct=s.get("duration_pct", 0.0),
|
|
141
|
+
word_count=s.get("word_count", 0),
|
|
142
|
+
words_per_minute=s.get("words_per_minute", 0.0),
|
|
143
|
+
filler_words=s.get("filler_words", 0),
|
|
144
|
+
questions=s.get("questions", 0),
|
|
145
|
+
longest_monologue=s.get("longest_monologue", 0),
|
|
146
|
+
monologues_count=s.get("monologues_count", 0),
|
|
147
|
+
)
|
|
148
|
+
for s in (analytics_raw.get("speakers") or [])
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
action_items = [
|
|
152
|
+
ActionItem(text=item, transcript_id=raw.get("id"))
|
|
153
|
+
for item in summary.action_items_list
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
topics = [
|
|
157
|
+
Topic(text=t, transcript_id=raw.get("id"))
|
|
158
|
+
for t in summary.topics_list
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
sentences = [
|
|
162
|
+
Sentence(
|
|
163
|
+
index=s.get("index", 0),
|
|
164
|
+
speaker_name=s.get("speaker_name", ""),
|
|
165
|
+
speaker_id=s.get("speaker_id"),
|
|
166
|
+
text=s.get("text", ""),
|
|
167
|
+
raw_text=s.get("raw_text", ""),
|
|
168
|
+
start_time=s.get("start_time", 0.0),
|
|
169
|
+
end_time=s.get("end_time", 0.0),
|
|
170
|
+
)
|
|
171
|
+
for s in (raw.get("sentences") or [])
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
return Transcript(
|
|
175
|
+
id=raw["id"],
|
|
176
|
+
title=raw.get("title", "Untitled"),
|
|
177
|
+
date=str(raw.get("date", "")),
|
|
178
|
+
duration=raw.get("duration", 0),
|
|
179
|
+
organizer_email=raw.get("organizer_email"),
|
|
180
|
+
participants=participants,
|
|
181
|
+
summary=summary,
|
|
182
|
+
speakers=speakers,
|
|
183
|
+
action_items=action_items,
|
|
184
|
+
topics=topics,
|
|
185
|
+
sentences=sentences,
|
|
186
|
+
raw=raw,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class FirefliesClient:
|
|
191
|
+
def __init__(self, api_key: str) -> None:
|
|
192
|
+
transport = HTTPXTransport(
|
|
193
|
+
url=FIREFLIES_API_URL,
|
|
194
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
195
|
+
)
|
|
196
|
+
self._client = Client(transport=transport, fetch_schema_from_transport=False)
|
|
197
|
+
|
|
198
|
+
def _execute(self, query, variables: dict | None = None) -> dict:
|
|
199
|
+
try:
|
|
200
|
+
result = self._client.execute(query, variable_values=variables or {})
|
|
201
|
+
return result
|
|
202
|
+
except Exception as e:
|
|
203
|
+
msg = str(e).lower()
|
|
204
|
+
if "429" in str(e) or "rate limit" in msg:
|
|
205
|
+
raise ApiError("rate limit exceeded — retry later") from e
|
|
206
|
+
if "unauthenticated" in msg or "unauthorized" in msg or "401" in str(e):
|
|
207
|
+
raise AuthError("API key missing or expired") from e
|
|
208
|
+
if any(code in str(e) for code in ("500", "502", "503")):
|
|
209
|
+
raise ApiError(f"Fireflies API error: {e}") from e
|
|
210
|
+
raise ApiError(str(e)) from e
|
|
211
|
+
|
|
212
|
+
def list_transcripts(
|
|
213
|
+
self,
|
|
214
|
+
limit: int = 10,
|
|
215
|
+
skip: int = 0,
|
|
216
|
+
keyword: str | None = None,
|
|
217
|
+
from_date: str | None = None,
|
|
218
|
+
to_date: str | None = None,
|
|
219
|
+
participants: list[str] | None = None,
|
|
220
|
+
) -> list[Transcript]:
|
|
221
|
+
vars: dict = {"limit": limit, "skip": skip}
|
|
222
|
+
if keyword:
|
|
223
|
+
vars["keyword"] = keyword
|
|
224
|
+
if from_date:
|
|
225
|
+
vars["fromDate"] = from_date
|
|
226
|
+
if to_date:
|
|
227
|
+
vars["toDate"] = to_date
|
|
228
|
+
if participants:
|
|
229
|
+
vars["participants"] = participants
|
|
230
|
+
result = self._execute(LIST_TRANSCRIPTS_QUERY, vars)
|
|
231
|
+
return [_parse_transcript(t) for t in (result.get("transcripts") or [])]
|
|
232
|
+
|
|
233
|
+
def get_transcript(self, transcript_id: str) -> Transcript | None:
|
|
234
|
+
result = self._execute(GET_TRANSCRIPT_QUERY, {"id": transcript_id})
|
|
235
|
+
raw = result.get("transcript")
|
|
236
|
+
if raw is None:
|
|
237
|
+
return None
|
|
238
|
+
return _parse_transcript(raw)
|
|
239
|
+
|
|
240
|
+
def get_transcript_with_sentences(self, transcript_id: str) -> Transcript | None:
|
|
241
|
+
result = self._execute(GET_TRANSCRIPT_WITH_SENTENCES_QUERY, {"id": transcript_id})
|
|
242
|
+
raw = result.get("transcript")
|
|
243
|
+
if raw is None:
|
|
244
|
+
return None
|
|
245
|
+
return _parse_transcript(raw)
|
|
246
|
+
|
|
247
|
+
def search_transcripts(
|
|
248
|
+
self,
|
|
249
|
+
query: str,
|
|
250
|
+
limit: int = 20,
|
|
251
|
+
from_date: str | None = None,
|
|
252
|
+
to_date: str | None = None,
|
|
253
|
+
participants: list[str] | None = None,
|
|
254
|
+
) -> list[Transcript]:
|
|
255
|
+
return self.list_transcripts(
|
|
256
|
+
limit=limit,
|
|
257
|
+
keyword=query,
|
|
258
|
+
from_date=from_date,
|
|
259
|
+
to_date=to_date,
|
|
260
|
+
participants=participants,
|
|
261
|
+
)
|
ffx/commands/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from ffx.commands.auth import auth
|
|
3
|
+
from ffx.commands.list_cmd import list_cmd
|
|
4
|
+
from ffx.commands.get import get
|
|
5
|
+
from ffx.commands.search import search
|
|
6
|
+
from ffx.commands.summary import summary
|
|
7
|
+
from ffx.commands.brief import brief
|
|
8
|
+
from ffx.commands.action_items import action_items
|
|
9
|
+
from ffx.commands.export import export
|
|
10
|
+
from ffx.commands.topics import topics
|
|
11
|
+
from ffx.commands.speaker import speaker
|
|
12
|
+
from ffx.commands.transcript import transcript
|
|
13
|
+
from ffx.commands.week import week, month
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(name="ffx", help="Fireflies.ai CLI", no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
app.command()(auth)
|
|
18
|
+
app.command("list")(list_cmd)
|
|
19
|
+
app.command()(get)
|
|
20
|
+
app.command()(search)
|
|
21
|
+
app.command()(summary)
|
|
22
|
+
app.command()(brief)
|
|
23
|
+
app.command("action-items")(action_items)
|
|
24
|
+
app.command()(export)
|
|
25
|
+
app.command()(transcript)
|
|
26
|
+
app.command()(topics)
|
|
27
|
+
app.command()(speaker)
|
|
28
|
+
app.command()(week)
|
|
29
|
+
app.command()(month)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from ffx.config import Config
|
|
7
|
+
from ffx.api_client import FirefliesClient, AuthError, ApiError
|
|
8
|
+
from ffx.models import ActionItem
|
|
9
|
+
from ffx.output import json_envelope, json_error
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def action_items(
|
|
13
|
+
days: Optional[int] = typer.Option(7, "--days", help="Last N days"),
|
|
14
|
+
owner: Optional[str] = typer.Option(None, "--filter", help="Filter action items by keyword"),
|
|
15
|
+
limit: int = typer.Option(20, "--limit"),
|
|
16
|
+
table: bool = typer.Option(False, "--table", help="Rich table output"),
|
|
17
|
+
) -> None:
|
|
18
|
+
"""List action items from recent meetings."""
|
|
19
|
+
json_mode = not table
|
|
20
|
+
config = Config()
|
|
21
|
+
if not config.api_key:
|
|
22
|
+
if json_mode:
|
|
23
|
+
typer.echo(json_error("API key not configured", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
24
|
+
else:
|
|
25
|
+
typer.echo("API key not set. Run: ffx auth", err=True)
|
|
26
|
+
raise typer.Exit(2)
|
|
27
|
+
|
|
28
|
+
from_date = None
|
|
29
|
+
if days:
|
|
30
|
+
from_date = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
client = FirefliesClient(config.api_key)
|
|
34
|
+
transcripts = client.list_transcripts(limit=limit, from_date=from_date)
|
|
35
|
+
except AuthError:
|
|
36
|
+
if json_mode:
|
|
37
|
+
typer.echo(json_error("API key may have expired", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
38
|
+
else:
|
|
39
|
+
typer.echo("API key may have expired. Run: ffx auth", err=True)
|
|
40
|
+
raise typer.Exit(2)
|
|
41
|
+
except ApiError as e:
|
|
42
|
+
if json_mode:
|
|
43
|
+
typer.echo(json_error(str(e), "API_ERROR"))
|
|
44
|
+
else:
|
|
45
|
+
typer.echo(f"API error: {e}", err=True)
|
|
46
|
+
raise typer.Exit(2)
|
|
47
|
+
|
|
48
|
+
all_items: list[ActionItem] = []
|
|
49
|
+
for t in transcripts:
|
|
50
|
+
for item in t.action_items:
|
|
51
|
+
if owner:
|
|
52
|
+
text_lower = item.text.lower()
|
|
53
|
+
assignee_lower = (item.assignee or "").lower()
|
|
54
|
+
if owner.lower() not in text_lower and owner.lower() not in assignee_lower:
|
|
55
|
+
continue
|
|
56
|
+
all_items.append(item)
|
|
57
|
+
|
|
58
|
+
if not all_items:
|
|
59
|
+
if json_mode:
|
|
60
|
+
typer.echo(json_envelope([], {"days": days, "owner": owner}))
|
|
61
|
+
else:
|
|
62
|
+
typer.echo("No action items found.")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
if json_mode:
|
|
66
|
+
results = [
|
|
67
|
+
{"text": i.text, "assignee": i.assignee, "transcript_id": i.transcript_id}
|
|
68
|
+
for i in all_items
|
|
69
|
+
]
|
|
70
|
+
typer.echo(json_envelope(results, {"days": days, "owner": owner}))
|
|
71
|
+
else:
|
|
72
|
+
for i, item in enumerate(all_items, 1):
|
|
73
|
+
assignee = f" ({item.assignee})" if item.assignee else ""
|
|
74
|
+
typer.echo(f" {i}. {item.text}{assignee}")
|
ffx/commands/auth.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from ffx.config import Config
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def auth() -> None:
|
|
6
|
+
"""Configure your Fireflies API key."""
|
|
7
|
+
config = Config()
|
|
8
|
+
|
|
9
|
+
if config.api_key:
|
|
10
|
+
confirm = typer.confirm("An API key is already set. Overwrite?")
|
|
11
|
+
if not confirm:
|
|
12
|
+
raise typer.Exit(0)
|
|
13
|
+
|
|
14
|
+
key = typer.prompt("Enter your Fireflies API key", hide_input=True)
|
|
15
|
+
if not key or not key.strip():
|
|
16
|
+
typer.echo("Error: API key cannot be empty.", err=True)
|
|
17
|
+
raise typer.Exit(1)
|
|
18
|
+
|
|
19
|
+
config.set("api_key", key.strip())
|
|
20
|
+
typer.echo("API key saved. Run: ffx list to verify.")
|
ffx/commands/brief.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import typer
|
|
3
|
+
from ffx.config import Config
|
|
4
|
+
from ffx.api_client import FirefliesClient, AuthError, ApiError
|
|
5
|
+
from ffx.output import json_error, print_summary_brief
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def brief(
|
|
9
|
+
transcript_id: str = typer.Argument(..., help="Transcript ID"),
|
|
10
|
+
table: bool = typer.Option(False, "--table", help="Rich table output"),
|
|
11
|
+
) -> None:
|
|
12
|
+
"""Show a formatted AI brief for a meeting."""
|
|
13
|
+
json_mode = not table
|
|
14
|
+
config = Config()
|
|
15
|
+
if not config.api_key:
|
|
16
|
+
if json_mode:
|
|
17
|
+
typer.echo(json_error("API key not configured", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
18
|
+
else:
|
|
19
|
+
typer.echo("API key not set. Run: ffx auth", err=True)
|
|
20
|
+
raise typer.Exit(2)
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
client = FirefliesClient(config.api_key)
|
|
24
|
+
t = client.get_transcript(transcript_id)
|
|
25
|
+
except AuthError:
|
|
26
|
+
if json_mode:
|
|
27
|
+
typer.echo(json_error("API key may have expired", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
28
|
+
else:
|
|
29
|
+
typer.echo("API key may have expired. Run: ffx auth", err=True)
|
|
30
|
+
raise typer.Exit(2)
|
|
31
|
+
except ApiError as e:
|
|
32
|
+
if json_mode:
|
|
33
|
+
typer.echo(json_error(str(e), "API_ERROR"))
|
|
34
|
+
else:
|
|
35
|
+
typer.echo(f"API error: {e}", err=True)
|
|
36
|
+
raise typer.Exit(2)
|
|
37
|
+
|
|
38
|
+
if t is None:
|
|
39
|
+
if json_mode:
|
|
40
|
+
typer.echo(json_error("Transcript not found", "NOT_FOUND"))
|
|
41
|
+
else:
|
|
42
|
+
typer.echo(f"Transcript '{transcript_id}' not found.", err=True)
|
|
43
|
+
raise typer.Exit(3)
|
|
44
|
+
|
|
45
|
+
print_summary_brief(t, json_mode=json_mode)
|
ffx/commands/export.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import csv
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from ffx.config import Config
|
|
9
|
+
from ffx.api_client import FirefliesClient, AuthError, ApiError
|
|
10
|
+
from ffx.output import json_error
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ExportFormat(str, Enum):
|
|
14
|
+
json = "json"
|
|
15
|
+
md = "md"
|
|
16
|
+
obsidian = "obsidian"
|
|
17
|
+
csv = "csv"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _to_md(t) -> str:
|
|
21
|
+
lines = [f"# {t.title}", "", f"**Date:** {t.display_date}"]
|
|
22
|
+
if t.organizer_email:
|
|
23
|
+
lines.append(f"**Organizer:** {t.organizer_email}")
|
|
24
|
+
if t.participants:
|
|
25
|
+
names = ", ".join(t.participants)
|
|
26
|
+
lines.append(f"**Participants:** {names}")
|
|
27
|
+
lines.append("")
|
|
28
|
+
if t.summary and t.summary.overview:
|
|
29
|
+
lines += ["## Overview", "", t.summary.overview, ""]
|
|
30
|
+
if t.summary and t.summary.bullet_gist_list:
|
|
31
|
+
lines += ["## Key Points", ""]
|
|
32
|
+
for b in t.summary.bullet_gist_list:
|
|
33
|
+
lines.append(f"- {b}")
|
|
34
|
+
lines.append("")
|
|
35
|
+
if t.summary and t.summary.action_items_list:
|
|
36
|
+
lines += ["## Action Items", ""]
|
|
37
|
+
for item in t.summary.action_items_list:
|
|
38
|
+
lines.append(f"- [ ] {item}")
|
|
39
|
+
lines.append("")
|
|
40
|
+
if t.summary and t.summary.keywords:
|
|
41
|
+
lines.append(f"**Keywords:** {', '.join(t.summary.keywords)}")
|
|
42
|
+
return "\n".join(lines)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _yaml_str(s: str) -> str:
|
|
46
|
+
if any(c in s for c in (':', '#', '"', "'", '{', '[', '>', '|', '\n')):
|
|
47
|
+
escaped = s.replace('\\', '\\\\').replace('"', '\\"')
|
|
48
|
+
return f'"{escaped}"'
|
|
49
|
+
return s
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _to_obsidian(t) -> str:
|
|
53
|
+
participants = list(t.participants)
|
|
54
|
+
keywords = (t.summary.keywords if t.summary else []) or []
|
|
55
|
+
frontmatter = [
|
|
56
|
+
"---",
|
|
57
|
+
f"title: {_yaml_str(t.title)}",
|
|
58
|
+
f"date: {t.display_date}",
|
|
59
|
+
f"duration: {t.duration // 60 if t.duration else 0}m",
|
|
60
|
+
f"organizer: {t.organizer_email or ''}",
|
|
61
|
+
"participants:",
|
|
62
|
+
]
|
|
63
|
+
for p in participants:
|
|
64
|
+
frontmatter.append(f" - {p}")
|
|
65
|
+
frontmatter.append("tags:")
|
|
66
|
+
for k in keywords:
|
|
67
|
+
frontmatter.append(f" - {_yaml_str(k)}")
|
|
68
|
+
frontmatter += ["source: fireflies", f"transcript_id: {t.id}", "---", ""]
|
|
69
|
+
md_body = _to_md(t)
|
|
70
|
+
# Strip the first line (# Title) since it's in frontmatter
|
|
71
|
+
body_lines = md_body.split("\n")
|
|
72
|
+
body = "\n".join(body_lines[1:]) if body_lines else ""
|
|
73
|
+
return "\n".join(frontmatter) + body
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _to_csv(t) -> str:
|
|
77
|
+
output = io.StringIO()
|
|
78
|
+
writer = csv.writer(output)
|
|
79
|
+
writer.writerow(["id", "title", "date", "duration_seconds", "organizer_email", "participants", "overview", "keywords"])
|
|
80
|
+
participants_str = "|".join(t.participants)
|
|
81
|
+
overview = t.summary.overview if t.summary else ""
|
|
82
|
+
keywords = "|".join(t.summary.keywords if t.summary else [])
|
|
83
|
+
writer.writerow([t.id, t.title, t.display_date, t.duration, t.organizer_email or "", participants_str, overview or "", keywords])
|
|
84
|
+
return output.getvalue()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def export(
|
|
88
|
+
transcript_id: str = typer.Argument(..., help="Transcript ID"),
|
|
89
|
+
format: ExportFormat = typer.Option(ExportFormat.json, "--format", help="Output format: json|md|obsidian|csv"),
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Export a meeting transcript in various formats."""
|
|
92
|
+
config = Config()
|
|
93
|
+
if not config.api_key:
|
|
94
|
+
typer.echo(json_error("API key not configured", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
95
|
+
raise typer.Exit(2)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
client = FirefliesClient(config.api_key)
|
|
99
|
+
t = client.get_transcript(transcript_id)
|
|
100
|
+
except AuthError:
|
|
101
|
+
typer.echo("API key may have expired. Run: ffx auth", err=True)
|
|
102
|
+
raise typer.Exit(2)
|
|
103
|
+
except ApiError as e:
|
|
104
|
+
typer.echo(f"API error: {e}", err=True)
|
|
105
|
+
raise typer.Exit(2)
|
|
106
|
+
|
|
107
|
+
if t is None:
|
|
108
|
+
typer.echo(f"Transcript '{transcript_id}' not found.", err=True)
|
|
109
|
+
raise typer.Exit(3)
|
|
110
|
+
|
|
111
|
+
if format == ExportFormat.json:
|
|
112
|
+
typer.echo(json.dumps({
|
|
113
|
+
"id": t.id, "title": t.title, "date": t.display_date,
|
|
114
|
+
"duration_seconds": t.duration, "organizer_email": t.organizer_email,
|
|
115
|
+
"participants": t.participants,
|
|
116
|
+
"summary": {
|
|
117
|
+
"overview": t.summary.overview,
|
|
118
|
+
"bullet_gist": t.summary.bullet_gist_list,
|
|
119
|
+
"action_items": t.summary.action_items_list,
|
|
120
|
+
"keywords": t.summary.keywords,
|
|
121
|
+
"topics_discussed": t.summary.topics_list,
|
|
122
|
+
} if t.summary else None,
|
|
123
|
+
}, indent=2))
|
|
124
|
+
elif format == ExportFormat.md:
|
|
125
|
+
typer.echo(_to_md(t))
|
|
126
|
+
elif format == ExportFormat.obsidian:
|
|
127
|
+
typer.echo(_to_obsidian(t))
|
|
128
|
+
elif format == ExportFormat.csv:
|
|
129
|
+
typer.echo(_to_csv(t), nl=False)
|
ffx/commands/get.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import typer
|
|
4
|
+
from ffx.config import Config
|
|
5
|
+
from ffx.api_client import FirefliesClient, AuthError, ApiError
|
|
6
|
+
from ffx.output import json_error, transcript_detail
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get(
|
|
10
|
+
transcript_id: str = typer.Argument(..., help="Transcript ID"),
|
|
11
|
+
table: bool = typer.Option(False, "--table", help="Rich table output"),
|
|
12
|
+
) -> None:
|
|
13
|
+
"""Fetch a single transcript by ID."""
|
|
14
|
+
json_mode = not table
|
|
15
|
+
config = Config()
|
|
16
|
+
if not config.api_key:
|
|
17
|
+
if json_mode:
|
|
18
|
+
typer.echo(json_error("API key not configured", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
19
|
+
else:
|
|
20
|
+
typer.echo("API key not set. Run: ffx auth", err=True)
|
|
21
|
+
raise typer.Exit(2)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
client = FirefliesClient(config.api_key)
|
|
25
|
+
t = client.get_transcript(transcript_id)
|
|
26
|
+
except AuthError:
|
|
27
|
+
if json_mode:
|
|
28
|
+
typer.echo(json_error("API key may have expired", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
29
|
+
else:
|
|
30
|
+
typer.echo("API key may have expired. Run: ffx auth", err=True)
|
|
31
|
+
raise typer.Exit(2)
|
|
32
|
+
except ApiError as e:
|
|
33
|
+
if json_mode:
|
|
34
|
+
typer.echo(json_error(str(e), "API_ERROR"))
|
|
35
|
+
else:
|
|
36
|
+
typer.echo(f"API error: {e}", err=True)
|
|
37
|
+
raise typer.Exit(2)
|
|
38
|
+
|
|
39
|
+
if t is None:
|
|
40
|
+
if json_mode:
|
|
41
|
+
typer.echo(json_error(f"Transcript {transcript_id!r} not found", "NOT_FOUND"))
|
|
42
|
+
else:
|
|
43
|
+
typer.echo(f"Transcript '{transcript_id}' not found.", err=True)
|
|
44
|
+
raise typer.Exit(3)
|
|
45
|
+
|
|
46
|
+
if json_mode:
|
|
47
|
+
typer.echo(json.dumps({
|
|
48
|
+
"id": t.id,
|
|
49
|
+
"title": t.title,
|
|
50
|
+
"date": t.display_date,
|
|
51
|
+
"duration_seconds": t.duration,
|
|
52
|
+
"organizer_email": t.organizer_email,
|
|
53
|
+
"participants": t.participants,
|
|
54
|
+
"summary": {
|
|
55
|
+
"overview": t.summary.overview,
|
|
56
|
+
"gist": t.summary.gist,
|
|
57
|
+
"bullet_gist": t.summary.bullet_gist_list,
|
|
58
|
+
"keywords": t.summary.keywords,
|
|
59
|
+
"topics_discussed": t.summary.topics_list,
|
|
60
|
+
"action_items": t.summary.action_items_list,
|
|
61
|
+
} if t.summary else None,
|
|
62
|
+
}, indent=2))
|
|
63
|
+
else:
|
|
64
|
+
transcript_detail(t)
|