convoviz 0.2.3__tar.gz → 0.2.5__tar.gz

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.
Files changed (62) hide show
  1. {convoviz-0.2.3 → convoviz-0.2.5}/PKG-INFO +30 -5
  2. {convoviz-0.2.3 → convoviz-0.2.5}/README.md +29 -4
  3. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/analysis/graphs.py +98 -40
  4. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/analysis/wordcloud.py +1 -1
  5. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/config.py +3 -1
  6. convoviz-0.2.5/convoviz/interactive.py +247 -0
  7. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/io/loaders.py +28 -5
  8. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/models/collection.py +12 -6
  9. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/models/conversation.py +24 -15
  10. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/models/message.py +42 -4
  11. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/pipeline.py +31 -8
  12. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/renderers/markdown.py +57 -21
  13. convoviz-0.2.5/convoviz/renderers/yaml.py +119 -0
  14. {convoviz-0.2.3 → convoviz-0.2.5}/pyproject.toml +2 -3
  15. convoviz-0.2.3/convoviz/interactive.py +0 -188
  16. convoviz-0.2.3/convoviz/renderers/yaml.py +0 -42
  17. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/__init__.py +0 -0
  18. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/__main__.py +0 -0
  19. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/analysis/__init__.py +0 -0
  20. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/colormaps.txt +0 -0
  21. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/AmaticSC-Regular.ttf +0 -0
  22. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/ArchitectsDaughter-Regular.ttf +0 -0
  23. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/BebasNeue-Regular.ttf +0 -0
  24. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Borel-Regular.ttf +0 -0
  25. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Courgette-Regular.ttf +0 -0
  26. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/CroissantOne-Regular.ttf +0 -0
  27. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Handjet-Regular.ttf +0 -0
  28. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/IndieFlower-Regular.ttf +0 -0
  29. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Kalam-Regular.ttf +0 -0
  30. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Lobster-Regular.ttf +0 -0
  31. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/MartianMono-Regular.ttf +0 -0
  32. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/MartianMono-Thin.ttf +0 -0
  33. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Montserrat-Regular.ttf +0 -0
  34. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Mooli-Regular.ttf +0 -0
  35. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Pacifico-Regular.ttf +0 -0
  36. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/PlayfairDisplay-Regular.ttf +0 -0
  37. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Raleway-Regular.ttf +0 -0
  38. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/RobotoMono-Regular.ttf +0 -0
  39. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/RobotoMono-Thin.ttf +0 -0
  40. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/RobotoSlab-Regular.ttf +0 -0
  41. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/RobotoSlab-Thin.ttf +0 -0
  42. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Ruwudu-Regular.ttf +0 -0
  43. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Sacramento-Regular.ttf +0 -0
  44. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/SedgwickAveDisplay-Regular.ttf +0 -0
  45. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/ShadowsIntoLight-Regular.ttf +0 -0
  46. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/TitilliumWeb-Regular.ttf +0 -0
  47. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Yellowtail-Regular.ttf +0 -0
  48. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/YsabeauOffice-Regular.ttf +0 -0
  49. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/YsabeauSC-Regular.ttf +0 -0
  50. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/YsabeauSC-Thin.ttf +0 -0
  51. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Zeyada-Regular.ttf +0 -0
  52. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/stopwords.txt +0 -0
  53. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/cli.py +0 -0
  54. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/exceptions.py +0 -0
  55. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/io/__init__.py +0 -0
  56. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/io/assets.py +0 -0
  57. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/io/writers.py +0 -0
  58. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/models/__init__.py +0 -0
  59. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/models/node.py +0 -0
  60. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/py.typed +0 -0
  61. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/renderers/__init__.py +0 -0
  62. {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: convoviz
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: Get analytics and visualizations on your ChatGPT data!
5
5
  Keywords: markdown,chatgpt,openai,visualization,analytics,json,export,data-analysis,obsidian
6
6
  Author: Mohamed Cheikh Sidiya
@@ -24,7 +24,7 @@ Requires-Python: >=3.12
24
24
  Project-URL: Repository, https://github.com/mohamed-chs/chatgpt-history-export-to-md
25
25
  Description-Content-Type: text/markdown
26
26
 
27
- # Convoviz 📊: Visualize your entire ChatGPT data !
27
+ # Convoviz 📊: Visualize your entire ChatGPT data
28
28
 
29
29
  Convert your ChatGPT history into well-formatted Markdown files. Additionally, visualize your data with word clouds 🔡☁️, view your prompt history graphs 📈, and access all your custom instructions 🤖 in a single location.
30
30
 
@@ -68,7 +68,7 @@ or pipx:
68
68
  pipx install convoviz
69
69
  ```
70
70
 
71
- ### 3. Run the Script 🏃‍♂️
71
+ ### 3. Run the tool 🏃‍♂️
72
72
 
73
73
  Simply run the command and follow the prompts:
74
74
 
@@ -81,9 +81,18 @@ convoviz
81
81
  You can provide arguments directly to skip the prompts:
82
82
 
83
83
  ```bash
84
- convoviz --zip path/to/your/export.zip --output path/to/output/folder
84
+ convoviz --input path/to/your/export.zip --output path/to/output/folder
85
85
  ```
86
86
 
87
+ Inputs can be any of:
88
+ - A ChatGPT export ZIP (downloaded from OpenAI)
89
+ - An extracted export directory containing `conversations.json`
90
+ - A `conversations.json` file directly
91
+
92
+ Notes:
93
+ - `--zip` / `-z` is kept as an alias for `--input` for convenience.
94
+ - You can force non-interactive mode with `--no-interactive`.
95
+
87
96
  For more options, run:
88
97
 
89
98
  ```bash
@@ -118,4 +127,20 @@ It was also a great opportunity to learn more about Python and type annotations.
118
127
 
119
128
  It should(?) also work as library, so you can import and use the models and functions. I need to add more documentation for that tho. Feel free to reach out if you need help.
120
129
 
121
- I'm working on automating it to add new conversations and updating old ones. Had some luck with a JavaScript bookmarklet, still ironing it out tho.
130
+ ### Offline / reproducible runs
131
+
132
+ Convoviz uses NLTK stopwords for word clouds. If you’re offline and NLTK data isn’t already installed, pre-download it once:
133
+
134
+ ```bash
135
+ python -c "import nltk; nltk.download('stopwords')"
136
+ ```
137
+
138
+ If you’re using `uv` without a global install, you can run:
139
+
140
+ ```bash
141
+ uv run python -c "import nltk; nltk.download('stopwords')"
142
+ ```
143
+
144
+ ### Bookmarklet
145
+
146
+ There’s also a JavaScript bookmarklet flow under `js/` (experimental) for exporting additional conversation data outside the official ZIP export.
@@ -1,4 +1,4 @@
1
- # Convoviz 📊: Visualize your entire ChatGPT data !
1
+ # Convoviz 📊: Visualize your entire ChatGPT data
2
2
 
3
3
  Convert your ChatGPT history into well-formatted Markdown files. Additionally, visualize your data with word clouds 🔡☁️, view your prompt history graphs 📈, and access all your custom instructions 🤖 in a single location.
4
4
 
@@ -42,7 +42,7 @@ or pipx:
42
42
  pipx install convoviz
43
43
  ```
44
44
 
45
- ### 3. Run the Script 🏃‍♂️
45
+ ### 3. Run the tool 🏃‍♂️
46
46
 
47
47
  Simply run the command and follow the prompts:
48
48
 
@@ -55,9 +55,18 @@ convoviz
55
55
  You can provide arguments directly to skip the prompts:
56
56
 
57
57
  ```bash
58
- convoviz --zip path/to/your/export.zip --output path/to/output/folder
58
+ convoviz --input path/to/your/export.zip --output path/to/output/folder
59
59
  ```
60
60
 
61
+ Inputs can be any of:
62
+ - A ChatGPT export ZIP (downloaded from OpenAI)
63
+ - An extracted export directory containing `conversations.json`
64
+ - A `conversations.json` file directly
65
+
66
+ Notes:
67
+ - `--zip` / `-z` is kept as an alias for `--input` for convenience.
68
+ - You can force non-interactive mode with `--no-interactive`.
69
+
61
70
  For more options, run:
62
71
 
63
72
  ```bash
@@ -92,4 +101,20 @@ It was also a great opportunity to learn more about Python and type annotations.
92
101
 
93
102
  It should(?) also work as library, so you can import and use the models and functions. I need to add more documentation for that tho. Feel free to reach out if you need help.
94
103
 
95
- I'm working on automating it to add new conversations and updating old ones. Had some luck with a JavaScript bookmarklet, still ironing it out tho.
104
+ ### Offline / reproducible runs
105
+
106
+ Convoviz uses NLTK stopwords for word clouds. If you’re offline and NLTK data isn’t already installed, pre-download it once:
107
+
108
+ ```bash
109
+ python -c "import nltk; nltk.download('stopwords')"
110
+ ```
111
+
112
+ If you’re using `uv` without a global install, you can run:
113
+
114
+ ```bash
115
+ uv run python -c "import nltk; nltk.download('stopwords')"
116
+ ```
117
+
118
+ ### Bookmarklet
119
+
120
+ There’s also a JavaScript bookmarklet flow under `js/` (experimental) for exporting additional conversation data outside the official ZIP export.
@@ -4,7 +4,9 @@ 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
7
8
  import matplotlib.font_manager as fm
9
+ from matplotlib.axes import Axes
8
10
  from matplotlib.figure import Figure
9
11
  from tqdm import tqdm
10
12
 
@@ -23,10 +25,10 @@ WEEKDAYS = [
23
25
  ]
24
26
 
25
27
 
26
- def _setup_figure(config: GraphConfig) -> tuple[Figure, fm.FontProperties]:
28
+ def _setup_figure(config: GraphConfig) -> tuple[Figure, Axes, fm.FontProperties]:
27
29
  """Internal helper to setup a figure with common styling."""
28
- fig = Figure(figsize=config.figsize, dpi=300)
29
- ax = fig.add_subplot()
30
+ fig = Figure(figsize=config.figsize, dpi=config.dpi)
31
+ ax: Axes = fig.add_subplot()
30
32
 
31
33
  # Load custom font if possible
32
34
  font_path = get_asset_path(f"fonts/{config.font_name}")
@@ -35,12 +37,27 @@ def _setup_figure(config: GraphConfig) -> tuple[Figure, fm.FontProperties]:
35
37
  )
