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.
@@ -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,3 @@
1
+ """Project Chats CLI package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -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)