convoviz 0.2.2__py3-none-any.whl → 0.2.4__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.
@@ -4,11 +4,15 @@ from collections import defaultdict
4
4
  from datetime import UTC, datetime
5
5
  from pathlib import Path
6
6
 
7
+ import matplotlib.dates as mdates
8
+ import matplotlib.font_manager as fm
9
+ from matplotlib.axes import Axes
7
10
  from matplotlib.figure import Figure
8
11
  from tqdm import tqdm
9
12
 
10
- from convoviz.config import GraphConfig
13
+ from convoviz.config import GraphConfig, get_default_config
11
14
  from convoviz.models import ConversationCollection
15
+ from convoviz.utils import get_asset_path
12
16
 
13
17
  WEEKDAYS = [
14
18
  "Monday",
@@ -21,52 +25,416 @@ WEEKDAYS = [
21
25
  ]
22
26
 
23
27
 
28
+ def _setup_figure(config: GraphConfig) -> tuple[Figure, Axes, fm.FontProperties]:
29
+ """Internal helper to setup a figure with common styling."""
30
+ fig = Figure(figsize=config.figsize, dpi=config.dpi)
31
+ ax: Axes = fig.add_subplot()
32
+
33
+ # Load custom font if possible
34
+ font_path = get_asset_path(f"fonts/{config.font_name}")
35
+ font_prop = (
36
+ fm.FontProperties(fname=str(font_path)) if font_path.exists() else fm.FontProperties()
37
+ )
38
+
39
+ # Styling
40
+ fig.set_facecolor("white")
41
+ ax.set_facecolor("white")
42
+ ax.spines["top"].set_visible(False)
43
+ ax.spines["right"].set_visible(False)
44
+ if config.grid:
45
+ ax.grid(axis="y", linestyle="--", alpha=0.7)
46
+ ax.set_axisbelow(True)
47
+
48
+ return fig, ax, font_prop
49
+
50
+
51
+ def _ts_to_dt(ts: float, config: GraphConfig) -> datetime:
52
+ """Convert epoch timestamps into aware datetimes based on config."""
53
+ dt_utc = datetime.fromtimestamp(ts, UTC)
54
+ if config.timezone == "utc":
55
+ return dt_utc
56
+ return dt_utc.astimezone()
57
+
58
+
59
+ def _tz_label(config: GraphConfig) -> str:
60
+ return "UTC" if config.timezone == "utc" else "Local"
61
+
62
+
24
63
  def generate_week_barplot(
25
64
  timestamps: list[float],
26
65
  title: str,
27
- _config: GraphConfig | None = None,
66
+ config: GraphConfig | None = None,
28
67
  ) -> Figure:
29
68
  """Create a bar graph showing message distribution across weekdays.
30
69
 
31
70
  Args:
32
71
  timestamps: List of Unix timestamps
33
72
  title: Title for the graph
34
- config: Optional graph configuration (for future extensions)
73
+ config: Optional graph configuration
35
74
 
36
75
  Returns:
37
76
  Matplotlib Figure object
38
77
  """
39
- dates = [datetime.fromtimestamp(ts, UTC) for ts in timestamps]
78
+ cfg = config or get_default_config().graph
79
+ dates = [_ts_to_dt(ts, cfg) for ts in timestamps]
40
80
 
41
81
  weekday_counts: defaultdict[str, int] = defaultdict(int)
42
82
  for date in dates:
43
83
  weekday_counts[WEEKDAYS[date.weekday()]] += 1
44
84
 
45
- x = WEEKDAYS
85
+ x = list(range(len(WEEKDAYS)))
46
86
  y = [weekday_counts[day] for day in WEEKDAYS]
47
87
 
48
- fig = Figure(dpi=300)
49
- ax = fig.add_subplot()
88
+ fig, ax, font_prop = _setup_figure(cfg)
89
+
90
+ bars = ax.bar(x, y, color=cfg.color, alpha=0.85)
91
+
92
+ if cfg.show_counts:
93
+ for bar in bars:
94
+ height = bar.get_height()
95
+ if height > 0:
96
+ ax.text(
97
+ bar.get_x() + bar.get_width() / 2.0,
98
+ height,
99
+ f"{int(height)}",
100
+ ha="center",
101
+ va="bottom",
102
+ fontproperties=font_prop,
103
+ )
104
+
105
+ ax.set_xlabel("Weekday", fontproperties=font_prop)
106
+ ax.set_ylabel("User Prompt Count", fontproperties=font_prop)
107
+ ax.set_title(title, fontproperties=font_prop, fontsize=16, pad=20)
108
+ ax.set_xticks(x)
109
+ ax.set_xticklabels(WEEKDAYS, rotation=45, fontproperties=font_prop)
110
+
111
+ for label in ax.get_yticklabels():
112
+ label.set_fontproperties(font_prop)
113
+
114
+ fig.tight_layout()
115
+ return fig
116
+
117
+
118
+ def generate_hour_barplot(
119
+ timestamps: list[float],
120
+ title: str,
121
+ config: GraphConfig | None = None,
122
+ ) -> Figure:
123
+ """Create a bar graph showing message distribution across hours of the day (0-23).
124
+
125
+ Args:
126
+ timestamps: List of Unix timestamps
127
+ title: Title for the graph
128
+ config: Optional graph configuration
129
+
130
+ Returns:
131
+ Matplotlib Figure object
132
+ """
133
+ cfg = config or get_default_config().graph
134
+ dates = [_ts_to_dt(ts, cfg) for ts in timestamps]
135
+
136
+ hour_counts: dict[int, int] = dict.fromkeys(range(24), 0)
137
+ for date in dates:
138
+ hour_counts[date.hour] += 1
139
+
140
+ x = [f"{i:02d}:00" for i in range(24)]
141
+ y = [hour_counts[i] for i in range(24)]
142
+
143
+ fig, ax, font_prop = _setup_figure(cfg)
144
+
145
+ bars = ax.bar(range(24), y, color=cfg.color, alpha=0.8)
146
+
147
+ if cfg.show_counts:
148
+ for bar in bars:
149
+ height = bar.get_height()
150
+ if height > 0:
151
+ ax.text(
152
+ bar.get_x() + bar.get_width() / 2.0,
153
+ height,
154
+ f"{int(height)}",
155
+ ha="center",
156
+ va="bottom",
157
+ fontproperties=font_prop,
158
+ fontsize=8,
159
+ )
160
+
161
+ ax.set_xlabel(f"Hour of Day ({_tz_label(cfg)})", fontproperties=font_prop)
162
+ ax.set_ylabel("User Prompt Count", fontproperties=font_prop)
163
+ ax.set_title(f"{title} - Hourly Distribution", fontproperties=font_prop, fontsize=16, pad=20)
164
+ ax.set_xticks(range(24))
165
+ ax.set_xticklabels(x, rotation=90, fontproperties=font_prop)
166
+
167
+ for label in ax.get_yticklabels():
168
+ label.set_fontproperties(font_prop)
50
169
 
51
- ax.bar(x, y)
52
- ax.set_xlabel("Weekday")
53
- ax.set_ylabel("Prompt Count")
54
- ax.set_title(title)
55
- ax.set_xticks(range(len(x)))
56
- ax.set_xticklabels(x, rotation=45)
57
170
  fig.tight_layout()
171
+ return fig
172
+
173
+
174
+ def generate_model_piechart(
175
+ collection: ConversationCollection,
176
+ config: GraphConfig | None = None,
177
+ ) -> Figure:
178
+ """Create a pie chart showing distribution of models used.
179
+
180
+ Groups models with < 5% usage into "Other".
181
+
182
+ Args:
183
+ collection: Collection of conversations
184
+ config: Optional graph configuration
185
+
186
+ Returns:
187
+ Matplotlib Figure object
188
+ """
189
+ cfg = config or get_default_config().graph
190
+ model_counts: defaultdict[str, int] = defaultdict(int)
58
191
 
192
+ for conv in collection.conversations:
193
+ model = conv.model or "Unknown"
194
+ model_counts[model] += 1
195
+
196
+ total = sum(model_counts.values())
197
+ if total == 0:
198
+ # Return empty figure or figure with "No Data"
199
+ fig, ax, font_prop = _setup_figure(cfg)
200
+ ax.text(0.5, 0.5, "No Data", ha="center", va="center", fontproperties=font_prop)
201
+ return fig
202
+
203
+ # Group minor models
204
+ threshold = 0.05
205
+ refined_counts: dict[str, int] = {}
206
+ other_count = 0
207
+
208
+ for model, count in model_counts.items():
209
+ if count / total < threshold:
210
+ other_count += count
211
+ else:
212
+ refined_counts[model] = count
213
+
214
+ if other_count > 0:
215
+ refined_counts["Other"] = other_count
216
+
217
+ # Sort for consistent display
218
+ sorted_items = sorted(refined_counts.items(), key=lambda x: x[1], reverse=True)
219
+ labels = [item[0] for item in sorted_items]
220
+ sizes = [item[1] for item in sorted_items]
221
+
222
+ fig, ax, font_prop = _setup_figure(cfg)
223
+
224
+ colors = [
225
+ "#4A90E2",
226
+ "#50E3C2",
227
+ "#F5A623",
228
+ "#D0021B",
229
+ "#8B572A",
230
+ "#417505",
231
+ "#9013FE",
232
+ "#BD10E0",
233
+ "#7F7F7F",
234
+ ]
235
+ ax.pie(
236
+ sizes,
237
+ labels=labels,
238
+ autopct="%1.1f%%",
239
+ startangle=140,
240
+ colors=colors[: len(labels)],
241
+ textprops={"fontproperties": font_prop},
242
+ )
243
+ ax.set_title("Model Usage Distribution", fontproperties=font_prop, fontsize=16, pad=20)
244
+
245
+ fig.tight_layout()
59
246
  return fig
60
247
 
61
248
 
62
- def generate_week_barplots(
249
+ def generate_length_histogram(
250
+ collection: ConversationCollection,
251
+ config: GraphConfig | None = None,
252
+ ) -> Figure:
253
+ """Create a histogram showing distribution of conversation lengths.
254
+
255
+ Caps the X-axis at the 95th percentile to focus on typical lengths.
256
+
257
+ Args:
258
+ collection: Collection of conversations
259
+ config: Optional graph configuration
260
+
261
+ Returns:
262
+ Matplotlib Figure object
263
+ """
264
+ cfg = config or get_default_config().graph
265
+ lengths = [conv.message_count("user") for conv in collection.conversations]
266
+
267
+ fig, ax, font_prop = _setup_figure(cfg)
268
+
269
+ if not lengths:
270
+ ax.text(0.5, 0.5, "No Data", ha="center", va="center", fontproperties=font_prop)
271
+ return fig
272
+
273
+ # Cap at 95th percentile to focus on most conversations
274
+ sorted_lengths = sorted(lengths)
275
+ idx = int(0.95 * (len(sorted_lengths) - 1))
276
+ cap = int(sorted_lengths[idx])
277
+ cap = max(cap, 5) # Ensure at least some range
278
+
279
+ # Filter lengths for the histogram plot, but keep the data correct
280
+ plot_lengths = [min(L, cap) for L in lengths]
281
+
282
+ bins = range(0, cap + 2, max(1, cap // 10))
283
+ ax.hist(plot_lengths, bins=bins, color=cfg.color, alpha=0.8, rwidth=0.8)
284
+
285
+ ax.set_xlabel("Number of User Prompts", fontproperties=font_prop)
286
+ ax.set_ylabel("Number of Conversations", fontproperties=font_prop)
287
+ ax.set_title(
288
+ f"Conversation Length Distribution (Capped at {cap})",
289
+ fontproperties=font_prop,
290
+ fontsize=16,
291
+ pad=20,
292
+ )
293
+
294
+ for label in ax.get_xticklabels() + ax.get_yticklabels():
295
+ label.set_fontproperties(font_prop)
296
+
297
+ fig.tight_layout()
298
+ return fig
299
+
300
+
301
+ def generate_monthly_activity_barplot(
302
+ collection: ConversationCollection,
303
+ config: GraphConfig | None = None,
304
+ ) -> Figure:
305
+ """Create a bar chart showing total prompt count per month with readable labels.
306
+
307
+ Args:
308
+ collection: Collection of conversations
309
+ config: Optional graph configuration
310
+
311
+ Returns:
312
+ Matplotlib Figure object
313
+ """
314
+ cfg = config or get_default_config().graph
315
+ month_groups = collection.group_by_month()
316
+ sorted_months = sorted(month_groups.keys())
317
+
318
+ # Format labels as "Feb '23"
319
+ x = [m.strftime("%b '%y") for m in sorted_months]
320
+ y = [len(month_groups[m].timestamps("user")) for m in sorted_months]
321
+
322
+ fig, ax, font_prop = _setup_figure(cfg)
323
+
324
+ positions = list(range(len(x)))
325
+ bars = ax.bar(positions, y, color=cfg.color, alpha=0.85)
326
+
327
+ if cfg.show_counts:
328
+ for bar in bars:
329
+ height = bar.get_height()
330
+ if height > 0:
331
+ ax.text(
332
+ bar.get_x() + bar.get_width() / 2.0,
333
+ height,
334
+ f"{int(height)}",
335
+ ha="center",
336
+ va="bottom",
337
+ fontproperties=font_prop,
338
+ fontsize=8,
339
+ )
340
+
341
+ ax.set_xlabel("Month", fontproperties=font_prop)
342
+ ax.set_ylabel("User Prompt Count", fontproperties=font_prop)
343
+ ax.set_title("Monthly Activity History", fontproperties=font_prop, fontsize=16, pad=20)
344
+ tick_step = max(1, len(positions) // 12) # show ~12 labels max
345
+ shown = positions[::tick_step] if positions else []
346
+ ax.set_xticks(shown)
347
+ ax.set_xticklabels([x[i] for i in shown], rotation=45, fontproperties=font_prop)
348
+
349
+ for label in ax.get_yticklabels():
350
+ label.set_fontproperties(font_prop)
351
+
352
+ fig.tight_layout()
353
+ return fig
354
+
355
+
356
+ def generate_daily_activity_lineplot(
357
+ collection: ConversationCollection,
358
+ config: GraphConfig | None = None,
359
+ ) -> Figure:
360
+ """Create a line chart showing user prompt count per day."""
361
+ cfg = config or get_default_config().graph
362
+ timestamps = collection.timestamps("user")
363
+
364
+ fig, ax, font_prop = _setup_figure(cfg)
365
+ if not timestamps:
366
+ ax.text(0.5, 0.5, "No Data", ha="center", va="center", fontproperties=font_prop)
367
+ return fig
368
+
369
+ counts: defaultdict[datetime, int] = defaultdict(int)
370
+ for ts in timestamps:
371
+ dt = _ts_to_dt(ts, cfg)
372
+ day = dt.replace(hour=0, minute=0, second=0, microsecond=0)
373
+ counts[day] += 1
374
+
375
+ days = sorted(counts.keys())
376
+ values = [counts[d] for d in days]
377
+
378
+ x = mdates.date2num(days)
379
+ ax.plot(x, values, color=cfg.color, linewidth=2.0)
380
+ ax.fill_between(x, values, color=cfg.color, alpha=0.15)
381
+ locator = mdates.AutoDateLocator()
382
+ ax.xaxis.set_major_locator(locator)
383
+ ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
384
+ ax.set_title("Daily Activity History", fontproperties=font_prop, fontsize=16, pad=20)
385
+ ax.set_xlabel(f"Day ({_tz_label(cfg)})", fontproperties=font_prop)
386
+ ax.set_ylabel("User Prompt Count", fontproperties=font_prop)
387
+
388
+ for label in ax.get_xticklabels() + ax.get_yticklabels():
389
+ label.set_fontproperties(font_prop)
390
+
391
+ fig.tight_layout()
392
+ return fig
393
+
394
+
395
+ def generate_summary_graphs(
396
+ collection: ConversationCollection,
397
+ output_dir: Path,
398
+ config: GraphConfig | None = None,
399
+ ) -> None:
400
+ """Generate all summary-level graphs.
401
+
402
+ Args:
403
+ collection: Collection of conversations
404
+ output_dir: Directory to save the graphs
405
+ config: Optional graph configuration
406
+ """
407
+ summary_dir = output_dir / "Summary"
408
+ summary_dir.mkdir(parents=True, exist_ok=True)
409
+
410
+ if not collection.conversations:
411
+ return
412
+
413
+ # Model usage
414
+ fig_models = generate_model_piechart(collection, config)
415
+ fig_models.savefig(summary_dir / "model_usage.png")
416
+
417
+ # Length distribution
418
+ fig_length = generate_length_histogram(collection, config)
419
+ fig_length.savefig(summary_dir / "conversation_lengths.png")
420
+
421
+ # Monthly activity
422
+ fig_activity = generate_monthly_activity_barplot(collection, config)
423
+ fig_activity.savefig(summary_dir / "monthly_activity.png")
424
+
425
+ # Daily activity
426
+ fig_daily = generate_daily_activity_lineplot(collection, config)
427
+ fig_daily.savefig(summary_dir / "daily_activity.png")
428
+
429
+
430
+ def generate_graphs(
63
431
  collection: ConversationCollection,
64
432
  output_dir: Path,
65
433
  config: GraphConfig | None = None,
66
434
  *,
67
435
  progress_bar: bool = False,
68
436
  ) -> None:
69
- """Generate weekly bar plots for monthly and yearly groupings.
437
+ """Generate weekly, hourly, and summary graphs.
70
438
 
71
439
  Args:
72
440
  collection: Collection of conversations
@@ -76,23 +444,44 @@ def generate_week_barplots(
76
444
  """
77
445
  output_dir.mkdir(parents=True, exist_ok=True)
78
446
 
447
+ # Summary graphs
448
+ generate_summary_graphs(collection, output_dir, config)
449
+
79
450
  month_groups = collection.group_by_month()
80
451
  year_groups = collection.group_by_year()
81
452
 
453
+ # Month-wise graphs
82
454
  for month, group in tqdm(
83
455
  month_groups.items(),
84
- desc="Creating monthly weekwise graphs 📈",
456
+ desc="Creating monthly graphs 📈",
85
457
  disable=not progress_bar,
86
458
  ):
459
+ base_name = month.strftime("%Y %B")
87
460
  title = month.strftime("%B '%y")
88
- fig = generate_week_barplot(group.timestamps("user"), title, config)
89
- fig.savefig(output_dir / f"{month.strftime('%Y %B')}.png")
461
+ timestamps = group.timestamps("user")
90
462
 
463
+ # Weekday distribution
464
+ fig_week = generate_week_barplot(timestamps, title, config)
465
+ fig_week.savefig(output_dir / f"{base_name}_weekly.png")
466
+
467
+ # Hourly distribution
468
+ fig_hour = generate_hour_barplot(timestamps, title, config)
469
+ fig_hour.savefig(output_dir / f"{base_name}_hourly.png")
470
+
471
+ # Year-wise graphs
91
472
  for year, group in tqdm(
92
473
  year_groups.items(),
93
- desc="Creating yearly weekwise graphs 📈",
474
+ desc="Creating yearly graphs 📈",
94
475
  disable=not progress_bar,
95
476
  ):
477
+ base_name = year.strftime("%Y")
96
478
  title = year.strftime("%Y")
97
- fig = generate_week_barplot(group.timestamps("user"), title, config)
98
- fig.savefig(output_dir / f"{year.strftime('%Y')}.png")
479
+ timestamps = group.timestamps("user")
480
+
481
+ # Weekday distribution
482
+ fig_week = generate_week_barplot(timestamps, title, config)
483
+ fig_week.savefig(output_dir / f"{base_name}_weekly.png")
484
+
485
+ # Hourly distribution
486
+ fig_hour = generate_hour_barplot(timestamps, title, config)
487
+ fig_hour.savefig(output_dir / f"{base_name}_hourly.png")
@@ -24,6 +24,23 @@ STOPWORD_LANGUAGES = [
24
24
  ]
25
25
 
26
26
 
27
+ @lru_cache(maxsize=1)
28
+ def load_programming_stopwords() -> frozenset[str]:
29
+ """Load programming keywords and types from assets.
30
+
31
+ Returns:
32
+ Frozen set of programming stop words
33
+ """
34
+ stopwords_path = Path(__file__).parent.parent / "assets" / "stopwords.txt"
35
+ if not stopwords_path.exists():
36
+ return frozenset()
37
+
38
+ with open(stopwords_path, encoding="utf-8") as f:
39
+ return frozenset(
40
+ line.strip().lower() for line in f if line.strip() and not line.strip().startswith("#")
41
+ )
42
+
43
+
27
44
  @lru_cache(maxsize=1)
28
45
  def load_nltk_stopwords() -> frozenset[str]:
29
46
  """Load and cache NLTK stopwords.
@@ -45,7 +62,7 @@ def load_nltk_stopwords() -> frozenset[str]:
45
62
  return frozenset(words)
46
63
 
47
64
 
48
- def parse_custom_stopwords(stopwords_str: str) -> set[str]:
65
+ def parse_custom_stopwords(stopwords_str: str | None) -> set[str]:
49
66
  """Parse a comma-separated string of custom stopwords.
50
67
 
51
68
  Args:
@@ -74,6 +91,9 @@ def generate_wordcloud(text: str, config: WordCloudConfig) -> Image:
74
91
  stopwords = set(load_nltk_stopwords())
75
92
  stopwords.update(parse_custom_stopwords(config.custom_stopwords))
76
93
 
94
+ if config.exclude_programming_keywords:
95
+ stopwords.update(load_programming_stopwords())
96
+
77
97
  wc = WordCloud(
78
98
  font_path=str(config.font_path) if config.font_path else None,
79
99
  width=config.width,
@@ -0,0 +1,75 @@
1
+ # Python
2
+ def
3
+ class
4
+ import
5
+ from
6
+ as
7
+ elif
8
+ finally
9
+ yield
10
+ pass
11
+ lambda
12
+ async
13
+ await
14
+ nonlocal
15
+ assert
16
+ self
17
+ cls
18
+ # JavaScript / TypeScript
19
+ const
20
+ let
21
+ var
22
+ function
23
+ export
24
+ default
25
+ extends
26
+ implements
27
+ static
28
+ # Java / C#
29
+ final
30
+ abstract
31
+ new
32
+ super
33
+ package
34
+ throws
35
+ synchronized
36
+ volatile
37
+ transient
38
+ native
39
+ strictfp
40
+ override
41
+ # C / C++
42
+ unsigned
43
+ signed
44
+ typedef
45
+ sizeof
46
+ extern
47
+ register
48
+ restrict
49
+ inline
50
+ template
51
+ typename
52
+ virtual
53
+ friend
54
+ mutable
55
+ explicit
56
+ operator
57
+ typeid
58
+ # Rust
59
+ mut
60
+ fn
61
+ pub
62
+ mod
63
+ trait
64
+ impl
65
+ where
66
+ loop
67
+ unsafe
68
+ crate
69
+ dyn
70
+ # Go
71
+ func
72
+ chan
73
+ defer
74
+ fallthrough
75
+ goto
convoviz/cli.py CHANGED
@@ -8,7 +8,7 @@ from rich.console import Console
8
8
  from convoviz.config import get_default_config
9
9
  from convoviz.exceptions import ConfigurationError, InvalidZipError
10
10
  from convoviz.interactive import run_interactive_config
11
- from convoviz.io.loaders import find_latest_zip, validate_zip
11
+ from convoviz.io.loaders import find_latest_zip
12
12
  from convoviz.pipeline import run_pipeline
13
13
  from convoviz.utils import default_font_path
14
14
 
@@ -22,14 +22,15 @@ console = Console()
22
22
  @app.callback(invoke_without_command=True)
23
23
  def run(
24
24
  ctx: typer.Context,
25
- zip_path: Path | None = typer.Option(
25
+ input_path: Path | None = typer.Option(
26
26
  None,
27
+ "--input",
27
28
  "--zip",
28
29
  "-z",
29
- help="Path to the ChatGPT export zip file.",
30
+ help="Path to the ChatGPT export zip file, JSON file, or extracted directory.",
30
31
  exists=True,
31
32
  file_okay=True,
32
- dir_okay=False,
33
+ dir_okay=True,
33
34
  ),
34
35
  output_dir: Path | None = typer.Option(
35
36
  None,
@@ -52,13 +53,13 @@ def run(
52
53
  config = get_default_config()
53
54
 
54
55
  # Override with CLI args
55
- if zip_path:
56
- config.zip_filepath = zip_path
56
+ if input_path:
57
+ config.input_path = input_path
57
58
  if output_dir:
58
59
  config.output_folder = output_dir
59
60
 
60
- # Determine mode: interactive if explicitly requested or no zip provided
61
- use_interactive = interactive if interactive is not None else (zip_path is None)
61
+ # Determine mode: interactive if explicitly requested or no input provided
62
+ use_interactive = interactive if interactive is not None else (input_path is None)
62
63
 
63
64
  if use_interactive:
64
65
  console.print("Welcome to ChatGPT Data Visualizer ✨📊!\n")
@@ -69,21 +70,23 @@ def run(
69
70
  raise typer.Exit(code=0) from None
70
71
  else:
71
72
  # Non-interactive mode: validate we have what we need
72
- if not config.zip_filepath:
73
+ if not config.input_path:
73
74
  # Try to find a default
74
75
  latest = find_latest_zip()
75
76
  if latest:
76
- console.print(f"No zip file specified, using latest found: {latest}")
77
- config.zip_filepath = latest
77
+ console.print(f"No input specified, using latest zip found: {latest}")
78
+ config.input_path = latest
78
79
  else:
79
80
  console.print(
80
- "[bold red]Error:[/bold red] No zip file provided and none found in Downloads."
81
+ "[bold red]Error:[/bold red] No input file provided and none found in Downloads."
81
82
  )
82
83
  raise typer.Exit(code=1)
83
84
 
84
- # Validate the zip
85
- if not validate_zip(config.zip_filepath):
86
- console.print(f"[bold red]Error:[/bold red] Invalid zip file: {config.zip_filepath}")
85
+ # Validate the input (basic check)
86
+ if not config.input_path.exists():
87
+ console.print(
88
+ f"[bold red]Error:[/bold red] Input path does not exist: {config.input_path}"
89
+ )
87
90
  raise typer.Exit(code=1)
88
91
 
89
92
  # Set default font if not set