36
38
 
37
39
  # Styling
40
+ fig.set_facecolor("white")
41
+ ax.set_facecolor("white")
38
42
  ax.spines["top"].set_visible(False)
39
43
  ax.spines["right"].set_visible(False)
40
44
  if config.grid:
41
45
  ax.grid(axis="y", linestyle="--", alpha=0.7)
46
+ ax.set_axisbelow(True)
42
47
 
43
- return fig, font_prop
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"
44
61
 
45
62
 
46
63
  def generate_week_barplot(
@@ -59,37 +76,37 @@ def generate_week_barplot(
59
76
  Matplotlib Figure object
60
77
  """
61
78
  cfg = config or get_default_config().graph
62
- dates = [datetime.fromtimestamp(ts, UTC) for ts in timestamps]
79
+ dates = [_ts_to_dt(ts, cfg) for ts in timestamps]
63
80
 
64
81
  weekday_counts: defaultdict[str, int] = defaultdict(int)
65
82
  for date in dates:
66
83
  weekday_counts[WEEKDAYS[date.weekday()]] += 1
67
84
 
68
- x = WEEKDAYS
85
+ x = list(range(len(WEEKDAYS)))
69
86
  y = [weekday_counts[day] for day in WEEKDAYS]
70
87
 
71
- fig, font_prop = _setup_figure(cfg)
72
- ax = fig.gca()
88
+ fig, ax, font_prop = _setup_figure(cfg)
73
89
 
74
- bars = ax.bar(x, y, color=cfg.color, alpha=0.8)
90
+ bars = ax.bar(x, y, color=cfg.color, alpha=0.85)
75
91
 
76
92
  if cfg.show_counts:
77
93
  for bar in bars:
78
94
  height = bar.get_height()
79
- ax.text(
80
- bar.get_x() + bar.get_width() / 2.0,
81
- height,
82
- f"{int(height)}",
83
- ha="center",
84
- va="bottom",
85
- fontproperties=font_prop,
86
- )
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
+ )
87
104
 
88
105
  ax.set_xlabel("Weekday", fontproperties=font_prop)
89
- ax.set_ylabel("Prompt Count", fontproperties=font_prop)
106
+ ax.set_ylabel("User Prompt Count", fontproperties=font_prop)
90
107
  ax.set_title(title, fontproperties=font_prop, fontsize=16, pad=20)
91
- ax.set_xticks(range(len(x)))
92
- ax.set_xticklabels(x, rotation=45, fontproperties=font_prop)
108
+ ax.set_xticks(x)
109
+ ax.set_xticklabels(WEEKDAYS, rotation=45, fontproperties=font_prop)
93
110
 
94
111
  for label in ax.get_yticklabels():
95
112
  label.set_fontproperties(font_prop)
@@ -114,7 +131,7 @@ def generate_hour_barplot(
114
131
  Matplotlib Figure object
115
132
  """
116
133
  cfg = config or get_default_config().graph
117
- dates = [datetime.fromtimestamp(ts, UTC) for ts in timestamps]
134
+ dates = [_ts_to_dt(ts, cfg) for ts in timestamps]
118
135
 
119
136
  hour_counts: dict[int, int] = dict.fromkeys(range(24), 0)
120
137
  for date in dates:
@@ -123,8 +140,7 @@ def generate_hour_barplot(
123
140
  x = [f"{i:02d}:00" for i in range(24)]
124
141
  y = [hour_counts[i] for i in range(24)]
125
142
 
126
- fig, font_prop = _setup_figure(cfg)
127
- ax = fig.gca()
143
+ fig, ax, font_prop = _setup_figure(cfg)
128
144
 
129
145
  bars = ax.bar(range(24), y, color=cfg.color, alpha=0.8)
130
146
 
@@ -142,8 +158,8 @@ def generate_hour_barplot(
142
158
  fontsize=8,
143
159
  )
144
160
 
145
- ax.set_xlabel("Hour of Day (UTC)", fontproperties=font_prop)
146
- ax.set_ylabel("Prompt Count", fontproperties=font_prop)
161
+ ax.set_xlabel(f"Hour of Day ({_tz_label(cfg)})", fontproperties=font_prop)
162
+ ax.set_ylabel("User Prompt Count", fontproperties=font_prop)
147
163
  ax.set_title(f"{title} - Hourly Distribution", fontproperties=font_prop, fontsize=16, pad=20)
148
164
  ax.set_xticks(range(24))
149
165
  ax.set_xticklabels(x, rotation=90, fontproperties=font_prop)
@@ -180,8 +196,7 @@ def generate_model_piechart(
180
196
  total = sum(model_counts.values())
181
197
  if total == 0:
182
198
  # Return empty figure or figure with "No Data"
183
- fig, font_prop = _setup_figure(cfg)
184
- ax = fig.gca()
199
+ fig, ax, font_prop = _setup_figure(cfg)
185
200
  ax.text(0.5, 0.5, "No Data", ha="center", va="center", fontproperties=font_prop)
186
201
  return fig
187
202
 
@@ -204,8 +219,7 @@ def generate_model_piechart(
204
219
  labels = [item[0] for item in sorted_items]
205
220
  sizes = [item[1] for item in sorted_items]
206
221
 
207
- fig, font_prop = _setup_figure(cfg)
208
- ax = fig.gca()
222
+ fig, ax, font_prop = _setup_figure(cfg)
209
223
 
210
224
  colors = [
211
225
  "#4A90E2",
@@ -250,17 +264,16 @@ def generate_length_histogram(
250
264
  cfg = config or get_default_config().graph
251
265
  lengths = [conv.message_count("user") for conv in collection.conversations]
252
266
 
253
- fig, font_prop = _setup_figure(cfg)
254
- ax = fig.gca()
267
+ fig, ax, font_prop = _setup_figure(cfg)
255
268
 
256
269
  if not lengths:
257
270
  ax.text(0.5, 0.5, "No Data", ha="center", va="center", fontproperties=font_prop)
258
271
  return fig
259
272
 
260
- import numpy as np
261
-
262
273
  # Cap at 95th percentile to focus on most conversations
263
- cap = int(np.percentile(lengths, 95))
274
+ sorted_lengths = sorted(lengths)
275
+ idx = int(0.95 * (len(sorted_lengths) - 1))
276
+ cap = int(sorted_lengths[idx])
264
277
  cap = max(cap, 5) # Ensure at least some range
265
278
 
266
279
  # Filter lengths for the histogram plot, but keep the data correct
@@ -306,10 +319,10 @@ def generate_monthly_activity_barplot(
306
319
  x = [m.strftime("%b '%y") for m in sorted_months]
307
320
  y = [len(month_groups[m].timestamps("user")) for m in sorted_months]
308
321
 
309
- fig, font_prop = _setup_figure(cfg)
310
- ax = fig.gca()
322
+ fig, ax, font_prop = _setup_figure(cfg)
311
323
 
312
- bars = ax.bar(x, y, color=cfg.color, alpha=0.8)
324
+ positions = list(range(len(x)))
325
+ bars = ax.bar(positions, y, color=cfg.color, alpha=0.85)
313
326
 
314
327
  if cfg.show_counts:
315
328
  for bar in bars:
@@ -326,10 +339,12 @@ def generate_monthly_activity_barplot(
326
339
  )
327
340
 
328
341
  ax.set_xlabel("Month", fontproperties=font_prop)
329
- ax.set_ylabel("Total Prompt Count", fontproperties=font_prop)
342
+ ax.set_ylabel("User Prompt Count", fontproperties=font_prop)
330
343
  ax.set_title("Monthly Activity History", fontproperties=font_prop, fontsize=16, pad=20)
331
- ax.set_xticks(range(len(x)))
332
- ax.set_xticklabels(x, rotation=45, fontproperties=font_prop)
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)
333
348
 
334
349
  for label in ax.get_yticklabels():
335
350
  label.set_fontproperties(font_prop)
@@ -338,6 +353,45 @@ def generate_monthly_activity_barplot(
338
353
  return fig
339
354
 
340
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
+
341
395
  def generate_summary_graphs(
342
396
  collection: ConversationCollection,
343
397
  output_dir: Path,
@@ -368,6 +422,10 @@ def generate_summary_graphs(
368
422
  fig_activity = generate_monthly_activity_barplot(collection, config)
369
423
  fig_activity.savefig(summary_dir / "monthly_activity.png")
370
424
 
425
+ # Daily activity
426
+ fig_daily = generate_daily_activity_lineplot(collection, config)
427
+ fig_daily.savefig(summary_dir / "daily_activity.png")
428
+
371
429
 
372
430
  def generate_graphs(
373
431
  collection: ConversationCollection,
@@ -62,7 +62,7 @@ def load_nltk_stopwords() -> frozenset[str]:
62
62
  return frozenset(words)
63
63
 
64
64
 
65
- def parse_custom_stopwords(stopwords_str: str) -> set[str]:
65
+ def parse_custom_stopwords(stopwords_str: str | None) -> set[str]:
66
66
  """Parse a comma-separated string of custom stopwords.
67
67
 
68
68
  Args:
@@ -19,7 +19,7 @@ class MarkdownConfig(BaseModel):
19
19
  """Configuration for markdown output."""
20
20
 
21
21
  latex_delimiters: Literal["default", "dollars"] = "default"
22
- flavor: Literal["obsidian", "standard"] = "obsidian"
22
+ flavor: Literal["obsidian", "standard"] = "standard"
23
23
 
24
24
 
25
25
  class YAMLConfig(BaseModel):
@@ -72,6 +72,8 @@ class GraphConfig(BaseModel):
72
72
  show_counts: bool = True
73
73
  font_name: str = "Montserrat-Regular.ttf"
74
74
  figsize: tuple[int, int] = (10, 6)
75
+ dpi: int = 300
76
+ timezone: Literal["utc", "local"] = "local"
75
77
 
76
78
 
77
79
  class ConvovizConfig(BaseModel):
@@ -0,0 +1,247 @@
1
+ """Interactive configuration prompts using questionary."""
2
+
3
+ from pathlib import Path
4
+ from typing import Literal, Protocol, cast
5
+
6
+ from questionary import Choice, Style, checkbox, select
7
+ from questionary import path as qst_path
8
+ from questionary import text as qst_text
9
+
10
+ from convoviz.config import ConvovizConfig, get_default_config
11
+ from convoviz.io.loaders import find_latest_zip, validate_zip
12
+ from convoviz.utils import colormaps, default_font_path, font_names, font_path, validate_header
13
+
14
+ CUSTOM_STYLE = Style(
15
+ [
16
+ ("qmark", "fg:#34eb9b bold"),
17
+ ("question", "bold fg:#e0e0e0"),
18
+ ("answer", "fg:#34ebeb bold"),
19
+ ("pointer", "fg:#e834eb bold"),
20
+ ("highlighted", "fg:#349ceb bold"),
21
+ ("selected", "fg:#34ebeb"),
22
+ ("separator", "fg:#eb3434"),
23
+ ("instruction", "fg:#eb9434"),
24
+ ("text", "fg:#b2eb34"),
25
+ ("disabled", "fg:#858585 italic"),
26
+ ]
27
+ )
28
+
29
+ class _QuestionaryPrompt[T](Protocol):
30
+ def ask(self) -> T | None: ...
31
+
32
+
33
+ def _ask_or_cancel[T](prompt: _QuestionaryPrompt[T]) -> T:
34
+ """Ask a questionary prompt; treat Ctrl+C/Ctrl+D as cancelling the run.
35
+
36
+ questionary's `.ask()` returns `None` on cancellation (Ctrl+C / Ctrl+D). We
37
+ convert that to `KeyboardInterrupt` so callers can abort the whole
38
+ interactive session with a single Ctrl+C.
39
+ """
40
+
41
+ result = prompt.ask()
42
+ if result is None:
43
+ raise KeyboardInterrupt
44
+ return result
45
+
46
+
47
+ def _validate_input_path(raw: str) -> bool | str:
48
+ path = Path(raw)
49
+ if not path.exists():
50
+ return "Path must exist"
51
+
52
+ if path.is_dir():
53
+ if (path / "conversations.json").exists():
54
+ return True
55
+ return "Directory must contain conversations.json"
56
+
57
+ if path.suffix.lower() == ".json":
58
+ return True
59
+
60
+ if path.suffix.lower() == ".zip":
61
+ return True if validate_zip(path) else "ZIP must contain conversations.json"
62
+
63
+ return "Input must be a .zip, a .json, or a directory containing conversations.json"
64
+
65
+
66
+ def run_interactive_config(initial_config: ConvovizConfig | None = None) -> ConvovizConfig:
67
+ """Run interactive prompts to configure convoviz.
68
+
69
+ Args:
70
+ initial_config: Optional starting configuration (uses defaults if None)
71
+
72
+ Returns:
73
+ Updated configuration based on user input
74
+ """
75
+ config = initial_config or get_default_config()
76
+
77
+ # Set sensible defaults if not already set
78
+ if not config.input_path:
79
+ latest = find_latest_zip()
80
+ if latest:
81
+ config.input_path = latest
82
+
83
+ if not config.wordcloud.font_path:
84
+ config.wordcloud.font_path = default_font_path()
85
+
86
+ # Prompt for input path
87
+ input_default = str(config.input_path) if config.input_path else ""
88
+ input_result: str = _ask_or_cancel(
89
+ qst_path(
90
+ "Enter the path to the export ZIP, conversations JSON, or extracted directory:",
91
+ default=input_default,
92
+ validate=_validate_input_path,
93
+ style=CUSTOM_STYLE,
94
+ )
95
+ )
96
+
97
+ if input_result:
98
+ config.input_path = Path(input_result)
99
+
100
+ # Prompt for output folder
101
+ output_result: str = _ask_or_cancel(
102
+ qst_path(
103
+ "Enter the path to the output folder:",
104
+ default=str(config.output_folder),
105
+ style=CUSTOM_STYLE,
106
+ )
107
+ )
108
+
109
+ if output_result:
110
+ config.output_folder = Path(output_result)
111
+
112
+ # Prompt for author headers
113
+ headers = config.message.author_headers
114
+ for role in ["system", "user", "assistant", "tool"]:
115
+ current = getattr(headers, role)
116
+ result: str = _ask_or_cancel(
117
+ qst_text(
118
+ f"Enter the message header for '{role}':",
119
+ default=current,
120
+ validate=lambda t: validate_header(t)
121
+ or "Must be a valid markdown header (e.g., # Title)",
122
+ style=CUSTOM_STYLE,
123
+ )
124
+ )
125
+ if result:
126
+ setattr(headers, role, result)
127
+
128
+ # Prompt for LaTeX delimiters
129
+ latex_result = cast(
130
+ Literal["default", "dollars"],
131
+ _ask_or_cancel(
132
+ select(
133
+ "Select the LaTeX math delimiters:",
134
+ choices=["default", "dollars"],
135
+ default=config.conversation.markdown.latex_delimiters,
136
+ style=CUSTOM_STYLE,
137
+ )
138
+ ),
139
+ )
140
+
141
+ if latex_result:
142
+ config.conversation.markdown.latex_delimiters = latex_result
143
+
144
+ # Prompt for markdown flavor
145
+ flavor_result = cast(
146
+ Literal["obsidian", "standard"],
147
+ _ask_or_cancel(
148
+ select(
149
+ "Select the markdown flavor:",
150
+ choices=["obsidian", "standard"],
151
+ default=config.conversation.markdown.flavor,
152
+ style=CUSTOM_STYLE,
153
+ )
154
+ ),
155
+ )
156
+
157
+ if flavor_result:
158
+ config.conversation.markdown.flavor = flavor_result
159
+
160
+ # Prompt for YAML headers
161
+ yaml_config = config.conversation.yaml
162
+ yaml_choices = [
163
+ Choice(title=field, checked=getattr(yaml_config, field))
164
+ for field in [
165
+ "title",
166
+ "tags",
167
+ "chat_link",
168
+ "create_time",
169
+ "update_time",
170
+ "model",
171
+ "used_plugins",
172
+ "message_count",
173
+ "content_types",
174
+ "custom_instructions",
175
+ ]
176
+ ]
177
+
178
+ selected: list[str] = _ask_or_cancel(
179
+ checkbox(
180
+ "Select YAML metadata headers to include:",
181
+ choices=yaml_choices,
182
+ style=CUSTOM_STYLE,
183
+ )
184
+ )
185
+
186
+ selected_set = set(selected)
187
+ for field_name in [
188
+ "title",
189
+ "tags",
190
+ "chat_link",
191
+ "create_time",
192
+ "update_time",
193
+ "model",
194
+ "used_plugins",
195
+ "message_count",
196
+ "content_types",
197
+ "custom_instructions",
198
+ ]:
199
+ setattr(yaml_config, field_name, field_name in selected_set)
200
+
201
+ # Prompt for font
202
+ available_fonts = font_names()
203
+ if available_fonts:
204
+ current_font = (
205
+ config.wordcloud.font_path.stem if config.wordcloud.font_path else available_fonts[0]
206
+ )
207
+ font_result: str = _ask_or_cancel(
208
+ select(
209
+ "Select the font for word clouds:",
210
+ choices=available_fonts,
211
+ default=current_font if current_font in available_fonts else available_fonts[0],
212
+ style=CUSTOM_STYLE,
213
+ )
214
+ )
215
+
216
+ if font_result:
217
+ config.wordcloud.font_path = font_path(font_result)
218
+
219
+ # Prompt for colormap
220
+ available_colormaps = colormaps()
221
+ if available_colormaps:
222
+ colormap_result: str = _ask_or_cancel(
223
+ select(
224
+ "Select the color theme for word clouds:",
225
+ choices=available_colormaps,
226
+ default=config.wordcloud.colormap
227
+ if config.wordcloud.colormap in available_colormaps
228
+ else available_colormaps[0],
229
+ style=CUSTOM_STYLE,
230
+ )
231
+ )
232
+
233
+ if colormap_result:
234
+ config.wordcloud.colormap = colormap_result
235
+
236
+ # Prompt for custom stopwords
237
+ stopwords_result: str = _ask_or_cancel(
238
+ qst_text(
239
+ "Enter custom stopwords (comma-separated):",
240
+ default=config.wordcloud.custom_stopwords,
241
+ style=CUSTOM_STYLE,
242
+ )
243
+ )
244
+
245
+ config.wordcloud.custom_stopwords = stopwords_result
246
+
247
+ return config