convoviz 0.4.1__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.
Files changed (62) hide show
  1. convoviz/__init__.py +34 -0
  2. convoviz/__main__.py +6 -0
  3. convoviz/analysis/__init__.py +22 -0
  4. convoviz/analysis/graphs.py +879 -0
  5. convoviz/analysis/wordcloud.py +204 -0
  6. convoviz/assets/colormaps.txt +15 -0
  7. convoviz/assets/fonts/AmaticSC-Regular.ttf +0 -0
  8. convoviz/assets/fonts/ArchitectsDaughter-Regular.ttf +0 -0
  9. convoviz/assets/fonts/BebasNeue-Regular.ttf +0 -0
  10. convoviz/assets/fonts/Borel-Regular.ttf +0 -0
  11. convoviz/assets/fonts/Courgette-Regular.ttf +0 -0
  12. convoviz/assets/fonts/CroissantOne-Regular.ttf +0 -0
  13. convoviz/assets/fonts/Handjet-Regular.ttf +0 -0
  14. convoviz/assets/fonts/IndieFlower-Regular.ttf +0 -0
  15. convoviz/assets/fonts/Kalam-Regular.ttf +0 -0
  16. convoviz/assets/fonts/Lobster-Regular.ttf +0 -0
  17. convoviz/assets/fonts/MartianMono-Regular.ttf +0 -0
  18. convoviz/assets/fonts/MartianMono-Thin.ttf +0 -0
  19. convoviz/assets/fonts/Montserrat-Regular.ttf +0 -0
  20. convoviz/assets/fonts/Mooli-Regular.ttf +0 -0
  21. convoviz/assets/fonts/Pacifico-Regular.ttf +0 -0
  22. convoviz/assets/fonts/PlayfairDisplay-Regular.ttf +0 -0
  23. convoviz/assets/fonts/Raleway-Regular.ttf +0 -0
  24. convoviz/assets/fonts/RobotoMono-Regular.ttf +0 -0
  25. convoviz/assets/fonts/RobotoMono-Thin.ttf +0 -0
  26. convoviz/assets/fonts/RobotoSlab-Regular.ttf +0 -0
  27. convoviz/assets/fonts/RobotoSlab-Thin.ttf +0 -0
  28. convoviz/assets/fonts/Ruwudu-Regular.ttf +0 -0
  29. convoviz/assets/fonts/Sacramento-Regular.ttf +0 -0
  30. convoviz/assets/fonts/SedgwickAveDisplay-Regular.ttf +0 -0
  31. convoviz/assets/fonts/ShadowsIntoLight-Regular.ttf +0 -0
  32. convoviz/assets/fonts/TitilliumWeb-Regular.ttf +0 -0
  33. convoviz/assets/fonts/Yellowtail-Regular.ttf +0 -0
  34. convoviz/assets/fonts/YsabeauOffice-Regular.ttf +0 -0
  35. convoviz/assets/fonts/YsabeauSC-Regular.ttf +0 -0
  36. convoviz/assets/fonts/YsabeauSC-Thin.ttf +0 -0
  37. convoviz/assets/fonts/Zeyada-Regular.ttf +0 -0
  38. convoviz/assets/stopwords.txt +1 -0
  39. convoviz/cli.py +149 -0
  40. convoviz/config.py +120 -0
  41. convoviz/exceptions.py +47 -0
  42. convoviz/interactive.py +264 -0
  43. convoviz/io/__init__.py +21 -0
  44. convoviz/io/assets.py +109 -0
  45. convoviz/io/loaders.py +191 -0
  46. convoviz/io/writers.py +231 -0
  47. convoviz/logging_config.py +69 -0
  48. convoviz/models/__init__.py +24 -0
  49. convoviz/models/collection.py +115 -0
  50. convoviz/models/conversation.py +158 -0
  51. convoviz/models/message.py +218 -0
  52. convoviz/models/node.py +66 -0
  53. convoviz/pipeline.py +184 -0
  54. convoviz/py.typed +0 -0
  55. convoviz/renderers/__init__.py +10 -0
  56. convoviz/renderers/markdown.py +269 -0
  57. convoviz/renderers/yaml.py +119 -0
  58. convoviz/utils.py +155 -0
  59. convoviz-0.4.1.dist-info/METADATA +215 -0
  60. convoviz-0.4.1.dist-info/RECORD +62 -0
  61. convoviz-0.4.1.dist-info/WHEEL +4 -0
  62. convoviz-0.4.1.dist-info/entry_points.txt +3 -0
