worklog-for-claude-code 0.1.0__tar.gz → 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.
- {worklog_for_claude_code-0.1.0/worklog_for_claude_code.egg-info → worklog_for_claude_code-0.2.0}/PKG-INFO +15 -13
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/README.md +12 -10
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/pyproject.toml +3 -3
- worklog_for_claude_code-0.2.0/tests/test_chats.py +407 -0
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/__init__.py +1 -1
- worklog_for_claude_code-0.2.0/worklog/chats.py +437 -0
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/cli.py +23 -1
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/config.py +47 -1
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/demo.py +45 -0
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/report.py +70 -28
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/web.py +203 -14
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0/worklog_for_claude_code.egg-info}/PKG-INFO +15 -13
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog_for_claude_code.egg-info/SOURCES.txt +2 -0
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/LICENSE +0 -0
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/setup.cfg +0 -0
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/tests/test_accounts.py +0 -0
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/tests/test_demo.py +0 -0
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/tests/test_worklog.py +0 -0
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/collector.py +0 -0
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/hooks.py +0 -0
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/identity.py +0 -0
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/parser.py +0 -0
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/redact.py +0 -0
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/services.py +0 -0
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/store.py +0 -0
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog_for_claude_code.egg-info/dependency_links.txt +0 -0
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog_for_claude_code.egg-info/entry_points.txt +0 -0
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog_for_claude_code.egg-info/requires.txt +0 -0
- {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog_for_claude_code.egg-info/top_level.txt +0 -0
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: worklog-for-claude-code
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Daily work log and token report across Claude Code accounts and devices, synced through your own private git repo
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Daily work log and token report across Claude Code accounts and devices, plus imported claude.ai chats, synced through your own private git repo
|
|
5
5
|
Author: Urvin
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/us-urvin/worklog-for-claude-code
|
|
8
8
|
Project-URL: Source, https://github.com/us-urvin/worklog-for-claude-code
|
|
9
9
|
Project-URL: Issues, https://github.com/us-urvin/worklog-for-claude-code/issues
|
|
10
10
|
Project-URL: Changelog, https://github.com/us-urvin/worklog-for-claude-code/blob/main/CHANGELOG.md
|
|
11
|
-
Keywords: claude-code,claude,worklog,time-tracking,token-usage,developer-tools,daily-report
|
|
11
|
+
Keywords: claude-code,claude,claude-ai,worklog,time-tracking,token-usage,developer-tools,daily-report
|
|
12
12
|
Classifier: Development Status :: 4 - Beta
|
|
13
13
|
Classifier: Environment :: Console
|
|
14
14
|
Classifier: Environment :: Web Environment
|
|
@@ -40,7 +40,7 @@ Dynamic: license-file
|
|
|
40
40
|
|
|
41
41
|
[](https://github.com/us-urvin/worklog-for-claude-code/actions/workflows/ci.yml)
|
|
42
42
|
[](https://pypi.org/project/worklog-for-claude-code/)
|
|
43
|
-
[](LICENSE)
|
|
43
|
+
[](https://github.com/us-urvin/worklog-for-claude-code/blob/main/LICENSE)
|
|
44
44
|
|
|
45
45
|
> Independent community project, not affiliated with or endorsed by Anthropic.
|
|
46
46
|
|
|
@@ -53,6 +53,7 @@ You use Claude Code all day. At the end of it, this tool gives you a simple page
|
|
|
53
53
|
- ✅ **What you worked on**: a short summary for each project
|
|
54
54
|
- 📊 **How many tokens you used**: per day, per account and per model
|
|
55
55
|
- 💻 **All in one place**: even if you use several computers or several Claude accounts
|
|
56
|
+
- 💬 **Your claude.ai chats too** (optional): import claude.ai's data export to see chats next to your coding work
|
|
56
57
|
|
|
57
58
|
It works in the background. You don't have to do anything after setup.
|
|
58
59
|
|
|
@@ -83,30 +84,31 @@ claude-worklog install-service
|
|
|
83
84
|
|
|
84
85
|
Done. Your work log fills up as you use Claude Code.
|
|
85
86
|
|
|
86
|
-
> Using more than one computer? Put the data folder in a **private** GitHub/GitLab repo so all your machines share it. See [Full setup](docs/GUIDE.md#full-setup).
|
|
87
|
+
> Using more than one computer? Put the data folder in a **private** GitHub/GitLab repo so all your machines share it. See [Full setup](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GUIDE.md#full-setup).
|
|
87
88
|
|
|
88
89
|
## Is my data private?
|
|
89
90
|
|
|
90
|
-
|
|
91
|
+
Mostly yes, with a few things you should know.
|
|
91
92
|
|
|
92
93
|
- 🔒 **Everything stays on your computer**, plus your own private git repo if you set one up. No cloud service, no tracking.
|
|
93
|
-
- ✂️ **Only short notes are saved**:
|
|
94
|
-
- 🧹 **
|
|
94
|
+
- ✂️ **Only short notes are saved**: the first 300 characters of each prompt, token counts, file names and commit messages. Your files and Claude's replies are never saved, but a code snippet you paste into a prompt can end up in those 300 characters.
|
|
95
|
+
- 🧹 **Common secrets are hidden (best effort)**: well-known API key formats, `password=…` / `token=…` pairs, e-mail addresses and IP addresses are replaced with `[REDACTED]` before saving. This can miss things, such as a password written in a normal sentence, so keep your data repo **private**.
|
|
95
96
|
- 🤖 **Optional AI summaries** use your own Claude account (`claude -p`). Turn them off in Settings to keep everything fully local.
|
|
97
|
+
- 💬 **Imported claude.ai chats** keep only chat titles and short redacted snippets of your own messages. Claude's replies, chat summaries and attachments are never saved.
|
|
96
98
|
|
|
97
|
-
[More about privacy →](docs/GUIDE.md#privacy)
|
|
99
|
+
[More about privacy →](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GUIDE.md#privacy)
|
|
98
100
|
|
|
99
101
|
## Good to know
|
|
100
102
|
|
|
101
|
-
-
|
|
103
|
+
- **Claude Code is tracked automatically.** claude.ai chats have no live connection: you import its data export on the **Sources** page (upload it, or let worklog pick it up from your Downloads folder). Exports have no token counts. See [claude.ai chats](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GUIDE.md#claudeai-chats).
|
|
102
104
|
- **Linux only** for now (it uses systemd).
|
|
103
105
|
- It reads Claude Code's local log files. Their format isn't official, so a Claude Code update could break it for a while.
|
|
104
106
|
|
|
105
107
|
## More
|
|
106
108
|
|
|
107
|
-
- 📖 [Full guide](docs/GUIDE.md): several computers, several accounts, all commands, uninstalling
|
|
108
|
-
- 🐛 [Report a bug](https://github.com/us-urvin/worklog-for-claude-code/issues/new/choose) · 🔐 [Security](SECURITY.md) · 🤝 [Contributing](CONTRIBUTING.md) · 📝 [Changelog](CHANGELOG.md)
|
|
109
|
+
- 📖 [Full guide](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GUIDE.md): several computers, several accounts, all commands, uninstalling
|
|
110
|
+
- 🐛 [Report a bug](https://github.com/us-urvin/worklog-for-claude-code/issues/new/choose) · 🔐 [Security](https://github.com/us-urvin/worklog-for-claude-code/blob/main/SECURITY.md) · 🤝 [Contributing](https://github.com/us-urvin/worklog-for-claude-code/blob/main/CONTRIBUTING.md) · 📝 [Changelog](https://github.com/us-urvin/worklog-for-claude-code/blob/main/CHANGELOG.md)
|
|
109
111
|
|
|
110
112
|
## License
|
|
111
113
|
|
|
112
|
-
[MIT](LICENSE) © 2026 Urvin
|
|
114
|
+
[MIT](https://github.com/us-urvin/worklog-for-claude-code/blob/main/LICENSE) © 2026 Urvin
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://github.com/us-urvin/worklog-for-claude-code/actions/workflows/ci.yml)
|
|
6
6
|
[](https://pypi.org/project/worklog-for-claude-code/)
|
|
7
|
-
[](LICENSE)
|
|
7
|
+
[](https://github.com/us-urvin/worklog-for-claude-code/blob/main/LICENSE)
|
|
8
8
|
|
|
9
9
|
> Independent community project, not affiliated with or endorsed by Anthropic.
|
|
10
10
|
|
|
@@ -17,6 +17,7 @@ You use Claude Code all day. At the end of it, this tool gives you a simple page
|
|
|
17
17
|
- ✅ **What you worked on**: a short summary for each project
|
|
18
18
|
- 📊 **How many tokens you used**: per day, per account and per model
|
|
19
19
|
- 💻 **All in one place**: even if you use several computers or several Claude accounts
|
|
20
|
+
- 💬 **Your claude.ai chats too** (optional): import claude.ai's data export to see chats next to your coding work
|
|
20
21
|
|
|
21
22
|
It works in the background. You don't have to do anything after setup.
|
|
22
23
|
|
|
@@ -47,30 +48,31 @@ claude-worklog install-service
|
|
|
47
48
|
|
|
48
49
|
Done. Your work log fills up as you use Claude Code.
|
|
49
50
|
|
|
50
|
-
> Using more than one computer? Put the data folder in a **private** GitHub/GitLab repo so all your machines share it. See [Full setup](docs/GUIDE.md#full-setup).
|
|
51
|
+
> Using more than one computer? Put the data folder in a **private** GitHub/GitLab repo so all your machines share it. See [Full setup](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GUIDE.md#full-setup).
|
|
51
52
|
|
|
52
53
|
## Is my data private?
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
Mostly yes, with a few things you should know.
|
|
55
56
|
|
|
56
57
|
- 🔒 **Everything stays on your computer**, plus your own private git repo if you set one up. No cloud service, no tracking.
|
|
57
|
-
- ✂️ **Only short notes are saved**:
|
|
58
|
-
- 🧹 **
|
|
58
|
+
- ✂️ **Only short notes are saved**: the first 300 characters of each prompt, token counts, file names and commit messages. Your files and Claude's replies are never saved, but a code snippet you paste into a prompt can end up in those 300 characters.
|
|
59
|
+
- 🧹 **Common secrets are hidden (best effort)**: well-known API key formats, `password=…` / `token=…` pairs, e-mail addresses and IP addresses are replaced with `[REDACTED]` before saving. This can miss things, such as a password written in a normal sentence, so keep your data repo **private**.
|
|
59
60
|
- 🤖 **Optional AI summaries** use your own Claude account (`claude -p`). Turn them off in Settings to keep everything fully local.
|
|
61
|
+
- 💬 **Imported claude.ai chats** keep only chat titles and short redacted snippets of your own messages. Claude's replies, chat summaries and attachments are never saved.
|
|
60
62
|
|
|
61
|
-
[More about privacy →](docs/GUIDE.md#privacy)
|
|
63
|
+
[More about privacy →](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GUIDE.md#privacy)
|
|
62
64
|
|
|
63
65
|
## Good to know
|
|
64
66
|
|
|
65
|
-
-
|
|
67
|
+
- **Claude Code is tracked automatically.** claude.ai chats have no live connection: you import its data export on the **Sources** page (upload it, or let worklog pick it up from your Downloads folder). Exports have no token counts. See [claude.ai chats](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GUIDE.md#claudeai-chats).
|
|
66
68
|
- **Linux only** for now (it uses systemd).
|
|
67
69
|
- It reads Claude Code's local log files. Their format isn't official, so a Claude Code update could break it for a while.
|
|
68
70
|
|
|
69
71
|
## More
|
|
70
72
|
|
|
71
|
-
- 📖 [Full guide](docs/GUIDE.md): several computers, several accounts, all commands, uninstalling
|
|
72
|
-
- 🐛 [Report a bug](https://github.com/us-urvin/worklog-for-claude-code/issues/new/choose) · 🔐 [Security](SECURITY.md) · 🤝 [Contributing](CONTRIBUTING.md) · 📝 [Changelog](CHANGELOG.md)
|
|
73
|
+
- 📖 [Full guide](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GUIDE.md): several computers, several accounts, all commands, uninstalling
|
|
74
|
+
- 🐛 [Report a bug](https://github.com/us-urvin/worklog-for-claude-code/issues/new/choose) · 🔐 [Security](https://github.com/us-urvin/worklog-for-claude-code/blob/main/SECURITY.md) · 🤝 [Contributing](https://github.com/us-urvin/worklog-for-claude-code/blob/main/CONTRIBUTING.md) · 📝 [Changelog](https://github.com/us-urvin/worklog-for-claude-code/blob/main/CHANGELOG.md)
|
|
73
75
|
|
|
74
76
|
## License
|
|
75
77
|
|
|
76
|
-
[MIT](LICENSE) © 2026 Urvin
|
|
78
|
+
[MIT](https://github.com/us-urvin/worklog-for-claude-code/blob/main/LICENSE) © 2026 Urvin
|
|
@@ -5,14 +5,14 @@ build-backend = "setuptools.build_meta"
|
|
|
5
5
|
[project]
|
|
6
6
|
# Distribution name on PyPI. The command stays `claude-worklog` and the package stays `worklog`.
|
|
7
7
|
name = "worklog-for-claude-code"
|
|
8
|
-
version = "0.
|
|
9
|
-
description = "Daily work log and token report across Claude Code accounts and devices, synced through your own private git repo"
|
|
8
|
+
version = "0.2.0"
|
|
9
|
+
description = "Daily work log and token report across Claude Code accounts and devices, plus imported claude.ai chats, synced through your own private git repo"
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
requires-python = ">=3.10"
|
|
12
12
|
license = "MIT"
|
|
13
13
|
license-files = ["LICENSE"]
|
|
14
14
|
authors = [{ name = "Urvin" }]
|
|
15
|
-
keywords = ["claude-code", "claude", "worklog", "time-tracking", "token-usage", "developer-tools", "daily-report"]
|
|
15
|
+
keywords = ["claude-code", "claude", "claude-ai", "worklog", "time-tracking", "token-usage", "developer-tools", "daily-report"]
|
|
16
16
|
classifiers = [
|
|
17
17
|
"Development Status :: 4 - Beta",
|
|
18
18
|
"Environment :: Console",
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""claude.ai chat export import. Every export here is synthetic, built in code."""
|
|
2
|
+
import http.client
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import zipfile
|
|
11
|
+
from datetime import date, datetime, timedelta, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from worklog import chats, cli, identity, store, web
|
|
17
|
+
from worklog.config import ConfigError, dump_config, load_config
|
|
18
|
+
from worklog.report import aggregate, render
|
|
19
|
+
|
|
20
|
+
ACCT = "66666666-aaaa-4aaa-8aaa-666666666666"
|
|
21
|
+
SECRET_REPLY = "ASSISTANT-REPLY-should-never-be-stored"
|
|
22
|
+
SECRET_SUMMARY = "CHAT-SUMMARY-should-never-be-stored"
|
|
23
|
+
SECRET_ATTACHMENT = "ATTACHMENT-CONTENT-should-never-be-stored"
|
|
24
|
+
SECRET_THINKING = "THINKING-should-never-be-stored"
|
|
25
|
+
SECRET_TOOL_INPUT = "TOOL-INPUT-should-never-be-stored"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ------------------------------------------------------------------ helpers
|
|
29
|
+
def ts(d: date, h: int, m: int = 0) -> str:
|
|
30
|
+
"""A local wall-clock time as the export writes it (UTC with Z)."""
|
|
31
|
+
local = datetime(d.year, d.month, d.day, h, m).astimezone()
|
|
32
|
+
return local.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def human(text: str, when: str, **kw) -> dict:
|
|
36
|
+
return {"uuid": f"h-{when}-{len(text)}", "sender": "human", "text": text, "created_at": when,
|
|
37
|
+
"content": [{"type": "text", "text": text}] if text else [], "attachments": [], "files": [],
|
|
38
|
+
"parent_message_uuid": "p", **kw}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def assistant(when: str, tools: list[dict] | None = None) -> dict:
|
|
42
|
+
content = [{"type": "thinking", "thinking": SECRET_THINKING},
|
|
43
|
+
{"type": "text", "text": SECRET_REPLY}] + (tools or [])
|
|
44
|
+
return {"uuid": f"a-{when}", "sender": "assistant", "text": SECRET_REPLY, "created_at": when,
|
|
45
|
+
"content": content, "attachments": [], "files": [], "parent_message_uuid": "p"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def tool(name: str, tid: str, **inp) -> dict:
|
|
49
|
+
return {"type": "tool_use", "id": tid, "name": name, "integration_name": "Claude Code",
|
|
50
|
+
"input": {"content": SECRET_TOOL_INPUT, **inp}}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def conv(uuid: str, msgs: list[dict], name: str = "Plan the launch", updated: str = "2026-09-21T10:00:00Z") -> dict:
|
|
54
|
+
return {"uuid": uuid, "name": name, "summary": SECRET_SUMMARY, "created_at": msgs[0]["created_at"] if msgs else "",
|
|
55
|
+
"updated_at": updated, "account": {"uuid": ACCT}, "chat_messages": msgs}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def write_zip(path: Path, convs: list[dict], member: str = "conversations.json") -> Path:
|
|
59
|
+
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
60
|
+
zf.writestr(member, json.dumps(convs))
|
|
61
|
+
return path
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
D = date(2026, 9, 20)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.fixture
|
|
68
|
+
def home(tmp_path, monkeypatch):
|
|
69
|
+
h = tmp_path / "home"
|
|
70
|
+
(h / ".claude" / "projects").mkdir(parents=True)
|
|
71
|
+
for var, sub in (("HOME", ""), ("XDG_CONFIG_HOME", ".config"), ("XDG_STATE_HOME", ".local/state"),
|
|
72
|
+
("XDG_DATA_HOME", ".local/share")):
|
|
73
|
+
monkeypatch.setenv(var, str(h / sub) if sub else str(h))
|
|
74
|
+
state = h / ".local/state/claude-worklog"
|
|
75
|
+
for mod in (identity, store, cli, chats):
|
|
76
|
+
monkeypatch.setattr(mod, "STATE_DIR", state)
|
|
77
|
+
assert Path.home() == h
|
|
78
|
+
return h
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.fixture
|
|
82
|
+
def cfg(home):
|
|
83
|
+
repo = home / "data"
|
|
84
|
+
repo.mkdir()
|
|
85
|
+
subprocess.run(["git", "init", "-q", str(repo)], check=True)
|
|
86
|
+
p = home / ".config/claude-worklog/config.toml"
|
|
87
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
p.write_text(f'device_name = "dev1"\nstore_repo = "{repo}"\n'
|
|
89
|
+
'[[accounts]]\nname = "personal"\nconfig_dir = "~/.claude"\n'
|
|
90
|
+
'[[accounts]]\nname = "work"\nconfig_dir = "~/.claude-work"\n'
|
|
91
|
+
'[collect]\nmax_prompt_chars = 40\n[report]\nuse_ai = false\n')
|
|
92
|
+
return load_config(p)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def records(cfg) -> dict[str, dict]:
|
|
96
|
+
return {str(p.relative_to(cfg.store_repo)): json.loads(p.read_text())
|
|
97
|
+
for p in sorted(cfg.store_repo.rglob("records/**/*.json"))}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def commits(cfg) -> int:
|
|
101
|
+
out = subprocess.run(["git", "-C", str(cfg.store_repo), "rev-list", "--count", "--all"],
|
|
102
|
+
capture_output=True, text=True)
|
|
103
|
+
return int(out.stdout.strip() or 0)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def basic_export() -> list[dict]:
|
|
107
|
+
return [conv("c1", [human("add a login page", ts(D, 9)), assistant(ts(D, 9, 1))])]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------ reading
|
|
111
|
+
def test_reads_zip_json_and_folder_and_merges_parts(tmp_path):
|
|
112
|
+
convs = basic_export()
|
|
113
|
+
(tmp_path / "conversations.json").write_text(json.dumps(convs))
|
|
114
|
+
assert chats.load_conversations(tmp_path / "conversations.json")[0][0]["uuid"] == "c1"
|
|
115
|
+
z = write_zip(tmp_path / "conversations-000.zip", convs, member="export/conversations.json")
|
|
116
|
+
assert chats.load_conversations(z)[0][0]["uuid"] == "c1"
|
|
117
|
+
|
|
118
|
+
folder = tmp_path / "dl"
|
|
119
|
+
folder.mkdir()
|
|
120
|
+
old = conv("c1", [human("old", ts(D, 9))], updated="2026-09-20T00:00:00Z")
|
|
121
|
+
new = conv("c1", [human("new", ts(D, 9))], updated="2026-09-22T00:00:00Z")
|
|
122
|
+
write_zip(folder / "conversations-000.zip", [new, conv("c2", [human("x", ts(D, 10))])])
|
|
123
|
+
write_zip(folder / "conversations-001.zip", [old])
|
|
124
|
+
(folder / "memories-000.zip").write_bytes(b"ignored")
|
|
125
|
+
got, bad, files = chats.load_conversations(folder)
|
|
126
|
+
assert sorted(c["uuid"] for c in got) == ["c1", "c2"] and bad == 0 and len(files) == 2
|
|
127
|
+
assert next(c for c in got if c["uuid"] == "c1")["chat_messages"][0]["text"] == "new"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.parametrize("content, msg", [
|
|
131
|
+
(json.dumps([{"id": "x", "mapping": {}}]), "ChatGPT"),
|
|
132
|
+
(json.dumps({"conversations": []}), "expected a list"),
|
|
133
|
+
("{not json", "not valid JSON"),
|
|
134
|
+
(json.dumps([{"foo": 1}]), "no chat_messages"),
|
|
135
|
+
])
|
|
136
|
+
def test_rejects_files_that_are_not_claude_exports(tmp_path, content, msg):
|
|
137
|
+
p = tmp_path / "conversations.json"
|
|
138
|
+
p.write_text(content)
|
|
139
|
+
with pytest.raises(chats.ExportError, match=msg):
|
|
140
|
+
chats.load_conversations(p)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_rejects_wrong_zip_and_oversized_member(tmp_path, monkeypatch):
|
|
144
|
+
with pytest.raises(chats.ExportError, match="no conversations.json"):
|
|
145
|
+
chats.load_conversations(write_zip(tmp_path / "memories-000.zip", [], member="memories.json"))
|
|
146
|
+
(tmp_path / "broken.zip").write_bytes(b"PK\x03\x04 not really")
|
|
147
|
+
with pytest.raises(chats.ExportError, match="not a readable zip"):
|
|
148
|
+
chats.load_conversations(tmp_path / "broken.zip")
|
|
149
|
+
monkeypatch.setattr(chats, "MAX_JSON_BYTES", 100)
|
|
150
|
+
with pytest.raises(chats.ExportError, match="larger than"):
|
|
151
|
+
chats.load_conversations(write_zip(tmp_path / "big.zip", [conv("c", [human("x" * 500, ts(D, 9))])]))
|
|
152
|
+
with pytest.raises(chats.ExportError, match="does not exist"):
|
|
153
|
+
chats.load_conversations(tmp_path / "missing.zip")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_malformed_chats_and_messages_are_counted_not_fatal(tmp_path):
|
|
157
|
+
good = conv("c1", [human("ok", ts(D, 9)), "junk", human("no time", "yesterday"), human("x", None)])
|
|
158
|
+
p = tmp_path / "conversations.json"
|
|
159
|
+
p.write_text(json.dumps([good, {"uuid": "c2"}, 7]))
|
|
160
|
+
convs, bad, _ = chats.load_conversations(p)
|
|
161
|
+
assert len(convs) == 1 and bad == 2
|
|
162
|
+
days, bad_msgs = chats.split_days(convs[0])
|
|
163
|
+
assert bad_msgs == 3 and days[D].prompts == ["ok"]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------ per-day rules
|
|
167
|
+
def test_only_real_prompts_are_kept_and_days_split_at_local_midnight():
|
|
168
|
+
msgs = [
|
|
169
|
+
human("first question", ts(D, 23, 50)),
|
|
170
|
+
human("", ts(D, 23, 51)), # tool-loop turn
|
|
171
|
+
human("", ts(D, 23, 52), content=[{"type": "injected_prompt_block", "prompt": "system stuff"}]),
|
|
172
|
+
human("first question", ts(D, 23, 55)), # edited/retried branch repeats the prompt
|
|
173
|
+
assistant(ts(D, 23, 56)),
|
|
174
|
+
human("after midnight", ts(D + timedelta(days=1), 0, 10)),
|
|
175
|
+
]
|
|
176
|
+
days, bad = chats.split_days(conv("c1", msgs))
|
|
177
|
+
assert bad == 0 and set(days) == {D, D + timedelta(days=1)}
|
|
178
|
+
assert days[D].prompts == ["first question"] and days[D].messages == 5
|
|
179
|
+
assert days[D + timedelta(days=1)].prompts == ["after midnight"]
|
|
180
|
+
since, _ = chats.split_days(conv("c1", msgs), since=D + timedelta(days=1))
|
|
181
|
+
assert set(since) == {D + timedelta(days=1)}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_record_redacts_truncates_and_never_holds_claudes_text(cfg):
|
|
185
|
+
key = "sk-ant-api03-" + "a" * 30
|
|
186
|
+
msgs = [human(f"my key is {key} and this prompt is far longer than forty characters", ts(D, 9),
|
|
187
|
+
attachments=[{"file_name": "notes.txt", "extracted_content": SECRET_ATTACHMENT}]),
|
|
188
|
+
assistant(ts(D, 9, 5), [tool("Write", "toolu_01A", file_path="/home/claude/app/src/main.py")])]
|
|
189
|
+
days, _ = chats.split_days(conv("c1", msgs, name=f"Debug {key}"))
|
|
190
|
+
rec = chats.build_record(days[D], "personal", cfg)
|
|
191
|
+
blob = json.dumps(rec)
|
|
192
|
+
for secret in (key, SECRET_REPLY, SECRET_SUMMARY, SECRET_ATTACHMENT, SECRET_THINKING, SECRET_TOOL_INPUT,
|
|
193
|
+
"toolu_01A", ACCT):
|
|
194
|
+
assert secret not in blob
|
|
195
|
+
assert rec["prompts"][0].startswith("my key is [REDACTED]") and len(rec["prompts"][0]) == 40
|
|
196
|
+
assert rec["title"] == "Debug [REDACTED]"
|
|
197
|
+
assert rec["source"] == "claude.ai" and rec["session_id"] == "chat-c1"
|
|
198
|
+
assert rec["tokens"] == {"input": 0, "output": 0, "cache_creation": 0, "cache_read": 0}
|
|
199
|
+
assert rec["project"]["name"] == "app" and rec["files_touched"] == ["src/main.py"]
|
|
200
|
+
assert rec["tool_calls"] == {"Write": 1}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_untitled_chat_and_assistant_only_day():
|
|
204
|
+
days, _ = chats.split_days(conv("c1", [human("hi", ts(D, 9))], name=""))
|
|
205
|
+
assert chats.build_record(days[D], "personal", load_cfg_stub())["title"] == "Untitled chat"
|
|
206
|
+
days, _ = chats.split_days(conv("c2", [assistant(ts(D, 9))]))
|
|
207
|
+
assert chats.build_record(days[D], "personal", load_cfg_stub()) is None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def load_cfg_stub():
|
|
211
|
+
from worklog.config import Account, Config
|
|
212
|
+
return Config(device="dev1", store_repo=Path("/nonexistent"), accounts=[Account("personal", Path("/x"))])
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ------------------------------------------------------------------ import
|
|
216
|
+
def test_import_is_idempotent_and_an_older_export_never_overwrites(cfg, tmp_path):
|
|
217
|
+
newer = conv("c1", [human("one", ts(D, 9)), human("two", ts(D, 10))], updated="2026-09-22T00:00:00Z")
|
|
218
|
+
res = chats.import_chats(cfg, write_zip(tmp_path / "conversations-000.zip", [newer]))
|
|
219
|
+
assert (res.chats, res.days, res.written, res.account) == (1, 1, 1, "personal")
|
|
220
|
+
path = f"records/{D.isoformat()}/dev1/personal/chat-c1.json"
|
|
221
|
+
assert records(cfg)[path]["prompts"] == ["one", "two"] and commits(cfg) == 1
|
|
222
|
+
|
|
223
|
+
again = chats.import_chats(cfg, tmp_path / "conversations-000.zip")
|
|
224
|
+
assert again.written == 0 and commits(cfg) == 1
|
|
225
|
+
|
|
226
|
+
older = conv("c1", [human("one", ts(D, 9))], updated="2026-09-21T00:00:00Z")
|
|
227
|
+
res = chats.import_chats(cfg, write_zip(tmp_path / "old.zip", [older]))
|
|
228
|
+
assert res.stale == 1 and res.written == 0 and records(cfg)[path]["prompts"] == ["one", "two"]
|
|
229
|
+
assert chats.load_state()["last_import"]["chats"] == 1
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_chats_that_are_local_claude_code_sessions_are_skipped(cfg, home, tmp_path):
|
|
233
|
+
d = home / ".claude/projects/-work"
|
|
234
|
+
d.mkdir(parents=True)
|
|
235
|
+
(d / "s1.jsonl").write_text(json.dumps({"type": "assistant", "timestamp": ts(D, 9), "message": {
|
|
236
|
+
"content": [{"type": "tool_use", "id": "toolu_01LOCAL", "name": "Bash", "input": {}}]}}) + "\n")
|
|
237
|
+
os.utime(d / "s1.jsonl", (time.time(), time.time()))
|
|
238
|
+
dup = conv("c1", [human("fix it", ts(D, 9)), assistant(ts(D, 9, 1), [tool("Bash", "toolu_01LOCAL")])])
|
|
239
|
+
other = conv("c2", [human("other", ts(D, 9)), assistant(ts(D, 9, 1), [tool("Bash", "toolu_01CLOUD")])])
|
|
240
|
+
res = chats.import_chats(cfg, write_zip(tmp_path / "c.zip", [dup, other]))
|
|
241
|
+
assert res.duplicates == 1 and res.days == 1
|
|
242
|
+
assert list(records(cfg)) == [f"records/{D.isoformat()}/dev1/personal/chat-c2.json"]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_account_choice_is_remembered_and_records_move(cfg, tmp_path):
|
|
246
|
+
z = write_zip(tmp_path / "c.zip", basic_export())
|
|
247
|
+
assert chats.import_chats(cfg, z).account == "personal"
|
|
248
|
+
with pytest.raises(chats.ExportError, match="no account named"):
|
|
249
|
+
chats.import_chats(cfg, z, account="nope")
|
|
250
|
+
res = chats.import_chats(cfg, z, account="work")
|
|
251
|
+
assert res.account == "work" and list(records(cfg)) == [f"records/{D.isoformat()}/dev1/work/chat-c1.json"]
|
|
252
|
+
assert chats.import_chats(cfg, z).account == "work" # remembered for this claude.ai login
|
|
253
|
+
state = json.loads(chats.state_path().read_text())
|
|
254
|
+
assert ACCT not in json.dumps(state) and oct(chats.state_path().stat().st_mode)[-3:] == "600"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_cli_import_chats(cfg, tmp_path, capsys, monkeypatch):
|
|
258
|
+
monkeypatch.setattr(cli, "load_config", lambda: cfg)
|
|
259
|
+
z = write_zip(tmp_path / "c.zip", basic_export())
|
|
260
|
+
try:
|
|
261
|
+
assert cli.main(["import-chats", str(z)]) == 0
|
|
262
|
+
assert "Imported 1 chat(s)" in capsys.readouterr().out
|
|
263
|
+
(tmp_path / "bad.json").write_text("[]x")
|
|
264
|
+
assert cli.main(["import-chats", str(tmp_path / "bad.json")]) == 1
|
|
265
|
+
assert "not valid JSON" in capsys.readouterr().out
|
|
266
|
+
finally:
|
|
267
|
+
root = logging.getLogger()
|
|
268
|
+
for h in root.handlers[:]: # cli._setup_logging adds handlers on every call
|
|
269
|
+
root.removeHandler(h)
|
|
270
|
+
h.close()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ------------------------------------------------------------------ downloads
|
|
274
|
+
def test_downloads_scan_is_opt_in_and_skips_partial_recent_and_seen(cfg, home):
|
|
275
|
+
dl = home / "Downloads"
|
|
276
|
+
dl.mkdir()
|
|
277
|
+
z = write_zip(dl / "conversations-000.zip", basic_export())
|
|
278
|
+
old = time.time() - 60
|
|
279
|
+
os.utime(z, (old, old))
|
|
280
|
+
assert chats.scan_downloads(cfg) == [] # toggle is off: nothing is read
|
|
281
|
+
|
|
282
|
+
cfg.watch_downloads = True
|
|
283
|
+
(dl / "conversations-000.zip.crdownload").write_bytes(b"")
|
|
284
|
+
assert chats.scan_downloads(cfg) == [] # browser still writing
|
|
285
|
+
(dl / "conversations-000.zip.crdownload").unlink()
|
|
286
|
+
fresh = write_zip(dl / "conversations-001.zip", [conv("c9", [human("new", ts(D, 9))])])
|
|
287
|
+
assert chats.pending_downloads(cfg) == [z] # fresh file has not settled yet
|
|
288
|
+
results = chats.scan_downloads(cfg)
|
|
289
|
+
assert len(results) == 1 and results[0].days == 1
|
|
290
|
+
assert chats.scan_downloads(cfg) == [] # already imported
|
|
291
|
+
assert z.exists() and fresh.exists() # never moved or deleted
|
|
292
|
+
|
|
293
|
+
bad = dl / "conversations-x.json"
|
|
294
|
+
bad.write_text(json.dumps([{"mapping": {}}]))
|
|
295
|
+
os.utime(bad, (old, old))
|
|
296
|
+
os.utime(fresh, (old, old))
|
|
297
|
+
results = chats.scan_downloads(cfg)
|
|
298
|
+
assert len(results) == 1 # the bad file is skipped, the settled one imported
|
|
299
|
+
assert chats.pending_downloads(cfg) == [] # and the bad one is not retried every sync
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_downloads_dir_from_xdg_user_dirs(home):
|
|
303
|
+
from worklog.config import default_downloads_dir
|
|
304
|
+
assert default_downloads_dir() == home / "Downloads"
|
|
305
|
+
(home / ".config").mkdir(exist_ok=True)
|
|
306
|
+
(home / ".config/user-dirs.dirs").write_text('XDG_DOWNLOAD_DIR="$HOME/Téléchargements"\n')
|
|
307
|
+
assert default_downloads_dir() == home / "Téléchargements"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ------------------------------------------------------------------ report + config
|
|
311
|
+
def test_report_counts_chats_once_across_devices_without_tokens(cfg, tmp_path):
|
|
312
|
+
chats.import_chats(cfg, write_zip(tmp_path / "c.zip", basic_export()))
|
|
313
|
+
rec = next(iter(records(cfg).values()))
|
|
314
|
+
copy = dict(rec, device="laptop")
|
|
315
|
+
accounts, projects = aggregate([rec, copy])
|
|
316
|
+
p = projects["chat:claude.ai chat"]
|
|
317
|
+
assert p.chats == 1 and p.sessions == 0 and p.titles == ["Plan the launch"]
|
|
318
|
+
text = render(D, cfg, accounts, projects, {})
|
|
319
|
+
assert "1 claude.ai chat(s)" in text and "no token data" in text and "Token usage" not in text
|
|
320
|
+
assert "- Plan the launch" in text
|
|
321
|
+
|
|
322
|
+
body, err = web.page_day(web.App(8765), cfg, D)
|
|
323
|
+
assert err is None and "1 claude.ai chat" in body and "no data from claude.ai" in body
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_config_chat_section_roundtrip_and_validation(cfg):
|
|
327
|
+
cfg.chat_account, cfg.watch_downloads, cfg.downloads_dir = "work", True, Path.home() / "dl"
|
|
328
|
+
p = cfg.path.with_name("roundtrip.toml")
|
|
329
|
+
p.write_text(dump_config(cfg))
|
|
330
|
+
again = load_config(p)
|
|
331
|
+
assert (again.chats_account, again.watch_downloads, again.downloads) == ("work", True, Path.home() / "dl")
|
|
332
|
+
p.write_text(dump_config(cfg).replace('account = "work"', 'account = "ghost"'))
|
|
333
|
+
with pytest.raises(ConfigError, match="ghost"):
|
|
334
|
+
load_config(p)
|
|
335
|
+
assert load_config(cfg.path).chats_account == "personal" # default: first account
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ------------------------------------------------------------------ web
|
|
339
|
+
@pytest.fixture
|
|
340
|
+
def server(cfg, monkeypatch):
|
|
341
|
+
monkeypatch.setattr(web, "load_config", lambda: cfg)
|
|
342
|
+
with socket.socket() as s:
|
|
343
|
+
s.bind(("127.0.0.1", 0))
|
|
344
|
+
port = s.getsockname()[1]
|
|
345
|
+
app = web.App(port)
|
|
346
|
+
srv = web._Server(("127.0.0.1", port), web.make_handler(app))
|
|
347
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
348
|
+
yield app, port
|
|
349
|
+
srv.shutdown()
|
|
350
|
+
srv.server_close()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _post(port, path, body: bytes, headers: dict):
|
|
354
|
+
c = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
|
355
|
+
c.request("POST", path, body=body, headers={"Host": f"localhost:{port}", **headers})
|
|
356
|
+
r = c.getresponse()
|
|
357
|
+
out = r.status, r.read()
|
|
358
|
+
c.close()
|
|
359
|
+
return out
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_upload_needs_csrf_and_imports_in_the_background(server, cfg, tmp_path):
|
|
363
|
+
app, port = server
|
|
364
|
+
data = write_zip(tmp_path / "c.zip", basic_export()).read_bytes()
|
|
365
|
+
assert _post(port, "/sources/upload", data, {"X-CSRF-Token": "wrong"})[0] == 403
|
|
366
|
+
assert _post(port, "/sources/upload", data, {"X-CSRF-Token": app.csrf,
|
|
367
|
+
"Origin": "http://evil.example"})[0] == 403
|
|
368
|
+
status, body = _post(port, "/sources/upload", data, {"X-CSRF-Token": app.csrf,
|
|
369
|
+
"X-File-Name": "conversations-000.zip"})
|
|
370
|
+
assert status == 202, body
|
|
371
|
+
for _ in range(100):
|
|
372
|
+
if not app.jobs.snapshot()["running"] and app.jobs.snapshot()["finished"]:
|
|
373
|
+
break
|
|
374
|
+
time.sleep(0.05)
|
|
375
|
+
assert "Imported 1 chat(s)" in app.jobs.snapshot()["message"]
|
|
376
|
+
assert list((chats.STATE_DIR / "uploads").iterdir()) == [] # temp copy removed
|
|
377
|
+
assert any(k.endswith("chat-c1.json") for k in records(cfg))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def test_upload_rejects_oversize_and_bad_files(server, monkeypatch):
|
|
381
|
+
app, port = server
|
|
382
|
+
monkeypatch.setattr(web, "MAX_UPLOAD", 10)
|
|
383
|
+
assert _post(port, "/sources/upload", b"x" * 11, {"X-CSRF-Token": app.csrf})[0] == 413
|
|
384
|
+
monkeypatch.setattr(web, "MAX_UPLOAD", 1000)
|
|
385
|
+
status, _ = _post(port, "/sources/upload", b'[{"mapping": {}}]', {"X-CSRF-Token": app.csrf})
|
|
386
|
+
assert status == 202
|
|
387
|
+
for _ in range(100):
|
|
388
|
+
if app.jobs.snapshot()["finished"]:
|
|
389
|
+
break
|
|
390
|
+
time.sleep(0.05)
|
|
391
|
+
snap = app.jobs.snapshot()
|
|
392
|
+
assert not snap["ok"] and "ChatGPT" in snap["message"]
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def test_sources_page_and_settings(server, cfg, monkeypatch):
|
|
396
|
+
app, _ = server
|
|
397
|
+
html_out = web.page_sources(app, cfg)
|
|
398
|
+
assert "claude.ai chats" in html_out and "Not imported yet" in html_out and "Coming later" in html_out
|
|
399
|
+
assert "Nothing in your Downloads folder is read" in html_out
|
|
400
|
+
saved = []
|
|
401
|
+
monkeypatch.setattr(web, "save_config", lambda c: saved.append(c))
|
|
402
|
+
loc, err, _ = app.post("/sources/settings", {"account": "work", "watch": "1", "downloads": "~/dl"})
|
|
403
|
+
assert err is None and loc == "/sources?ok=chat_saved"
|
|
404
|
+
new = saved[-1]
|
|
405
|
+
assert new.chat_account == "work" and new.watch_downloads and new.downloads_dir == Path.home() / "dl"
|
|
406
|
+
assert app.post("/sources/settings", {"account": "work", "downloads": "/etc"})[1]
|
|
407
|
+
assert app.post("/sources/settings", {"account": "ghost"})[1]
|