openlogbook 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
logbook/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Logbook — a diary that writes itself. Layer 1: the folder, the chain, the CLI."""
2
+
3
+ __version__ = "0.1.0"
4
+ FORMAT = "logbook/0.1"
logbook/chain.py ADDED
@@ -0,0 +1,52 @@
1
+ """The hash chain. Pure functions, no I/O. This file *is* SPEC.md §3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from collections.abc import Iterable
8
+ from typing import Any
9
+
10
+ Line = dict[str, Any]
11
+
12
+ GENESIS = "0" * 64
13
+ CONTENT_FIELDS = ("at", "end", "tz", "source", "kind", "tier", "payload")
14
+
15
+
16
+ def canonical_json(obj: object) -> str:
17
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
18
+
19
+
20
+ def content_hash(line: Line) -> str:
21
+ content = {k: line.get(k) for k in CONTENT_FIELDS}
22
+ return hashlib.sha256(canonical_json(content).encode("utf-8")).hexdigest()
23
+
24
+
25
+ def line_hash(prev: str, seq: int, chash: str, recorded_at: str) -> str:
26
+ return hashlib.sha256(f"{prev}|{seq}|{chash}|{recorded_at}".encode()).hexdigest()
27
+
28
+
29
+ def compute_hash(line: Line) -> str:
30
+ return line_hash(line["prev"], line["seq"], content_hash(line), line["recorded_at"])
31
+
32
+
33
+ def verify_lines(lines: Iterable[Line]) -> tuple[int, str, list[str]]:
34
+ """Walk lines in order. Returns (count, head, errors)."""
35
+ errors, prev, seq, head = [], GENESIS, 0, GENESIS
36
+ for n, line in enumerate(lines, 1):
37
+ if line.get("seq") != seq + 1:
38
+ errors.append(f"line {n}: seq {line.get('seq')} expected {seq + 1}")
39
+ if line.get("prev") != prev:
40
+ errors.append(f"line {n}: prev does not match previous hash")
41
+ if "payload" not in line or "schema" not in (line.get("payload") or {}):
42
+ errors.append(f"line {n}: payload.schema missing")
43
+ if line.get("tier") not in (1, 2, 3):
44
+ errors.append(f"line {n}: tier must be 1, 2 or 3")
45
+ expected = compute_hash(line) if all(k in line for k in ("prev", "seq", "recorded_at")) else None
46
+ if expected != line.get("hash"):
47
+ errors.append(f"line {n}: hash does not recompute")
48
+ prev, seq, head = line.get("hash", prev), line.get("seq", seq), line.get("hash", head)
49
+ if len(errors) > 20:
50
+ errors.append("…stopping after 20 errors")
51
+ break
52
+ return seq, head, errors
logbook/cli.py ADDED
@@ -0,0 +1,130 @@
1
+ """logbook — init · add · show · verify · export. Three verbs and two you run once a year."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from datetime import date, datetime
9
+ from pathlib import Path
10
+
11
+ from . import __version__
12
+ from .store import CodeCheckoutError, Logbook, now_utc
13
+
14
+
15
+ def _tz_default() -> str:
16
+ key = getattr(datetime.now().astimezone().tzinfo, "key", None)
17
+ return key if isinstance(key, str) else "UTC"
18
+
19
+
20
+ def cmd_init(a: argparse.Namespace) -> None:
21
+ root = Path(a.path or Path.home() / "Logbook").expanduser()
22
+ try:
23
+ lb = Logbook.init(root, a.timezone or _tz_default())
24
+ except CodeCheckoutError as e:
25
+ print(f"refusing to init: {e}", file=sys.stderr)
26
+ sys.exit(2)
27
+ print(f'created {lb.root}\nDrop any export into {lb.root / "inbox"}, or: logbook add "what happened"')
28
+
29
+
30
+ def cmd_add(a: argparse.Namespace) -> None:
31
+ lb = Logbook.find()
32
+ what = " ".join(a.what).strip()
33
+ p = Path(what).expanduser()
34
+ if p.exists() and p.suffix == ".jsonl": # observations produced by an adapter
35
+ drafts = [json.loads(line) for line in p.read_text(encoding="utf-8").splitlines() if line.strip()]
36
+ n = lb.append_many(drafts)
37
+ print(f"added {n} lines from {p.name}")
38
+ elif p.exists():
39
+ print(
40
+ f"{p.name}: no adapter for this file yet (roadmap phase 1). "
41
+ "Put it in inbox/ and it will be read when one exists."
42
+ )
43
+ sys.exit(2)
44
+ else: # a sentence, in your own words
45
+ at = a.at or now_utc()
46
+ line = lb.append(
47
+ at=at, source="manual", kind="note", tier=2, payload={"schema": "note/v1", "text": what}
48
+ )
49
+ print(f"#{line['seq']} {line['at']} {what}")
50
+
51
+
52
+ def cmd_show(a: argparse.Namespace) -> None:
53
+ lb = Logbook.find()
54
+ day = date.today().isoformat() if a.day in (None, "today") else a.day
55
+ rows = [line for line in lb.lines() if line["at"].startswith(day)]
56
+ if not rows:
57
+ print(f"{day}: nothing logged")
58
+ return
59
+ print(day)
60
+ for line in rows:
61
+ p = line["payload"]
62
+ text = (
63
+ p.get("text")
64
+ or p.get("title")
65
+ or p.get("name")
66
+ or ", ".join(f"{k}={v}" for k, v in p.items() if k != "schema")
67
+ )
68
+ print(f" {line['at'][11:16]} {line['kind']:<10} {line['source']:<14} {text}")
69
+ note = lb.root / "notes" / day[:4] / f"{day}.md"
70
+ if note.exists():
71
+ print(" — note —\n" + "\n".join(" " + s for s in note.read_text(encoding="utf-8").splitlines()))
72
+
73
+
74
+ def cmd_verify(a: argparse.Namespace) -> None:
75
+ lb = Logbook(Path(a.root).expanduser()) if a.root else Logbook.find()
76
+ seq, head, errors = lb.verify()
77
+ if a.expect:
78
+ exp = json.loads(Path(a.expect).read_text(encoding="utf-8"))
79
+ if (exp["seq"], exp["head"]) != (seq, head):
80
+ errors.append(
81
+ f"expected seq={exp['seq']} head={exp['head'][:12]}…, got seq={seq} head={head[:12]}…"
82
+ )
83
+ if errors:
84
+ print(f"INVALID — {len(errors)} problem(s):")
85
+ for e in errors:
86
+ print(" " + e)
87
+ sys.exit(1)
88
+ print(f"valid — {seq} lines, head {head}")
89
+
90
+
91
+ def cmd_export(a: argparse.Namespace) -> None:
92
+ lb = Logbook.find()
93
+ out = Path(a.path).expanduser()
94
+ out.parent.mkdir(parents=True, exist_ok=True)
95
+ n = 0
96
+ with out.open("w", encoding="utf-8") as fh:
97
+ for line in lb.lines():
98
+ fh.write(json.dumps(line, ensure_ascii=False, sort_keys=True) + "\n")
99
+ n += 1
100
+ print(f"exported {n} lines to {out} — verify with: logbook verify")
101
+
102
+
103
+ def main(argv: list[str] | None = None) -> None:
104
+ ap = argparse.ArgumentParser(prog="logbook", description="A diary that writes itself.")
105
+ ap.add_argument("--version", action="version", version=__version__)
106
+ sub = ap.add_subparsers(dest="cmd", required=True)
107
+ s = sub.add_parser("init", help="create a logbook (default ~/Logbook)")
108
+ s.add_argument("path", nargs="?")
109
+ s.add_argument("--timezone")
110
+ s.set_defaults(fn=cmd_init)
111
+ s = sub.add_parser("add", help="a sentence in your words, or an adapter's .jsonl")
112
+ s.add_argument("what", nargs="+")
113
+ s.add_argument("--at", help="RFC3339 UTC, default now")
114
+ s.set_defaults(fn=cmd_add)
115
+ s = sub.add_parser("show", help="one day (default today)")
116
+ s.add_argument("day", nargs="?")
117
+ s.set_defaults(fn=cmd_show)
118
+ s = sub.add_parser("verify", help="check the chain")
119
+ s.add_argument("--root", help="logbook folder (default: find)")
120
+ s.add_argument("--expect", help="expected.json with seq and head (conformance)")
121
+ s.set_defaults(fn=cmd_verify)
122
+ s = sub.add_parser("export", help="the whole log as one .jsonl")
123
+ s.add_argument("path")
124
+ s.set_defaults(fn=cmd_export)
125
+ a = ap.parse_args(argv)
126
+ a.fn(a)
127
+
128
+
129
+ if __name__ == "__main__":
130
+ main()
logbook/store.py ADDED
@@ -0,0 +1,166 @@
1
+ """The folder. The only code that writes to logbook/*.jsonl."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import secrets
8
+ import time
9
+ import uuid
10
+ from collections.abc import Iterable, Iterator
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from . import FORMAT
16
+ from .chain import GENESIS, Line, compute_hash, verify_lines
17
+
18
+
19
+ def now_utc() -> str:
20
+ return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
21
+
22
+
23
+ def uuid7() -> str:
24
+ """RFC 9562 UUIDv7: 48-bit ms timestamp, then random. Time-ordered, so ids sort like the log."""
25
+ ms = time.time_ns() // 1_000_000
26
+ rand_a = secrets.randbits(12)
27
+ rand_b = secrets.randbits(62)
28
+ value = (ms << 80) | (0x7 << 76) | (rand_a << 64) | (0b10 << 62) | rand_b
29
+ return str(uuid.UUID(int=value))
30
+
31
+
32
+ CHECKOUT_MARKERS = ("pyproject.toml", ".git", "logbook/__init__.py")
33
+
34
+
35
+ class CodeCheckoutError(Exception):
36
+ """The folder looks like a clone of this repository, not a personal record."""
37
+
38
+
39
+ def code_checkout_marker(root: Path) -> str | None:
40
+ """The first thing in `root` that says "source code, not a diary", or None.
41
+
42
+ On a case-insensitive disk ~/Logbook and a clone at ~/logbook are the same folder,
43
+ so a logbook must never be created in, or found in, a folder that has one of these."""
44
+ return next((m for m in CHECKOUT_MARKERS if (Path(root) / m).exists()), None)
45
+
46
+
47
+ class Logbook:
48
+ def __init__(self, root: Path):
49
+ self.root = Path(root)
50
+ self.meta_path = self.root / "logbook.json"
51
+ self.log_dir = self.root / "logbook"
52
+
53
+ # -- lifecycle -----------------------------------------------------------
54
+ @classmethod
55
+ def init(cls, root: Path, timezone_name: str) -> Logbook:
56
+ root = Path(root)
57
+ marker = code_checkout_marker(root)
58
+ if marker is not None:
59
+ raise CodeCheckoutError(
60
+ f"{root} looks like a code checkout (it has {marker}); "
61
+ "choose another folder or set LOGBOOK_HOME"
62
+ )
63
+ if (root / "logbook.json").exists():
64
+ raise FileExistsError(f"{root} is already a logbook")
65
+ for d in ("logbook", "notes", "inbox", "inbox/done"):
66
+ (root / d).mkdir(parents=True, exist_ok=True)
67
+ meta = {
68
+ "format": FORMAT,
69
+ "owner_id": uuid7(),
70
+ "created_at": now_utc(),
71
+ "timezone": timezone_name,
72
+ "seq": 0,
73
+ "head": GENESIS,
74
+ }
75
+ (root / "logbook.json").write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
76
+ return cls(root)
77
+
78
+ @classmethod
79
+ def find(cls, start: Path | None = None) -> Logbook:
80
+ env = os.environ.get("LOGBOOK_HOME")
81
+ candidates = [Path(env)] if env else []
82
+ p = Path(start or Path.cwd()).resolve()
83
+ candidates += [p, *p.parents, Path.home() / "Logbook"]
84
+ for c in candidates:
85
+ if (c / "logbook.json").exists() and code_checkout_marker(c) is None:
86
+ return cls(c)
87
+ raise FileNotFoundError("no logbook found; run `logbook init`")
88
+
89
+ @property
90
+ def meta(self) -> dict[str, Any]:
91
+ data: dict[str, Any] = json.loads(self.meta_path.read_text(encoding="utf-8"))
92
+ return data
93
+
94
+ def _save_meta(self, meta: dict[str, Any]) -> None:
95
+ self.meta_path.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
96
+
97
+ # -- reading -------------------------------------------------------------
98
+ def files(self) -> list[Path]:
99
+ return sorted(self.log_dir.glob("*/*.jsonl"))
100
+
101
+ def lines(self) -> Iterator[Line]:
102
+ """All lines in chain order (by seq). Files partition by month of `at`; backfilled
103
+ history lands in old files, so file order is not chain order."""
104
+ rows: list[Line] = []
105
+ for f in self.files():
106
+ with f.open(encoding="utf-8") as fh:
107
+ rows.extend(json.loads(raw) for raw in fh if raw.strip())
108
+ rows.sort(key=lambda r: r.get("seq", 0))
109
+ return iter(rows)
110
+
111
+ def verify(self) -> tuple[int, str, list[str]]:
112
+ seq, head, errors = verify_lines(self.lines())
113
+ meta = self.meta
114
+ if meta["seq"] != seq or meta["head"] != head:
115
+ errors.append(
116
+ f"logbook.json says seq={meta['seq']} head={meta['head'][:12]}…, "
117
+ f"files say seq={seq} head={head[:12]}…"
118
+ )
119
+ return seq, head, errors
120
+
121
+ # -- writing -------------------------------------------------------------
122
+ def append(
123
+ self,
124
+ at: str,
125
+ source: str,
126
+ kind: str,
127
+ tier: int,
128
+ payload: dict[str, Any],
129
+ end: str | None = None,
130
+ tz: str | None = None,
131
+ recorded_at: str | None = None,
132
+ ) -> Line:
133
+ if "schema" not in payload:
134
+ raise ValueError("payload.schema is required")
135
+ if tier not in (1, 2, 3):
136
+ raise ValueError("tier must be 1, 2 or 3")
137
+ meta = self.meta
138
+ line: Line = {
139
+ "id": uuid7(),
140
+ "seq": meta["seq"] + 1,
141
+ "at": at,
142
+ "end": end,
143
+ "tz": tz or meta["timezone"],
144
+ "source": source,
145
+ "kind": kind,
146
+ "tier": tier,
147
+ "payload": payload,
148
+ "recorded_at": recorded_at or now_utc(),
149
+ "prev": meta["head"],
150
+ }
151
+ line["hash"] = compute_hash(line)
152
+ year, month = at[:4], at[5:7]
153
+ path = self.log_dir / year / f"{month}.jsonl"
154
+ path.parent.mkdir(parents=True, exist_ok=True)
155
+ with path.open("a", encoding="utf-8") as fh:
156
+ fh.write(json.dumps(line, ensure_ascii=False, sort_keys=True) + "\n")
157
+ meta["seq"], meta["head"] = line["seq"], line["hash"]
158
+ self._save_meta(meta)
159
+ return line
160
+
161
+ def append_many(self, drafts: Iterable[dict[str, Any]]) -> int:
162
+ n = 0
163
+ for d in drafts:
164
+ self.append(**d)
165
+ n += 1
166
+ return n
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.5
2
+ Name: openlogbook
3
+ Version: 0.1.0
4
+ Summary: A diary that writes itself. Your life, in a folder.
5
+ Project-URL: Homepage, https://github.com/bighydro/logbook
6
+ Project-URL: Specification, https://github.com/bighydro/logbook/blob/main/SPEC.md
7
+ Project-URL: Changelog, https://github.com/bighydro/logbook/blob/main/CHANGELOG.md
8
+ Author: bighydro
9
+ License-Expression: Apache-2.0
10
+ License-File: LICENSE
11
+ Keywords: append-only,diary,hash-chain,lifelog,local-first,logbook,personal-data
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Database
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Logbook
22
+
23
+ **A diary that writes itself.**
24
+
25
+ Your life already gets recorded — by your phone, your photos, your calendar, your messages. Logbook writes it all down once, in one folder that only you hold, and reads it back to you as days. You hand a page to someone you care about; they hand one back.
26
+
27
+ It is the opposite of social media: no feed, no followers, no likes, no counts. Reveal, never reward. Nothing leaves without a name.
28
+
29
+ ![How Logbook works](docs/how-it-works.svg)
30
+
31
+ ## Sixty seconds
32
+
33
+ ```bash
34
+ pipx install openlogbook # or: pip install .
35
+ logbook init # creates ~/Logbook and your key
36
+ logbook add "had lunch with a friend by the lake"
37
+ logbook add ~/Downloads/takeout.zip # any export, no flags
38
+ logbook show today
39
+ logbook verify # the chain is intact
40
+ ```
41
+
42
+ That is the whole product. Everything else is a layer somebody plugs in.
43
+
44
+ ## What is in the folder
45
+
46
+ ```
47
+ ~/Logbook/
48
+ logbook/ the record. one file per month. append only. never edit.
49
+ 2026/09.jsonl
50
+ inbox/ drop anything here. it gets read, then moved to done/.
51
+ notes/ what you write. plain Markdown, one file per day.
52
+ logbook.json who this is, your timezone, the chain head.
53
+ ```
54
+
55
+ Nothing here needs the app to make sense. Open the files in any editor twenty years from now.
56
+
57
+ If you also keep a clone of this repository, set `LOGBOOK_HOME` to your record's folder and pass that path to `logbook init`. On a case-insensitive disk (macOS by default) `~/Logbook` and a clone named `~/logbook` are the same folder; `init` refuses a folder that contains `pyproject.toml`, `.git` or `logbook/__init__.py`, and the CLI never picks such a folder as your record.
58
+
59
+ ## Three rules
60
+
61
+ 1. **Append only.** Every line is hash-chained to the one before. A broken chain is an error, never repaired silently.
62
+ 2. **Files first.** No database is required to read, verify or export a logbook. Databases are caches.
63
+ 3. **Nothing leaves.** Personal lines are encrypted with your key. There is no server. Sharing is a page handed to a named person.
64
+
65
+ ## Read next
66
+
67
+ - [VISION.md](VISION.md) — why, and the rules the product refuses to break
68
+ - [LORE.md](LORE.md) — where the logbook comes from: ships, pilots, diaries, and the log in computing
69
+ - [SPEC.md](SPEC.md) — the format, one page; this is the part meant to become a standard
70
+ - [ARCHITECTURE.md](ARCHITECTURE.md) — the five layers and what each may depend on
71
+ - [ROADMAP.md](ROADMAP.md) — what ships when
72
+ - [CONTRIBUTING.md](CONTRIBUTING.md) — the easiest thing to build is an adapter for the export you have
73
+
74
+ ## Status
75
+
76
+ v0.1 — the format, the CLI (init, add, show, verify, export) and the conformance fixture. Engines (days, trips, people) and the circle (sharing) are the next layers; see the roadmap.
77
+
78
+ Apache-2.0 for code. The specification is CC0.
@@ -0,0 +1,9 @@
1
+ logbook/__init__.py,sha256=N87aAwK21etcjCRblv3_U-uDSku-0QeIwEEkkRXi3LE,133
2
+ logbook/chain.py,sha256=HG9-TZ5dqKpYiZU9K86SnnVEn9SNE-AYJZCvpSwCXXo,2074
3
+ logbook/cli.py,sha256=ftFoS-BQ6sOAcV8GhJrJIplsSdgEeu1s9n4BKOeQOBQ,4877
4
+ logbook/store.py,sha256=mW81JE5flwg9UwSznnmfgXTMcXkUkVYHKNjebjDMGyE,5983
5
+ openlogbook-0.1.0.dist-info/METADATA,sha256=_uvLWCVjBuqz_JtxjpMf6Uuk64OoaL6LHZogsNePP3w,3653
6
+ openlogbook-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
7
+ openlogbook-0.1.0.dist-info/entry_points.txt,sha256=ovE9nsrl54dyY_PqB9x0faDRQHy3cG9j9B2vVa4DrMw,45
8
+ openlogbook-0.1.0.dist-info/licenses/LICENSE,sha256=OKQ3H79ngWXufbd6r2VeG-wTYtMvRpRKbgJ61wiG-iY,582
9
+ openlogbook-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ logbook = logbook.cli:main
@@ -0,0 +1,7 @@
1
+ Apache License 2.0 — https://www.apache.org/licenses/LICENSE-2.0
2
+
3
+ Copyright 2026 bighydro
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at the URL above. Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
6
+
7
+ SPEC.md and everything under schema/ and rfcs/ are dedicated to the public domain under CC0 1.0.