convoviz/io/writers.py ADDED
@@ -0,0 +1,231 @@
1
+ """Writing functions for conversations and collections."""
2
+
3
+ import logging
4
+ from os import utime as os_utime
5
+ from pathlib import Path
6
+ from urllib.parse import quote
7
+
8
+ from orjson import OPT_INDENT_2, dumps
9
+ from tqdm import tqdm
10
+
11
+ from convoviz.config import AuthorHeaders, ConversationConfig, FolderOrganization
12
+ from convoviz.io.assets import copy_asset, resolve_asset_path
13
+ from convoviz.models import Conversation, ConversationCollection
14
+ from convoviz.renderers import render_conversation
15
+ from convoviz.utils import sanitize
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Month names for folder naming
20
+ _MONTH_NAMES = [
21
+ "January",
22
+ "February",
23
+ "March",
24
+ "April",
25
+ "May",
26
+ "June",
27
+ "July",
28
+ "August",
29
+ "September",
30
+ "October",
31
+ "November",
32
+ "December",
33
+ ]
34
+
35
+
36
+ def get_date_folder_path(conversation: Conversation) -> Path:
37
+ """Get the date-based folder path for a conversation.
38
+
39
+ Creates a nested structure: year/month
40
+ Example: 2024/03-March/
41
+
42
+ Args:
43
+ conversation: The conversation to get the path for
44
+
45
+ Returns:
46
+ Relative path for the date-based folder structure
47
+ """
48
+ create_time = conversation.create_time
49
+
50
+ # Year folder: "2024"
51
+ year = str(create_time.year)
52
+
53
+ # Month folder: "03-March"
54
+ month_num = create_time.month
55
+ month_name = _MONTH_NAMES[month_num - 1]
56
+ month = f"{month_num:02d}-{month_name}"
57
+
58
+ return Path(year) / month
59
+
60
+
61
+ def save_conversation(
62
+ conversation: Conversation,
63
+ filepath: Path,
64
+ config: ConversationConfig,
65
+ headers: AuthorHeaders,
66
+ source_path: Path | None = None,
67
+ ) -> Path:
68
+ """Save a conversation to a markdown file.
69
+
70
+ Handles filename conflicts by appending a counter. Sets the file's
71
+ modification time to match the conversation's update time.
72
+
73
+ Args:
74
+ conversation: The conversation to save
75
+ filepath: Target file path
76
+ config: Conversation rendering configuration
77
+ headers: Author header configuration
78
+ source_path: Path to the source directory containing assets
79
+
80
+ Returns:
81
+ The actual path the file was saved to (may differ if there was a conflict)
82
+ """
83
+ # Handle filename conflicts
84
+ base_name = sanitize(filepath.stem)
85
+ final_path = filepath
86
+ counter = 0
87
+
88
+ while final_path.exists():
89
+ counter += 1
90
+ final_path = filepath.with_name(f"{base_name} ({counter}){filepath.suffix}")
91
+
92
+ # Define asset resolver
93
+ def asset_resolver(asset_id: str) -> str | None:
94
+ if not source_path:
95
+ return None
96
+
97
+ src_file = resolve_asset_path(source_path, asset_id)
98
+ if not src_file:
99
+ return None
100
+
101
+ # Copy to output directory (relative to the markdown file's directory)
102
+ return copy_asset(src_file, final_path.parent)
103
+
104
+ # Render and write
105
+ markdown = render_conversation(conversation, config, headers, asset_resolver=asset_resolver)
106
+ with final_path.open("w", encoding="utf-8") as f:
107
+ f.write(markdown)
108
+ logger.debug(f"Saved conversation: {final_path}")
109
+
110
+ # Set modification time
111
+ timestamp = conversation.update_time.timestamp()
112
+ os_utime(final_path, (timestamp, timestamp))
113
+
114
+ return final_path
115
+
116
+
117
+ def _generate_year_index(year_dir: Path, year: str) -> None:
118
+ """Generate a _index.md file for a year folder.
119
+
120
+ Args:
121
+ year_dir: Path to the year directory
122
+ year: The year string (e.g., "2024")
123
+ """
124
+ months = sorted(
125
+ [d.name for d in year_dir.iterdir() if d.is_dir()],
126
+ key=lambda m: int(m.split("-")[0]),
127
+ )
128
+
129
+ lines = [
130
+ f"# {year}",
131
+ "",
132
+ "## Months",
133
+ "",
134
+ ]
135
+
136
+ for month in months:
137
+ month_name = month.split("-", 1)[1] if "-" in month else month
138
+ lines.append(f"- [{month_name}]({month}/_index.md)")
139
+
140
+ index_path = year_dir / "_index.md"
141
+ index_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
142
+ logger.debug(f"Generated year index: {index_path}")
143
+
144
+
145
+ def _generate_month_index(month_dir: Path, year: str, month: str) -> None:
146
+ """Generate a _index.md file for a month folder.
147
+
148
+ Args:
149
+ month_dir: Path to the month directory
150
+ year: The year string (e.g., "2024")
151
+ month: The month folder name (e.g., "03-March")
152
+ """
153
+ month_name = month.split("-", 1)[1] if "-" in month else month
154
+ files = sorted([f.name for f in month_dir.glob("*.md") if f.name != "_index.md"])
155
+
156
+ lines = [
157
+ f"# {month_name} {year}",
158
+ "",
159
+ "## Conversations",
160
+ "",
161
+ ]
162
+
163
+ for file in files:
164
+ title = file[:-3] # Remove .md extension
165
+ encoded_file = quote(file)
166
+ lines.append(f"- [{title}]({encoded_file})")
167
+
168
+ index_path = month_dir / "_index.md"
169
+ index_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
170
+ logger.debug(f"Generated month index: {index_path}")
171
+
172
+
173
+ def save_collection(
174
+ collection: ConversationCollection,
175
+ directory: Path,
176
+ config: ConversationConfig,
177
+ headers: AuthorHeaders,
178
+ *,
179
+ folder_organization: FolderOrganization = FolderOrganization.FLAT,
180
+ progress_bar: bool = False,
181
+ ) -> None:
182
+ """Save all conversations in a collection to markdown files.
183
+
184
+ Args:
185
+ collection: The collection to save
186
+ directory: Target directory
187
+ config: Conversation rendering configuration
188
+ headers: Author header configuration
189
+ folder_organization: How to organize files in folders (flat or by date)
190
+ progress_bar: Whether to show a progress bar
191
+ """
192
+ directory.mkdir(parents=True, exist_ok=True)
193
+
194
+ for conv in tqdm(
195
+ collection.conversations,
196
+ desc="Writing Markdown 📄 files",
197
+ disable=not progress_bar,
198
+ ):
199
+ # Determine target directory based on organization setting
200
+ if folder_organization == FolderOrganization.DATE:
201
+ target_dir = directory / get_date_folder_path(conv)
202
+ target_dir.mkdir(parents=True, exist_ok=True)
203
+ else:
204
+ target_dir = directory
205
+
206
+ filepath = target_dir / f"{sanitize(conv.title)}.md"
207
+ save_conversation(conv, filepath, config, headers, source_path=collection.source_path)
208
+
209
+ # Generate index files for date organization
210
+ if folder_organization == FolderOrganization.DATE:
211
+ for year_dir in directory.iterdir():
212
+ if year_dir.is_dir() and year_dir.name.isdigit():
213
+ for month_dir in year_dir.iterdir():
214
+ if month_dir.is_dir():
215
+ _generate_month_index(month_dir, year_dir.name, month_dir.name)
216
+ _generate_year_index(year_dir, year_dir.name)
217
+
218
+
219
+ def save_custom_instructions(
220
+ collection: ConversationCollection,
221
+ filepath: Path,
222
+ ) -> None:
223
+ """Save all custom instructions from a collection to a JSON file.
224
+
225
+ Args:
226
+ collection: The collection to extract instructions from
227
+ filepath: Target JSON file path
228
+ """
229
+ instructions = collection.custom_instructions
230
+ with filepath.open("w", encoding="utf-8") as f:
231
+ f.write(dumps(instructions, option=OPT_INDENT_2).decode())
@@ -0,0 +1,69 @@
1
+ """Logging configuration for convoviz."""
2
+
3
+ import logging
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ from rich.logging import RichHandler
8
+
9
+
10
+ def setup_logging(
11
+ verbosity: int = 0,
12
+ log_file: Path | None = None,
13
+ ) -> Path:
14
+ """Set up logging configuration.
15
+
16
+ Args:
17
+ verbosity: Level of verbosity (0=WARNING, 1=INFO, 2=DEBUG)
18
+ log_file: Path to log file. If None, a temporary file is created.
19
+
20
+ Returns:
21
+ Path to the log file used.
22
+ """
23
+ # clear existing handlers
24
+ root_logger = logging.getLogger()
25
+ root_logger.handlers.clear()
26
+
27
+ # Determine log level for console
28
+ if verbosity >= 2:
29
+ console_level = logging.DEBUG
30
+ elif verbosity >= 1:
31
+ console_level = logging.INFO
32
+ else:
33
+ console_level = logging.WARNING
34
+
35
+ # Console handler (Rich)
36
+ console_handler = RichHandler(
37
+ rich_tracebacks=True,
38
+ markup=True,
39
+ show_time=False,
40
+ show_path=False,
41
+ )
42
+ console_handler.setLevel(console_level)
43
+
44
+ # File handler
45
+ if log_file is None:
46
+ # Create temp file if not specified
47
+ with tempfile.NamedTemporaryFile(prefix="convoviz_", suffix=".log", delete=False) as tf:
48
+ log_file = Path(tf.name)
49
+
50
+ # Ensure parent dir exists
51
+ if not log_file.parent.exists():
52
+ log_file.parent.mkdir(parents=True, exist_ok=True)
53
+
54
+ file_handler = logging.FileHandler(log_file, encoding="utf-8")
55
+ file_handler.setLevel(logging.DEBUG) # Always log DEBUG to file
56
+ file_formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
57
+ file_handler.setFormatter(file_formatter)
58
+
59
+ # Configure root logger
60
+ # We set root level to DEBUG so that the handlers can filter as they please
61
+ root_logger.setLevel(logging.DEBUG)
62
+ root_logger.addHandler(console_handler)
63
+ root_logger.addHandler(file_handler)
64
+
65
+ # Reduce noise from explicit libraries if necessary
66
+ logging.getLogger("matplotlib").setLevel(logging.WARNING)
67
+ logging.getLogger("PIL").setLevel(logging.WARNING)
68
+
69
+ return log_file
@@ -0,0 +1,24 @@
1
+ """Data models for convoviz."""
2
+
3
+ from convoviz.models.collection import ConversationCollection
4
+ from convoviz.models.conversation import Conversation
5
+ from convoviz.models.message import (
6
+ AuthorRole,
7
+ Message,
8
+ MessageAuthor,
9
+ MessageContent,
10
+ MessageMetadata,
11
+ )
12
+ from convoviz.models.node import Node, build_node_tree
13
+
14
+ __all__ = [
15
+ "AuthorRole",
16
+ "Conversation",
17
+ "ConversationCollection",
18
+ "Message",
19
+ "MessageAuthor",
20
+ "MessageContent",
21
+ "MessageMetadata",
22
+ "Node",
23
+ "build_node_tree",
24
+ ]
@@ -0,0 +1,115 @@
1
+ """ConversationCollection model - manages a set of conversations.
2
+
3
+ This is a pure data model - I/O and visualization logic are in separate modules.
4
+ """
5
+
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ from convoviz.models.conversation import Conversation
13
+ from convoviz.models.message import AuthorRole
14
+
15
+
16
+ class ConversationCollection(BaseModel):
17
+ """A collection of ChatGPT conversations.
18
+
19
+ Provides grouping and aggregation operations over conversations.
20
+ """
21
+
22
+ conversations: list[Conversation] = Field(default_factory=list)
23
+ source_path: Path | None = None
24
+
25
+ @property
26
+ def index(self) -> dict[str, Conversation]:
27
+ """Get conversations indexed by conversation_id."""
28
+ return {conv.conversation_id: conv for conv in self.conversations}
29
+
30
+ @property
31
+ def last_updated(self) -> datetime:
32
+ """Get the most recent update time across all conversations."""
33
+ if not self.conversations:
34
+ return datetime.min
35
+ return max(conv.update_time for conv in self.conversations)
36
+
37
+ def update(self, other: "ConversationCollection") -> None:
38
+ """Merge another collection into this one.
39
+
40
+ Merges per-conversation, keeping the newest version when IDs collide.
41
+
42
+ Note: We intentionally do *not* gate on ``other.last_updated`` because
43
+ "new" conversations can still have older timestamps than the most recent
44
+ conversation in this collection (e.g. bookmarklet downloads).
45
+ """
46
+ merged: dict[str, Conversation] = dict(self.index)
47
+
48
+ for conv_id, incoming in other.index.items():
49
+ existing = merged.get(conv_id)
50
+ if existing is None or incoming.update_time > existing.update_time:
51
+ merged[conv_id] = incoming
52
+
53
+ self.conversations = list(merged.values())
54
+
55
+ def add(self, conversation: Conversation) -> None:
56
+ """Add a conversation to the collection."""
57
+ self.conversations.append(conversation)
58
+
59
+ @property
60
+ def custom_instructions(self) -> list[dict[str, Any]]:
61
+ """Get all custom instructions from all conversations."""
62
+ instructions: list[dict[str, Any]] = []
63
+ for conv in self.conversations:
64
+ if not conv.custom_instructions:
65
+ continue
66
+ instructions.append(
67
+ {
68
+ "chat_title": conv.title,
69
+ "chat_link": conv.url,
70
+ "time": conv.create_time,
71
+ "custom_instructions": conv.custom_instructions,
72
+ }
73
+ )
74
+ return instructions
75
+
76
+ def timestamps(self, *authors: AuthorRole) -> list[float]:
77
+ """Get all message timestamps from specified authors."""
78
+ result: list[float] = []
79
+ for conv in self.conversations:
80
+ result.extend(conv.timestamps(*authors))
81
+ return result
82
+
83
+ def plaintext(self, *authors: AuthorRole) -> str:
84
+ """Get concatenated plain text from all conversations."""
85
+ return "\n".join(conv.plaintext(*authors) for conv in self.conversations)
86
+
87
+ def group_by_week(self) -> dict[datetime, "ConversationCollection"]:
88
+ """Group conversations by the week they were created."""
89
+ groups: dict[datetime, ConversationCollection] = {}
90
+ for conv in self.conversations:
91
+ week_start = conv.week_start
92
+ if week_start not in groups:
93
+ groups[week_start] = ConversationCollection()
94
+ groups[week_start].add(conv)
95
+ return groups
96
+
97
+ def group_by_month(self) -> dict[datetime, "ConversationCollection"]:
98
+ """Group conversations by the month they were created."""
99
+ groups: dict[datetime, ConversationCollection] = {}
100
+ for conv in self.conversations:
101
+ month_start = conv.month_start
102
+ if month_start not in groups:
103
+ groups[month_start] = ConversationCollection()
104
+ groups[month_start].add(conv)
105
+ return groups
106
+
107
+ def group_by_year(self) -> dict[datetime, "ConversationCollection"]:
108
+ """Group conversations by the year they were created."""
109
+ groups: dict[datetime, ConversationCollection] = {}
110
+ for conv in self.conversations:
111
+ year_start = conv.year_start
112
+ if year_start not in groups:
113
+ groups[year_start] = ConversationCollection()
114
+ groups[year_start].add(conv)
115
+ return groups
@@ -0,0 +1,158 @@
1
+ """Conversation model - pure data class.
2
+
3
+ Object path: conversations.json -> conversation (one of the list items)
4
+ """
5
+
6
+ from datetime import datetime, timedelta
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ from convoviz.models.message import AuthorRole
12
+ from convoviz.models.node import Node, build_node_tree
13
+
14
+
15
+ class Conversation(BaseModel):
16
+ """A single ChatGPT conversation.
17
+
18
+ This is a pure data model - rendering and I/O logic are in separate modules.
19
+ """
20
+
21
+ title: str
22
+ create_time: datetime
23
+ update_time: datetime
24
+ mapping: dict[str, Node]
25
+ moderation_results: list[Any] = Field(default_factory=list)
26
+ current_node: str
27
+ plugin_ids: list[str] | None = None
28
+ conversation_id: str
29
+ conversation_template_id: str | None = None
30
+ id: str | None = None
31
+
32
+ @property
33
+ def node_mapping(self) -> dict[str, Node]:
34
+ """Get the connected node tree."""
35
+ return build_node_tree(self.mapping)
36
+
37
+ @property
38
+ def all_message_nodes(self) -> list[Node]:
39
+ """Get all nodes that have messages (including hidden/internal ones)."""
40
+ return [node for node in self.node_mapping.values() if node.has_message]
41
+
42
+ @property
43
+ def visible_message_nodes(self) -> list[Node]:
44
+ """Get all nodes that have *visible* (non-hidden) messages."""
45
+ return [
46
+ node
47
+ for node in self.node_mapping.values()
48
+ if node.has_message and node.message is not None and not node.message.is_hidden
49
+ ]
50
+
51
+ def nodes_by_author(self, *authors: AuthorRole, include_hidden: bool = False) -> list[Node]:
52
+ """Get nodes with messages from specified authors.
53
+
54
+ Args:
55
+ *authors: Author roles to filter by. Defaults to ("user",) if empty.
56
+ include_hidden: Whether to include hidden/internal messages.
57
+ """
58
+ if not authors:
59
+ authors = ("user",)
60
+ nodes = self.all_message_nodes if include_hidden else self.visible_message_nodes
61
+ return [node for node in nodes if node.message and node.message.author.role in authors]
62
+
63
+ @property
64
+ def leaf_count(self) -> int:
65
+ """Count the number of leaf nodes (conversation endpoints)."""
66
+ return sum(1 for node in self.all_message_nodes if node.is_leaf)
67
+
68
+ @property
69
+ def url(self) -> str:
70
+ """Get the ChatGPT URL for this conversation."""
71
+ return f"https://chat.openai.com/c/{self.conversation_id}"
72
+
73
+ @property
74
+ def content_types(self) -> list[str]:
75
+ """Get all unique content types in the conversation (excluding hidden messages)."""
76
+ return list(
77
+ {
78
+ node.message.content.content_type
79
+ for node in self.visible_message_nodes
80
+ if node.message
81
+ }
82
+ )
83
+
84
+ def message_count(self, *authors: AuthorRole) -> int:
85
+ """Count messages from specified authors."""
86
+ return len(self.nodes_by_author(*authors))
87
+
88
+ @property
89
+ def model(self) -> str | None:
90
+ """Get the ChatGPT model used for this conversation."""
91
+ assistant_nodes = self.nodes_by_author("assistant")
92
+ if not assistant_nodes:
93
+ return None
94
+ message = assistant_nodes[0].message
95
+ return message.metadata.model_slug if message else None
96
+
97
+ @property
98
+ def plugins(self) -> list[str]:
99
+ """Get all plugins used in this conversation."""
100
+ return list(
101
+ {
102
+ node.message.metadata.invoked_plugin["namespace"]
103
+ for node in self.nodes_by_author("tool")
104
+ if node.message and node.message.metadata.invoked_plugin
105
+ }
106
+ )
107
+
108
+ @property
109
+ def custom_instructions(self) -> dict[str, str]:
110
+ """Get custom instructions used for this conversation."""
111
+ system_nodes = self.nodes_by_author("system")
112
+ for node in system_nodes:
113
+ context_message = node.message
114
+ if context_message and context_message.metadata.is_user_system_message:
115
+ return context_message.metadata.user_context_message_data or {}
116
+ return {}
117
+
118
+ def timestamps(self, *authors: AuthorRole) -> list[float]:
119
+ """Get message timestamps from specified authors.
120
+
121
+ Useful for generating time-based visualizations.
122
+ """
123
+ if not authors:
124
+ authors = ("user",)
125
+ return [
126
+ node.message.create_time.timestamp()
127
+ for node in self.nodes_by_author(*authors)
128
+ if node.message and node.message.create_time
129
+ ]
130
+
131
+ def plaintext(self, *authors: AuthorRole) -> str:
132
+ """Get concatenated plain text from specified authors.
133
+
134
+ Useful for word cloud generation.
135
+ """
136
+ if not authors:
137
+ authors = ("user",)
138
+ return "\n".join(
139
+ node.message.text
140
+ for node in self.nodes_by_author(*authors)
141
+ if node.message and node.message.has_content
142
+ )
143
+
144
+ @property
145
+ def week_start(self) -> datetime:
146
+ """Get the Monday of the week this conversation was created."""
147
+ start_of_week = self.create_time - timedelta(days=self.create_time.weekday())
148
+ return start_of_week.replace(hour=0, minute=0, second=0, microsecond=0)
149
+
150
+ @property
151
+ def month_start(self) -> datetime:
152
+ """Get the first day of the month this conversation was created."""
153
+ return self.create_time.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
154
+
155
+ @property
156
+ def year_start(self) -> datetime:
157
+ """Get January 1st of the year this conversation was created."""
158
+ return self.create_time.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)