project-chats 0.2.0__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.
- project_chats-0.2.0/LICENSE +21 -0
- project_chats-0.2.0/PKG-INFO +187 -0
- project_chats-0.2.0/README.md +161 -0
- project_chats-0.2.0/project_chats/__init__.py +3 -0
- project_chats-0.2.0/project_chats/__main__.py +5 -0
- project_chats-0.2.0/project_chats/browser_move.py +242 -0
- project_chats-0.2.0/project_chats/cli.py +101 -0
- project_chats-0.2.0/project_chats/core.py +613 -0
- project_chats-0.2.0/project_chats/gui.py +909 -0
- project_chats-0.2.0/project_chats.egg-info/PKG-INFO +187 -0
- project_chats-0.2.0/project_chats.egg-info/SOURCES.txt +16 -0
- project_chats-0.2.0/project_chats.egg-info/dependency_links.txt +1 -0
- project_chats-0.2.0/project_chats.egg-info/entry_points.txt +3 -0
- project_chats-0.2.0/project_chats.egg-info/requires.txt +3 -0
- project_chats-0.2.0/project_chats.egg-info/top_level.txt +1 -0
- project_chats-0.2.0/pyproject.toml +40 -0
- project_chats-0.2.0/setup.cfg +4 -0
- project_chats-0.2.0/tests/test_smoke.py +277 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Project Chats contributors
|
|
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,187 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: project-chats
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Find project-related ChatGPT conversations, review them in a desktop app, and move approved chats into a ChatGPT Project.
|
|
5
|
+
Author: Project Chats contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/michaeljeisner/project-chats
|
|
8
|
+
Project-URL: Issues, https://github.com/michaeljeisner/project-chats/issues
|
|
9
|
+
Project-URL: Source, https://github.com/michaeljeisner/project-chats
|
|
10
|
+
Keywords: chatgpt,projects,migration,conversation-export
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Environment :: X11 Applications
|
|
15
|
+
Classifier: Environment :: MacOS X
|
|
16
|
+
Classifier: Environment :: Win32 (MS Windows)
|
|
17
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
18
|
+
Classifier: Topic :: Communications :: Chat
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Provides-Extra: browser
|
|
24
|
+
Requires-Dist: playwright>=1.45; extra == "browser"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# Project Chats
|
|
28
|
+
|
|
29
|
+
Project Chats is a local desktop tool that finds project-related ChatGPT conversations, helps you review them, and moves approved chats into a shared ChatGPT Project. It is designed for one-off ChatGPT Business/Team cleanup work where each user can only access their own chats — each person runs it on their own account, reviews the results, and ships the approved chats into a shared Project.
|
|
30
|
+
|
|
31
|
+
The GUI is the primary interface. A CLI is available for scripting.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
git clone https://github.com/michaeljeisner/project-chats.git
|
|
37
|
+
cd project-chats
|
|
38
|
+
python3 scripts/install.py
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
That creates a `.venv` in the repo, installs everything including Playwright, and writes `./project-chats-gui` and `./project-chats` launchers at the repo root. On macOS you can also double-click `Project Chats.command` after the script finishes.
|
|
42
|
+
|
|
43
|
+
Then launch the GUI:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
./project-chats-gui
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The GUI will offer to install browser automation (Playwright + Chromium) if it wasn't included, and the first run of Auto-Move will prompt you to sign into ChatGPT in the opened browser window.
|
|
50
|
+
|
|
51
|
+
> **PyPI release coming.** Once the package is published you'll be able to install with `pipx install 'project-chats[browser]'` and skip the clone.
|
|
52
|
+
|
|
53
|
+
## What It Does
|
|
54
|
+
|
|
55
|
+
- Imports a ChatGPT export (`conversations.json`), normalized JSON, Markdown, or text files.
|
|
56
|
+
- Scores conversations against a project profile (name + terms).
|
|
57
|
+
- Lets you review high-confidence and possible matches in-app, with snippets.
|
|
58
|
+
- Generates project memory files from approved chats:
|
|
59
|
+
- `project_brief.md`
|
|
60
|
+
- `decisions.md`
|
|
61
|
+
- `requirements.md`
|
|
62
|
+
- `open_questions.md`
|
|
63
|
+
- `source_chats.csv`
|
|
64
|
+
- Generates `move_queue.html` with links and per-chat move instructions.
|
|
65
|
+
- Optionally drives ChatGPT in a browser to move approved chats into the selected Project.
|
|
66
|
+
- Creates a handoff zip for teammates or a project coordinator.
|
|
67
|
+
|
|
68
|
+
## What It Does Not Do
|
|
69
|
+
|
|
70
|
+
- Does not scrape workspace-wide chats — only what your account can see.
|
|
71
|
+
- Does not read browser cookies, passwords, or ChatGPT session storage.
|
|
72
|
+
- Does not use undocumented ChatGPT APIs.
|
|
73
|
+
- Does not use a ChatGPT Project API (OpenAI does not expose one). The optional `auto-move` step drives the visible ChatGPT UI in a browser.
|
|
74
|
+
|
|
75
|
+
## Using the GUI
|
|
76
|
+
|
|
77
|
+
Launch:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
project-chats-gui
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The GUI walks you through four steps:
|
|
84
|
+
|
|
85
|
+
1. **New Project** — name it and add terms (project names, aliases, people, repos, clients, domains, or any words that identify relevant chats).
|
|
86
|
+
2. **Import Chats** — drop a `conversations.json`, normalized JSON, Markdown, or text. Classification runs automatically.
|
|
87
|
+
3. **Review** — check the candidates, approve the right ones, hit Save & Continue.
|
|
88
|
+
4. **Move** — open the move queue HTML for manual moving, or use Auto-Move to drive ChatGPT in a real browser.
|
|
89
|
+
|
|
90
|
+
The browser used by Auto-Move is a separate Playwright profile, not your everyday Chrome — sign into the ChatGPT account you want to use the first time. You can point at a different profile directory from Settings to use a different account.
|
|
91
|
+
|
|
92
|
+
## Quick Start (CLI)
|
|
93
|
+
|
|
94
|
+
The CLI mirrors what the GUI does and is useful for scripting or headless runs:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
project-chats init --project-name "Project Atlas" \
|
|
98
|
+
--term "Atlas" --term "launch" --term "fidelity"
|
|
99
|
+
|
|
100
|
+
project-chats ingest ~/Downloads/conversations.json --user-label michael
|
|
101
|
+
project-chats classify
|
|
102
|
+
project-chats build
|
|
103
|
+
project-chats bundle
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
This writes everything under `./project-chat-run/`:
|
|
107
|
+
|
|
108
|
+
```text
|
|
109
|
+
project-chat-run/outputs/review_queue.html
|
|
110
|
+
project-chat-run/outputs/move_queue.html
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Edit `project-chat-run/outputs/review_queue.csv` and set `approved=true` only for chats you want moved, then rerun `project-chats build`. The GUI does this in-app.
|
|
114
|
+
|
|
115
|
+
To move approved chats automatically through the ChatGPT UI:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
project-chats auto-move
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The first run opens a real browser profile at `project-chat-run/browser-profile`. Sign into ChatGPT there when prompted. The command writes `project-chat-run/outputs/move_log.csv`.
|
|
122
|
+
|
|
123
|
+
To use a specific browser profile/account from the CLI:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
project-chats auto-move --user-data-dir ./profiles/alice
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Multi-User Workflow
|
|
130
|
+
|
|
131
|
+
1. Project coordinator creates a profile:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
project-chats init --project-name "Shared Project" --term "client" --term "repo-name"
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
2. Send `project-chat-run/project_profile.json` to each participant.
|
|
138
|
+
3. Each participant runs the same workflow on their own account.
|
|
139
|
+
4. Each participant reviews and either uses the move queue HTML manually or runs `project-chats auto-move` while signed in.
|
|
140
|
+
5. Participants send the generated zip to the coordinator if a consolidated memory pack is needed.
|
|
141
|
+
|
|
142
|
+
## Inputs
|
|
143
|
+
|
|
144
|
+
### ChatGPT Export
|
|
145
|
+
|
|
146
|
+
Personal ChatGPT exports include `conversations.json`. ChatGPT Business may not expose the same export flow, so this format is supported when available but not required.
|
|
147
|
+
|
|
148
|
+
### Normalized JSON
|
|
149
|
+
|
|
150
|
+
```json
|
|
151
|
+
[
|
|
152
|
+
{
|
|
153
|
+
"user_label": "alice",
|
|
154
|
+
"conversation_id": "abc123",
|
|
155
|
+
"title": "Migration notes",
|
|
156
|
+
"url": "https://chatgpt.com/c/abc123",
|
|
157
|
+
"messages": [
|
|
158
|
+
{"author": "user", "text": "Project Atlas rollout plan..."},
|
|
159
|
+
{"author": "assistant", "text": "Decision: use staged rollout..."}
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
]
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Markdown/Text
|
|
166
|
+
|
|
167
|
+
Markdown and text files are ingested as one chat per file. The file name becomes the chat title.
|
|
168
|
+
|
|
169
|
+
## Safety
|
|
170
|
+
|
|
171
|
+
Only run this on chats you are authorized to process. Review the generated files before uploading them into a ChatGPT Project or sharing them with teammates.
|
|
172
|
+
|
|
173
|
+
The `auto-move` command is best-effort UI automation. ChatGPT can change labels or menus without notice, so run `project-chats auto-move --dry-run` first, then use `--limit 1` for a supervised first move.
|
|
174
|
+
|
|
175
|
+
## Hack on It
|
|
176
|
+
|
|
177
|
+
The install above already gives you an editable checkout. To run the tests:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
python3 -m unittest discover -s tests
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
If you want to reinstall from scratch (e.g. after pulling changes):
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
python3 scripts/install.py
|
|
187
|
+
```
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Project Chats
|
|
2
|
+
|
|
3
|
+
Project Chats is a local desktop tool that finds project-related ChatGPT conversations, helps you review them, and moves approved chats into a shared ChatGPT Project. It is designed for one-off ChatGPT Business/Team cleanup work where each user can only access their own chats — each person runs it on their own account, reviews the results, and ships the approved chats into a shared Project.
|
|
4
|
+
|
|
5
|
+
The GUI is the primary interface. A CLI is available for scripting.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
git clone https://github.com/michaeljeisner/project-chats.git
|
|
11
|
+
cd project-chats
|
|
12
|
+
python3 scripts/install.py
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
That creates a `.venv` in the repo, installs everything including Playwright, and writes `./project-chats-gui` and `./project-chats` launchers at the repo root. On macOS you can also double-click `Project Chats.command` after the script finishes.
|
|
16
|
+
|
|
17
|
+
Then launch the GUI:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
./project-chats-gui
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The GUI will offer to install browser automation (Playwright + Chromium) if it wasn't included, and the first run of Auto-Move will prompt you to sign into ChatGPT in the opened browser window.
|
|
24
|
+
|
|
25
|
+
> **PyPI release coming.** Once the package is published you'll be able to install with `pipx install 'project-chats[browser]'` and skip the clone.
|
|
26
|
+
|
|
27
|
+
## What It Does
|
|
28
|
+
|
|
29
|
+
- Imports a ChatGPT export (`conversations.json`), normalized JSON, Markdown, or text files.
|
|
30
|
+
- Scores conversations against a project profile (name + terms).
|
|
31
|
+
- Lets you review high-confidence and possible matches in-app, with snippets.
|
|
32
|
+
- Generates project memory files from approved chats:
|
|
33
|
+
- `project_brief.md`
|
|
34
|
+
- `decisions.md`
|
|
35
|
+
- `requirements.md`
|
|
36
|
+
- `open_questions.md`
|
|
37
|
+
- `source_chats.csv`
|
|
38
|
+
- Generates `move_queue.html` with links and per-chat move instructions.
|
|
39
|
+
- Optionally drives ChatGPT in a browser to move approved chats into the selected Project.
|
|
40
|
+
- Creates a handoff zip for teammates or a project coordinator.
|
|
41
|
+
|
|
42
|
+
## What It Does Not Do
|
|
43
|
+
|
|
44
|
+
- Does not scrape workspace-wide chats — only what your account can see.
|
|
45
|
+
- Does not read browser cookies, passwords, or ChatGPT session storage.
|
|
46
|
+
- Does not use undocumented ChatGPT APIs.
|
|
47
|
+
- Does not use a ChatGPT Project API (OpenAI does not expose one). The optional `auto-move` step drives the visible ChatGPT UI in a browser.
|
|
48
|
+
|
|
49
|
+
## Using the GUI
|
|
50
|
+
|
|
51
|
+
Launch:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
project-chats-gui
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The GUI walks you through four steps:
|
|
58
|
+
|
|
59
|
+
1. **New Project** — name it and add terms (project names, aliases, people, repos, clients, domains, or any words that identify relevant chats).
|
|
60
|
+
2. **Import Chats** — drop a `conversations.json`, normalized JSON, Markdown, or text. Classification runs automatically.
|
|
61
|
+
3. **Review** — check the candidates, approve the right ones, hit Save & Continue.
|
|
62
|
+
4. **Move** — open the move queue HTML for manual moving, or use Auto-Move to drive ChatGPT in a real browser.
|
|
63
|
+
|
|
64
|
+
The browser used by Auto-Move is a separate Playwright profile, not your everyday Chrome — sign into the ChatGPT account you want to use the first time. You can point at a different profile directory from Settings to use a different account.
|
|
65
|
+
|
|
66
|
+
## Quick Start (CLI)
|
|
67
|
+
|
|
68
|
+
The CLI mirrors what the GUI does and is useful for scripting or headless runs:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
project-chats init --project-name "Project Atlas" \
|
|
72
|
+
--term "Atlas" --term "launch" --term "fidelity"
|
|
73
|
+
|
|
74
|
+
project-chats ingest ~/Downloads/conversations.json --user-label michael
|
|
75
|
+
project-chats classify
|
|
76
|
+
project-chats build
|
|
77
|
+
project-chats bundle
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
This writes everything under `./project-chat-run/`:
|
|
81
|
+
|
|
82
|
+
```text
|
|
83
|
+
project-chat-run/outputs/review_queue.html
|
|
84
|
+
project-chat-run/outputs/move_queue.html
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Edit `project-chat-run/outputs/review_queue.csv` and set `approved=true` only for chats you want moved, then rerun `project-chats build`. The GUI does this in-app.
|
|
88
|
+
|
|
89
|
+
To move approved chats automatically through the ChatGPT UI:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
project-chats auto-move
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The first run opens a real browser profile at `project-chat-run/browser-profile`. Sign into ChatGPT there when prompted. The command writes `project-chat-run/outputs/move_log.csv`.
|
|
96
|
+
|
|
97
|
+
To use a specific browser profile/account from the CLI:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
project-chats auto-move --user-data-dir ./profiles/alice
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Multi-User Workflow
|
|
104
|
+
|
|
105
|
+
1. Project coordinator creates a profile:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
project-chats init --project-name "Shared Project" --term "client" --term "repo-name"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
2. Send `project-chat-run/project_profile.json` to each participant.
|
|
112
|
+
3. Each participant runs the same workflow on their own account.
|
|
113
|
+
4. Each participant reviews and either uses the move queue HTML manually or runs `project-chats auto-move` while signed in.
|
|
114
|
+
5. Participants send the generated zip to the coordinator if a consolidated memory pack is needed.
|
|
115
|
+
|
|
116
|
+
## Inputs
|
|
117
|
+
|
|
118
|
+
### ChatGPT Export
|
|
119
|
+
|
|
120
|
+
Personal ChatGPT exports include `conversations.json`. ChatGPT Business may not expose the same export flow, so this format is supported when available but not required.
|
|
121
|
+
|
|
122
|
+
### Normalized JSON
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
[
|
|
126
|
+
{
|
|
127
|
+
"user_label": "alice",
|
|
128
|
+
"conversation_id": "abc123",
|
|
129
|
+
"title": "Migration notes",
|
|
130
|
+
"url": "https://chatgpt.com/c/abc123",
|
|
131
|
+
"messages": [
|
|
132
|
+
{"author": "user", "text": "Project Atlas rollout plan..."},
|
|
133
|
+
{"author": "assistant", "text": "Decision: use staged rollout..."}
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Markdown/Text
|
|
140
|
+
|
|
141
|
+
Markdown and text files are ingested as one chat per file. The file name becomes the chat title.
|
|
142
|
+
|
|
143
|
+
## Safety
|
|
144
|
+
|
|
145
|
+
Only run this on chats you are authorized to process. Review the generated files before uploading them into a ChatGPT Project or sharing them with teammates.
|
|
146
|
+
|
|
147
|
+
The `auto-move` command is best-effort UI automation. ChatGPT can change labels or menus without notice, so run `project-chats auto-move --dry-run` first, then use `--limit 1` for a supervised first move.
|
|
148
|
+
|
|
149
|
+
## Hack on It
|
|
150
|
+
|
|
151
|
+
The install above already gives you an editable checkout. To run the tests:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
python3 -m unittest discover -s tests
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
If you want to reinstall from scratch (e.g. after pulling changes):
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
python3 scripts/install.py
|
|
161
|
+
```
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .core import load_profile, read_review_rows, workspace_paths
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
MOVE_FIELDS = [
|
|
12
|
+
"timestamp",
|
|
13
|
+
"user_label",
|
|
14
|
+
"conversation_id",
|
|
15
|
+
"title",
|
|
16
|
+
"url",
|
|
17
|
+
"status",
|
|
18
|
+
"detail",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class MoveOptions:
|
|
24
|
+
workspace: Path
|
|
25
|
+
user_label: str | None = None
|
|
26
|
+
project_name: str | None = None
|
|
27
|
+
user_data_dir: Path | None = None
|
|
28
|
+
channel: str = "chrome"
|
|
29
|
+
headless: bool = False
|
|
30
|
+
dry_run: bool = False
|
|
31
|
+
limit: int | None = None
|
|
32
|
+
slow_mo_ms: int = 150
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def auto_move(options: MoveOptions) -> Path:
|
|
36
|
+
profile = load_profile(options.workspace)
|
|
37
|
+
project_name = options.project_name or profile["project_name"]
|
|
38
|
+
rows = approved_rows(options.workspace, options.user_label, options.limit)
|
|
39
|
+
output_path = workspace_paths(options.workspace)["outputs"] / "move_log.csv"
|
|
40
|
+
|
|
41
|
+
if options.dry_run:
|
|
42
|
+
records = [
|
|
43
|
+
move_record(row, "dry_run", f"Would move to project: {project_name}")
|
|
44
|
+
for row in rows
|
|
45
|
+
]
|
|
46
|
+
append_move_records(output_path, records)
|
|
47
|
+
return output_path
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
|
51
|
+
from playwright.sync_api import sync_playwright
|
|
52
|
+
except ImportError as exc:
|
|
53
|
+
raise SystemExit(
|
|
54
|
+
"Browser automation requires Playwright. Install with: "
|
|
55
|
+
"python3 -m pip install 'project-chats[browser]' && python3 -m playwright install chromium"
|
|
56
|
+
) from exc
|
|
57
|
+
|
|
58
|
+
records = []
|
|
59
|
+
user_data_dir = options.user_data_dir or (options.workspace / "browser-profile")
|
|
60
|
+
user_data_dir.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
|
|
62
|
+
with sync_playwright() as p:
|
|
63
|
+
context = p.chromium.launch_persistent_context(
|
|
64
|
+
str(user_data_dir),
|
|
65
|
+
channel=options.channel,
|
|
66
|
+
headless=options.headless,
|
|
67
|
+
slow_mo=options.slow_mo_ms,
|
|
68
|
+
)
|
|
69
|
+
page = context.pages[0] if context.pages else context.new_page()
|
|
70
|
+
page.goto("https://chatgpt.com/", wait_until="domcontentloaded")
|
|
71
|
+
wait_for_login(page)
|
|
72
|
+
|
|
73
|
+
for row in rows:
|
|
74
|
+
url = row.get("url") or ""
|
|
75
|
+
if not url:
|
|
76
|
+
records.append(move_record(row, "skipped", "No chat URL available."))
|
|
77
|
+
continue
|
|
78
|
+
try:
|
|
79
|
+
page.goto(url, wait_until="domcontentloaded")
|
|
80
|
+
page.wait_for_timeout(1000)
|
|
81
|
+
status, detail = move_one_chat(page, project_name, PlaywrightTimeoutError)
|
|
82
|
+
records.append(move_record(row, status, detail))
|
|
83
|
+
except Exception as exc: # noqa: BLE001 - log per-row failures and continue.
|
|
84
|
+
records.append(move_record(row, "ui_failed", str(exc)[:500]))
|
|
85
|
+
|
|
86
|
+
context.close()
|
|
87
|
+
|
|
88
|
+
append_move_records(output_path, records)
|
|
89
|
+
return output_path
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def approved_rows(workspace: Path, user_label: str | None, limit: int | None) -> list[dict]:
|
|
93
|
+
rows = [
|
|
94
|
+
row
|
|
95
|
+
for row in read_review_rows(workspace)
|
|
96
|
+
if str(row.get("approved", "")).lower() == "true"
|
|
97
|
+
]
|
|
98
|
+
if user_label:
|
|
99
|
+
rows = [row for row in rows if row.get("user_label") == user_label]
|
|
100
|
+
rows = [
|
|
101
|
+
row
|
|
102
|
+
for row in rows
|
|
103
|
+
if row.get("move_status") not in {"moved", "already_in_project"}
|
|
104
|
+
]
|
|
105
|
+
return rows[:limit] if limit else rows
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def wait_for_login(page) -> None:
|
|
109
|
+
if page.get_by_role("textbox").count() or page.get_by_role("button", name="Search chats").count():
|
|
110
|
+
return
|
|
111
|
+
print("Sign into ChatGPT in the opened browser window, then press Enter here.")
|
|
112
|
+
input()
|
|
113
|
+
page.goto("https://chatgpt.com/", wait_until="domcontentloaded")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def move_one_chat(page, project_name: str, timeout_error_type) -> tuple[str, str]:
|
|
117
|
+
if page.get_by_text(project_name, exact=True).count():
|
|
118
|
+
return "already_in_project", f"Project name already visible on page: {project_name}"
|
|
119
|
+
|
|
120
|
+
open_chat_menu(page, timeout_error_type)
|
|
121
|
+
click_menu_item(page, ["Move to project", "Move to Project", "Add to project", "Add to Project"], timeout_error_type)
|
|
122
|
+
choose_project(page, project_name, timeout_error_type)
|
|
123
|
+
confirm_if_needed(page)
|
|
124
|
+
page.wait_for_timeout(1200)
|
|
125
|
+
|
|
126
|
+
if page.get_by_text(project_name, exact=True).count():
|
|
127
|
+
return "moved", f"Moved to project: {project_name}"
|
|
128
|
+
return "unknown", "Move flow completed, but project name was not visible afterward."
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def open_chat_menu(page, timeout_error_type) -> None:
|
|
132
|
+
labels = [
|
|
133
|
+
"Open conversation options",
|
|
134
|
+
"Open chat options",
|
|
135
|
+
"Conversation options",
|
|
136
|
+
"Chat options",
|
|
137
|
+
"More",
|
|
138
|
+
"More options",
|
|
139
|
+
]
|
|
140
|
+
for label in labels:
|
|
141
|
+
locator = page.get_by_role("button", name=label)
|
|
142
|
+
if click_first_visible(locator):
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
buttons = page.locator("button").all()
|
|
146
|
+
for button in reversed(buttons[-20:]):
|
|
147
|
+
try:
|
|
148
|
+
text = (button.inner_text(timeout=500) or "").strip()
|
|
149
|
+
aria = button.get_attribute("aria-label", timeout=500) or ""
|
|
150
|
+
if "⋯" in text or "…" in text or aria.lower() in {"more", "more options"}:
|
|
151
|
+
button.click(timeout=1500)
|
|
152
|
+
return
|
|
153
|
+
except timeout_error_type:
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
raise RuntimeError("Could not find the chat options menu.")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def click_menu_item(page, labels: list[str], timeout_error_type) -> None:
|
|
160
|
+
for label in labels:
|
|
161
|
+
for role in ("menuitem", "button"):
|
|
162
|
+
locator = page.get_by_role(role, name=label)
|
|
163
|
+
if click_first_visible(locator):
|
|
164
|
+
return
|
|
165
|
+
locator = page.get_by_text(label, exact=True)
|
|
166
|
+
if click_first_visible(locator):
|
|
167
|
+
return
|
|
168
|
+
raise RuntimeError(f"Could not find any menu item: {', '.join(labels)}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def choose_project(page, project_name: str, timeout_error_type) -> None:
|
|
172
|
+
candidates = [
|
|
173
|
+
page.get_by_role("option", name=project_name),
|
|
174
|
+
page.get_by_role("menuitem", name=project_name),
|
|
175
|
+
page.get_by_role("button", name=project_name),
|
|
176
|
+
page.get_by_text(project_name, exact=True),
|
|
177
|
+
]
|
|
178
|
+
for locator in candidates:
|
|
179
|
+
if click_first_visible(locator):
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
search_boxes = [
|
|
183
|
+
page.get_by_placeholder("Search projects"),
|
|
184
|
+
page.get_by_placeholder("Search projects..."),
|
|
185
|
+
page.get_by_role("textbox"),
|
|
186
|
+
]
|
|
187
|
+
for box in search_boxes:
|
|
188
|
+
if fill_first_visible(box, project_name):
|
|
189
|
+
page.wait_for_timeout(600)
|
|
190
|
+
if click_first_visible(page.get_by_text(project_name, exact=True)):
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
raise RuntimeError(f"Could not select project: {project_name}")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def confirm_if_needed(page) -> None:
|
|
197
|
+
for label in ("Move", "Add", "Confirm", "Done"):
|
|
198
|
+
locator = page.get_by_role("button", name=label)
|
|
199
|
+
if click_first_visible(locator):
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def click_first_visible(locator) -> bool:
|
|
204
|
+
count = min(locator.count(), 8)
|
|
205
|
+
for idx in range(count):
|
|
206
|
+
item = locator.nth(idx)
|
|
207
|
+
if item.is_visible():
|
|
208
|
+
item.click(timeout=3000)
|
|
209
|
+
return True
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def fill_first_visible(locator, value: str) -> bool:
|
|
214
|
+
count = min(locator.count(), 8)
|
|
215
|
+
for idx in range(count):
|
|
216
|
+
item = locator.nth(idx)
|
|
217
|
+
if item.is_visible():
|
|
218
|
+
item.fill(value, timeout=3000)
|
|
219
|
+
return True
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def move_record(row: dict, status: str, detail: str) -> dict:
|
|
224
|
+
return {
|
|
225
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
226
|
+
"user_label": row.get("user_label", ""),
|
|
227
|
+
"conversation_id": row.get("conversation_id", ""),
|
|
228
|
+
"title": row.get("title", ""),
|
|
229
|
+
"url": row.get("url", ""),
|
|
230
|
+
"status": status,
|
|
231
|
+
"detail": detail,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def append_move_records(path: Path, records: list[dict]) -> None:
|
|
236
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
exists = path.exists()
|
|
238
|
+
with path.open("a", newline="") as f:
|
|
239
|
+
writer = csv.DictWriter(f, fieldnames=MOVE_FIELDS)
|
|
240
|
+
if not exists:
|
|
241
|
+
writer.writeheader()
|
|
242
|
+
writer.writerows(records)
|