opencode-chat-exporter 1.0.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.
- chat_exporter/__init__.py +2 -0
- chat_exporter/__main__.py +16 -0
- chat_exporter/antigravity.py +599 -0
- chat_exporter/cli.py +194 -0
- chat_exporter/db.py +149 -0
- chat_exporter/gui.py +546 -0
- chat_exporter/render.py +272 -0
- opencode_chat_exporter-1.0.0.dist-info/METADATA +109 -0
- opencode_chat_exporter-1.0.0.dist-info/RECORD +13 -0
- opencode_chat_exporter-1.0.0.dist-info/WHEEL +5 -0
- opencode_chat_exporter-1.0.0.dist-info/entry_points.txt +2 -0
- opencode_chat_exporter-1.0.0.dist-info/licenses/LICENSE +21 -0
- opencode_chat_exporter-1.0.0.dist-info/top_level.txt +1 -0
chat_exporter/render.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .db import OpenCodeDB
|
|
8
|
+
|
|
9
|
+
ROLE_LABELS = {
|
|
10
|
+
"user": "USER",
|
|
11
|
+
"assistant": "ASSISTANT",
|
|
12
|
+
"system": "SYSTEM",
|
|
13
|
+
"tool": "TOOL",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _ts(db: OpenCodeDB, ms: int | None) -> str:
|
|
18
|
+
t = db.ts(ms)
|
|
19
|
+
return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _fence(text: str) -> str:
|
|
23
|
+
for f in ("```", "~~~"):
|
|
24
|
+
if f not in text:
|
|
25
|
+
return f
|
|
26
|
+
return "````"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_base64url(value: Any) -> bool:
|
|
30
|
+
return isinstance(value, str) and value.startswith("data:")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _pretty_json(obj: Any) -> str:
|
|
34
|
+
try:
|
|
35
|
+
return json.dumps(obj, ensure_ascii=False, indent=2, default=str)
|
|
36
|
+
except Exception:
|
|
37
|
+
return str(obj)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _safe_filename(name: str) -> str:
|
|
41
|
+
return re.sub(r'[\\/:*?"<>|]', "_", name)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _tool_title(state: dict) -> str:
|
|
45
|
+
"""One-line human summary of a tool call from its input."""
|
|
46
|
+
inp = state.get("input") or {}
|
|
47
|
+
for key in ("command", "pattern", "filePath", "path", "url", "query"):
|
|
48
|
+
if inp.get(key) is not None:
|
|
49
|
+
return str(inp[key])
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Exporter:
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
db: OpenCodeDB,
|
|
57
|
+
include_reasoning: bool = False,
|
|
58
|
+
include_metadata: bool = False,
|
|
59
|
+
output_dir: Path | None = None,
|
|
60
|
+
max_output: int | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self.db = db
|
|
63
|
+
self.include_reasoning = include_reasoning
|
|
64
|
+
self.include_metadata = include_metadata
|
|
65
|
+
self.output_dir = output_dir
|
|
66
|
+
self.max_output = max_output
|
|
67
|
+
self.attachments_dir = (output_dir / "attachments") if output_dir else None
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------- sessions
|
|
70
|
+
def render_session(self, session_id: str) -> str:
|
|
71
|
+
session = self.db.get_session(session_id)
|
|
72
|
+
if session is None:
|
|
73
|
+
raise KeyError(f"session not found: {session_id}")
|
|
74
|
+
messages = self.db.get_session_messages(session_id)
|
|
75
|
+
|
|
76
|
+
out: list[str] = []
|
|
77
|
+
self._header(out, session)
|
|
78
|
+
out.append("")
|
|
79
|
+
out.append("---")
|
|
80
|
+
out.append("")
|
|
81
|
+
|
|
82
|
+
n_user = n_asst = 0
|
|
83
|
+
for msg in messages:
|
|
84
|
+
role = msg["data"].get("role")
|
|
85
|
+
if role == "user":
|
|
86
|
+
n_user += 1
|
|
87
|
+
elif role == "assistant":
|
|
88
|
+
n_asst += 1
|
|
89
|
+
self._message(out, msg)
|
|
90
|
+
|
|
91
|
+
if n_user == 0 and n_asst == 0:
|
|
92
|
+
out.append("_(no messages in this session)_")
|
|
93
|
+
|
|
94
|
+
return "\n".join(out).rstrip() + "\n"
|
|
95
|
+
|
|
96
|
+
def render_all(self, include_archived: bool = False) -> str:
|
|
97
|
+
chunks = []
|
|
98
|
+
for s in self.db.list_sessions(include_archived=include_archived):
|
|
99
|
+
chunks.append(self.render_session(s["id"]))
|
|
100
|
+
chunks.append("\n\n---\n\n# ┄┄┄ NEXT SESSION ┄┄┄\n\n")
|
|
101
|
+
return "\n".join(chunks).rstrip() + "\n"
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------ header
|
|
104
|
+
def _header(self, out: list[str], s: dict) -> None:
|
|
105
|
+
title = s.get("title") or "Untitled session"
|
|
106
|
+
out.append(f"# {title}")
|
|
107
|
+
out.append("")
|
|
108
|
+
out.append(f"> Session `{s.get('id')}`")
|
|
109
|
+
agent = s.get("agent")
|
|
110
|
+
model = s.get("model")
|
|
111
|
+
if isinstance(model, dict):
|
|
112
|
+
bits = [x for x in (model.get("providerID"), model.get("modelID")) if x]
|
|
113
|
+
model = " · ".join(bits)
|
|
114
|
+
if agent:
|
|
115
|
+
out.append(f"**Agent:** `{agent}`")
|
|
116
|
+
if model:
|
|
117
|
+
out.append(f"**Model:** `{model}`")
|
|
118
|
+
directory = s.get("directory")
|
|
119
|
+
if directory:
|
|
120
|
+
out.append(f"**Directory:** `{directory}`")
|
|
121
|
+
created = _ts(self.db, s.get("time_created"))
|
|
122
|
+
updated = _ts(self.db, s.get("time_updated"))
|
|
123
|
+
if created:
|
|
124
|
+
out.append(f"**Created:** {created}")
|
|
125
|
+
if updated:
|
|
126
|
+
out.append(f"**Updated:** {updated}")
|
|
127
|
+
share_url = s.get("share_url")
|
|
128
|
+
if share_url:
|
|
129
|
+
out.append(f"**Share:** {share_url}")
|
|
130
|
+
total = (int(s.get("tokens_input") or 0) + int(s.get("tokens_output") or 0)
|
|
131
|
+
+ int(s.get("tokens_reasoning") or 0))
|
|
132
|
+
if total:
|
|
133
|
+
out.append(f"**Tokens:** {total:,}")
|
|
134
|
+
cost = s.get("cost")
|
|
135
|
+
if isinstance(cost, (int, float)) and cost:
|
|
136
|
+
out.append(f"**Cost:** ${float(cost):.4f}")
|
|
137
|
+
out.append("")
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------- messages
|
|
140
|
+
def _message(self, out: list[str], msg: dict) -> None:
|
|
141
|
+
role = msg["data"].get("role", "unknown")
|
|
142
|
+
label = ROLE_LABELS.get(role, role.upper())
|
|
143
|
+
header = f"### {label}"
|
|
144
|
+
t = _ts(self.db, msg["time_created"])
|
|
145
|
+
if t:
|
|
146
|
+
header += f" `{t}`"
|
|
147
|
+
model = msg["data"].get("model")
|
|
148
|
+
if isinstance(model, dict) and model.get("modelID"):
|
|
149
|
+
header += f" · {model['modelID']}"
|
|
150
|
+
out.append(header)
|
|
151
|
+
out.append("")
|
|
152
|
+
|
|
153
|
+
for part in msg["parts"]:
|
|
154
|
+
data = part["data"]
|
|
155
|
+
ptype = data.get("type", "")
|
|
156
|
+
if ptype == "text":
|
|
157
|
+
text = str(data.get("text", "")).strip()
|
|
158
|
+
if text:
|
|
159
|
+
out.append(text)
|
|
160
|
+
out.append("")
|
|
161
|
+
elif ptype == "reasoning":
|
|
162
|
+
if self.include_reasoning:
|
|
163
|
+
rtext = str(data.get("text", "")).strip()
|
|
164
|
+
if rtext:
|
|
165
|
+
out.append("<details><summary>🤔 reasoning</summary>")
|
|
166
|
+
out.append("")
|
|
167
|
+
out.append(rtext)
|
|
168
|
+
out.append("")
|
|
169
|
+
out.append("</details>")
|
|
170
|
+
out.append("")
|
|
171
|
+
elif ptype == "tool":
|
|
172
|
+
self._tool(out, data)
|
|
173
|
+
elif ptype == "file":
|
|
174
|
+
self._file(out, data)
|
|
175
|
+
elif ptype == "compaction":
|
|
176
|
+
out.append("> 🧹 *context compacted*")
|
|
177
|
+
out.append("")
|
|
178
|
+
elif ptype in ("step-start", "step-finish"):
|
|
179
|
+
pass
|
|
180
|
+
else:
|
|
181
|
+
if self.include_metadata:
|
|
182
|
+
out.append(f"<!-- part type: {ptype} -->")
|
|
183
|
+
out.append(_pretty_json(data))
|
|
184
|
+
out.append("")
|
|
185
|
+
|
|
186
|
+
out.append("")
|
|
187
|
+
out.append("---")
|
|
188
|
+
out.append("")
|
|
189
|
+
|
|
190
|
+
# ------------------------------------------------------------------- tools
|
|
191
|
+
def _tool(self, out: list[str], data: dict) -> None:
|
|
192
|
+
tool = data.get("tool") or data.get("name") or "tool"
|
|
193
|
+
state = data.get("state") or {}
|
|
194
|
+
status = state.get("status", "")
|
|
195
|
+
inp = state.get("input") or {}
|
|
196
|
+
out_value = state.get("output")
|
|
197
|
+
metadata = state.get("metadata") if isinstance(state.get("metadata"), dict) else {}
|
|
198
|
+
|
|
199
|
+
icon = {"completed": "✅", "error": "⛔", "cancelled": "🚫"}.get(status, "⏳")
|
|
200
|
+
out.append(f"#### {icon} Tool: `{tool}` · `{status}`")
|
|
201
|
+
out.append("")
|
|
202
|
+
|
|
203
|
+
title = _tool_title(state)
|
|
204
|
+
if title:
|
|
205
|
+
out.append(f"**Input:**")
|
|
206
|
+
out.append("")
|
|
207
|
+
out.append(self._fence_block(title))
|
|
208
|
+
out.append("")
|
|
209
|
+
elif inp:
|
|
210
|
+
out.append(f"**Input:**")
|
|
211
|
+
out.append("")
|
|
212
|
+
out.append(self._fence_block(_pretty_json(inp)))
|
|
213
|
+
out.append("")
|
|
214
|
+
|
|
215
|
+
text = self._format_output(out_value, tool)
|
|
216
|
+
if text:
|
|
217
|
+
out.append("**Output:**")
|
|
218
|
+
out.append("")
|
|
219
|
+
out.append(self._fence_block(text))
|
|
220
|
+
out.append("")
|
|
221
|
+
|
|
222
|
+
if metadata.get("exit") is not None:
|
|
223
|
+
out.append(f"*exit code: {metadata['exit']}*")
|
|
224
|
+
out.append("")
|
|
225
|
+
|
|
226
|
+
def _format_output(self, out_value: Any, tool: str) -> str | None:
|
|
227
|
+
if out_value is None or out_value == "":
|
|
228
|
+
return None
|
|
229
|
+
if _is_base64url(out_value):
|
|
230
|
+
try:
|
|
231
|
+
b64 = out_value.split(",", 1)[1]
|
|
232
|
+
return base64.b64decode(b64).decode("utf-8", errors="replace")
|
|
233
|
+
except Exception:
|
|
234
|
+
return None
|
|
235
|
+
if isinstance(out_value, (dict, list)):
|
|
236
|
+
text = _pretty_json(out_value)
|
|
237
|
+
else:
|
|
238
|
+
text = str(out_value)
|
|
239
|
+
if self.max_output and len(text) > self.max_output:
|
|
240
|
+
text = text[: self.max_output] + "\n…[output truncated]"
|
|
241
|
+
return text
|
|
242
|
+
|
|
243
|
+
def _fence_block(self, text: str) -> str:
|
|
244
|
+
f = _fence(text)
|
|
245
|
+
return f"{f}\n{text}\n{f}"
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------------------------- files
|
|
248
|
+
def _file(self, out: list[str], data: dict) -> None:
|
|
249
|
+
filename = data.get("filename") or "(attachment)"
|
|
250
|
+
mime = data.get("mime") or ""
|
|
251
|
+
url = data.get("url") or ""
|
|
252
|
+
line = f"#### 📎 {filename}"
|
|
253
|
+
if mime:
|
|
254
|
+
line += f" `{mime}`"
|
|
255
|
+
out.append(line)
|
|
256
|
+
out.append("")
|
|
257
|
+
|
|
258
|
+
if self.attachments_dir and _is_base64url(url):
|
|
259
|
+
try:
|
|
260
|
+
b64 = url.split(",", 1)[1]
|
|
261
|
+
raw = base64.b64decode(b64)
|
|
262
|
+
dest = self.attachments_dir / _safe_filename(filename)
|
|
263
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
dest.write_bytes(raw)
|
|
265
|
+
out.append(f"- saved to `{dest}`")
|
|
266
|
+
except Exception as e:
|
|
267
|
+
out.append(f"- *could not save attachment: {e}*")
|
|
268
|
+
elif url.startswith("http"):
|
|
269
|
+
out.append(f"- `{url}`")
|
|
270
|
+
elif url:
|
|
271
|
+
out.append(f"- `{url[:200]}`")
|
|
272
|
+
out.append("")
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opencode-chat-exporter
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Export OpenCode / AntiGravity local chats to a single connected Markdown file (GUI + CLI)
|
|
5
|
+
Author: nasro2123
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/nasro2123/chat-exporter
|
|
8
|
+
Project-URL: Repository, https://github.com/nasro2123/chat-exporter
|
|
9
|
+
Project-URL: Issues, https://github.com/nasro2123/chat-exporter/issues
|
|
10
|
+
Keywords: opencode,antigravity,chat,export,markdown,cli
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: customtkinter==5.2.2
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# Chat Exporter
|
|
29
|
+
|
|
30
|
+
A Python tool that exports your local chats — from **OpenCode (Desktop)** or **AntiGravity (Google)** — into one **single connected Markdown file**, preserving every detail verbatim: user and assistant messages, CMD / PowerShell commands, tool outputs, file & search operations, and attachments.
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- **Two chat sources**: OpenCode or AntiGravity (switch from the GUI or CLI)
|
|
35
|
+
- **Simple, clean GUI** (CustomTkinter) to browse and preview conversations
|
|
36
|
+
- **Live preview** with color-coded messages (you / assistant / tools)
|
|
37
|
+
- **Full export** of each conversation into one connected Markdown file — not separate sections
|
|
38
|
+
- Export all conversations to a folder, or everything into a single file
|
|
39
|
+
- **Live watch mode**: automatically re-export when the source updates
|
|
40
|
+
- Fully equivalent command-line interface
|
|
41
|
+
|
|
42
|
+
## Requirements
|
|
43
|
+
|
|
44
|
+
- Python 3.10+
|
|
45
|
+
|
|
46
|
+
## Building the EXE
|
|
47
|
+
|
|
48
|
+
**Easiest way:** double-click `build.bat`
|
|
49
|
+
|
|
50
|
+
Or from PowerShell:
|
|
51
|
+
|
|
52
|
+
```powershell
|
|
53
|
+
.\build.ps1 # onedir build (faster startup)
|
|
54
|
+
.\build.ps1 -OneFile # single-file exe (easier to share)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The script handles everything automatically: creating a `.venv`, installing dependencies, then building with PyInstaller. Output goes to `dist\`.
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
pip install -r requirements.txt
|
|
63
|
+
|
|
64
|
+
# GUI
|
|
65
|
+
python run.py
|
|
66
|
+
|
|
67
|
+
# CLI — OpenCode (default)
|
|
68
|
+
python -m chat_exporter --cli list
|
|
69
|
+
python -m chat_exporter --cli export --latest
|
|
70
|
+
|
|
71
|
+
# CLI — AntiGravity
|
|
72
|
+
python -m chat_exporter --cli --source antigravity list
|
|
73
|
+
python -m chat_exporter --cli --source antigravity export --latest
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Default locations
|
|
77
|
+
|
|
78
|
+
| Source | Path |
|
|
79
|
+
|---|---|
|
|
80
|
+
| OpenCode | `%USERPROFILE%\.local\share\opencode\opencode.db` |
|
|
81
|
+
| AntiGravity | `%USERPROFILE%\.gemini\antigravity` |
|
|
82
|
+
|
|
83
|
+
The path can be changed from the "…" button in the GUI or via `--db` (for AntiGravity, pass the `antigravity` folder containing `conversations\` and `brain\`).
|
|
84
|
+
|
|
85
|
+
### How does AntiGravity reading work?
|
|
86
|
+
|
|
87
|
+
Conversations are stored in a SQLite database with a `step_payload` column encoded as **protobuf**. The reader first tries the plain-text `transcript.jsonl` generated by the app under `brain\<uuid>\.system_generated\logs\` (easy and readable); if not found, it decodes the protobuf directly from the database. Both paths produce identical output.
|
|
88
|
+
|
|
89
|
+
## Project structure
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
Chat Exporter/
|
|
93
|
+
├── chat_exporter/
|
|
94
|
+
│ ├── db.py # OpenCode database reader (sessions / messages / parts) + factory
|
|
95
|
+
│ ├── antigravity.py # AntiGravity reader (transcript.jsonl + protobuf fallback)
|
|
96
|
+
│ ├── render.py # Conversation -> full Markdown renderer
|
|
97
|
+
│ ├── gui.py # Graphical interface
|
|
98
|
+
│ ├── cli.py # Command-line interface
|
|
99
|
+
│ └── __main__.py
|
|
100
|
+
├── run.py # Launch the GUI
|
|
101
|
+
├── run_gui.py
|
|
102
|
+
├── build.bat # One-click EXE build
|
|
103
|
+
├── build.ps1 # Build script (auto venv + PyInstaller)
|
|
104
|
+
└── requirements.txt
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Privacy
|
|
108
|
+
|
|
109
|
+
Everything runs locally — the tool reads data on your machine and writes Markdown files only. Nothing is sent to the internet. Review exported files before sharing them; they may contain sensitive paths and data from your sessions.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
chat_exporter/__init__.py,sha256=ZE9dmbrNoJ7tBAOISIu5mK8xZEOzBgVYYxOAKtRphkg,109
|
|
2
|
+
chat_exporter/__main__.py,sha256=s--TO_TfzC1TZRCuZtu4HQBObfAnv_hIVhWAgHmHpLU,447
|
|
3
|
+
chat_exporter/antigravity.py,sha256=ozmz2HImVRCRBS1wr50geNFohNEUWRR-cWSgidM9rMw,21876
|
|
4
|
+
chat_exporter/cli.py,sha256=E1aJan1WUnQ2tmmxxWuN2pKdpXIv4SD7NYGzUa25nZ0,7341
|
|
5
|
+
chat_exporter/db.py,sha256=1SEW9dY4K9uTGZpAYEH6J9dvbaGXHuAIJkZyODVZQxo,5756
|
|
6
|
+
chat_exporter/gui.py,sha256=jMgcvVnuMihFYVKqhOxFR7qxdAjZ2JcZaxX1Y1ZxFmI,22034
|
|
7
|
+
chat_exporter/render.py,sha256=PDXoglDjMj8Qquv60ufWJcDfz_MpJkI7a0KaZ0ImwIM,9502
|
|
8
|
+
opencode_chat_exporter-1.0.0.dist-info/licenses/LICENSE,sha256=WKBSpMZp6NRMM0kGUKXFH2OpDKtSQJtnaOvte0bETOo,1066
|
|
9
|
+
opencode_chat_exporter-1.0.0.dist-info/METADATA,sha256=eYINvNg2MQa747qJvJJ9IvHvOgQ4ai6sOik9snMbhSs,4450
|
|
10
|
+
opencode_chat_exporter-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
opencode_chat_exporter-1.0.0.dist-info/entry_points.txt,sha256=T_EglGYkzIcXoeQqbELA6pi5O6Vk90gGNG8Dn_bGRVg,66
|
|
12
|
+
opencode_chat_exporter-1.0.0.dist-info/top_level.txt,sha256=X2tacK0ysdURlsoPuW4TzGvcDt2cXhbHPlto5Qc5WQE,14
|
|
13
|
+
opencode_chat_exporter-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nasro2123
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
chat_exporter
|