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.
Files changed (29) hide show
  1. {worklog_for_claude_code-0.1.0/worklog_for_claude_code.egg-info → worklog_for_claude_code-0.2.0}/PKG-INFO +15 -13
  2. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/README.md +12 -10
  3. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/pyproject.toml +3 -3
  4. worklog_for_claude_code-0.2.0/tests/test_chats.py +407 -0
  5. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/__init__.py +1 -1
  6. worklog_for_claude_code-0.2.0/worklog/chats.py +437 -0
  7. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/cli.py +23 -1
  8. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/config.py +47 -1
  9. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/demo.py +45 -0
  10. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/report.py +70 -28
  11. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/web.py +203 -14
  12. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0/worklog_for_claude_code.egg-info}/PKG-INFO +15 -13
  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
  14. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/LICENSE +0 -0
  15. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/setup.cfg +0 -0
  16. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/tests/test_accounts.py +0 -0
  17. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/tests/test_demo.py +0 -0
  18. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/tests/test_worklog.py +0 -0
  19. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/collector.py +0 -0
  20. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/hooks.py +0 -0
  21. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/identity.py +0 -0
  22. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/parser.py +0 -0
  23. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/redact.py +0 -0
  24. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/services.py +0 -0
  25. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog/store.py +0 -0
  26. {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
  27. {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
  28. {worklog_for_claude_code-0.1.0 → worklog_for_claude_code-0.2.0}/worklog_for_claude_code.egg-info/requires.txt +0 -0
  29. {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.1.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
  [![CI](https://github.com/us-urvin/worklog-for-claude-code/actions/workflows/ci.yml/badge.svg)](https://github.com/us-urvin/worklog-for-claude-code/actions/workflows/ci.yml)
42
42
  [![PyPI](https://img.shields.io/pypi/v/worklog-for-claude-code)](https://pypi.org/project/worklog-for-claude-code/)
43
- [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
43
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](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
- Yes.
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**: your prompts (shortened), token counts, file names and commit messages. Never your code, and never Claude's replies.
94
- - 🧹 **Secrets are removed**: API keys, passwords, tokens and e-mail addresses are cleaned out before saving.
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
- - Works with **Claude Code only**, not claude.ai chat.
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
  [![CI](https://github.com/us-urvin/worklog-for-claude-code/actions/workflows/ci.yml/badge.svg)](https://github.com/us-urvin/worklog-for-claude-code/actions/workflows/ci.yml)
6
6
  [![PyPI](https://img.shields.io/pypi/v/worklog-for-claude-code)](https://pypi.org/project/worklog-for-claude-code/)
7
- [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](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
- Yes.
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**: your prompts (shortened), token counts, file names and commit messages. Never your code, and never Claude's replies.
58
- - 🧹 **Secrets are removed**: API keys, passwords, tokens and e-mail addresses are cleaned out before saving.
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
- - Works with **Claude Code only**, not claude.ai chat.
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.1.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]
@@ -1,3 +1,3 @@
1
1
  """claude-worklog: daily work log + token report for Claude Code."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"