checkmarks 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.
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.4
2
+ Name: checkmarks
3
+ Version: 0.2.0
4
+ Summary: Progress dashboard for Markdown checklists
5
+ Author-email: menisadi <menisadi@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2025 Meni
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/menisadi/checkmarks
29
+ Project-URL: Issues, https://github.com/menisadi/checkmarks/issues
30
+ Keywords: cli,markdown,checklists
31
+ Requires-Python: >=3.11
32
+ Description-Content-Type: text/markdown
33
+ License-File: LICENSE
34
+ Dynamic: license-file
35
+
36
+ # Checkmarks
37
+
38
+ A small command-line dashboard for your progress. Point it at a folder of Markdown files (books, courses, tutorials) and get one progress bar per file.
39
+
40
+ ```
41
+ $ checkmarks ~/progress
42
+ Thinking, Fast and Slow [████------] 143/320 pages 45%
43
+ Gödel, Escher, Bach [██--------] 180/777 pages 23%
44
+ Rust tutorial [████------] 12/28 tasks 43%
45
+ NN hands-on [██--------] 2/8 tasks 25%
46
+ ```
47
+
48
+ Your Markdown files are the source of truth: checkmarks only reads and reports. It never changes your files and keeps no state of its own.
49
+
50
+ ## Installation
51
+
52
+ ### Option 1: Install via PyPI (Recommended)
53
+ ```bash
54
+ pip install checkmarks
55
+ ```
56
+
57
+ ### Option 2: Install from source
58
+
59
+ 1. Make sure you have Python 3.11+ installed.
60
+ 2. Clone or download this repository.
61
+ ```bash
62
+ git clone https://github.com/menisadi/checkmarks.git
63
+ cd checkmarks
64
+ pip install .
65
+ ```
66
+
67
+ ## Usage
68
+
69
+ ```bash
70
+ checkmarks todo.md # one file
71
+ checkmarks ~/progress # every .md file in a folder
72
+ checkmarks -r ~/progress # ...and in its subfolders
73
+ checkmarks a.md b.md notes/ # any mix of files and folders
74
+ fd -e md | checkmarks - # read paths from stdin
75
+ checkmarks # $CHECKMARKS_DIR if set, else the current folder
76
+ ```
77
+
78
+ In folders, files ending in `.md` or `.markdown` are read, sorted by path. With `-r`, folders whose names start with `.` (like `.git`) are skipped.
79
+
80
+ ## File format
81
+
82
+ ### Checklists
83
+
84
+ By default, checkmarks counts checkboxes:
85
+
86
+ ```markdown
87
+ # My Todo List
88
+
89
+ - [ ] Buy groceries
90
+ - [x] Write blog post
91
+ - [X] Nested items count too
92
+ 1. [ ] Ordered lists work as well
93
+ ```
94
+
95
+ - `[x]` and `[X]` are done; `[ ]` is pending.
96
+ - List items can start with `-`, `*`, `+`, `1.` or `1)`.
97
+ - Checkboxes inside fenced code blocks are ignored.
98
+
99
+ ### Counters
100
+
101
+ For things that aren't a list of tasks, like the pages of a book, add frontmatter with a `progress` key. It replaces the checkbox count:
102
+
103
+ ```markdown
104
+ ---
105
+ progress: 143/320
106
+ unit: pages
107
+ ---
108
+ # Dune
109
+ ```
110
+
111
+ Supported keys:
112
+
113
+ | Key | Meaning |
114
+ |------------|--------------------------------------------------------------------|
115
+ | `progress` | `done/total`, e.g. `143/320` |
116
+ | `unit` | Label after the count (default `tasks`, or `units` with `progress`) |
117
+ | `title` | Row title |
118
+
119
+ `unit` and `title` also work in checklist files. Other keys are ignored.
120
+
121
+ ### Titles
122
+
123
+ The row title is the frontmatter `title`, else the first `# Heading`, else the filename.
124
+
125
+ ## Output
126
+
127
+ On a terminal, checkmarks prints the aligned table shown above. Rows at 100% are green; set `NO_COLOR` or pass `--no-color` to turn colour off.
128
+
129
+ When output is piped, it switches to tab-separated rows with no header:
130
+
131
+ ```
132
+ title done total pct unit mode status path
133
+ ```
134
+
135
+ Use `--json` for a JSON array:
136
+
137
+ ```bash
138
+ $ checkmarks --json dune.md
139
+ [
140
+ {
141
+ "title": "Dune",
142
+ "done": 143,
143
+ "total": 320,
144
+ "pct": 44.7,
145
+ "unit": "pages",
146
+ "mode": "counter",
147
+ "status": "ok",
148
+ "path": "dune.md"
149
+ }
150
+ ]
151
+ ```
152
+
153
+ `status` is `ok`, `empty` (no checkboxes) or `error` (e.g. an invalid `progress` value). Problems are reported on stderr, and the row is still shown.
154
+
155
+ Empty files found in folders are hidden; pass `--all` to show them. Files you name directly are always shown.
156
+
157
+ ## Options
158
+
159
+ | Option | Description |
160
+ |------------------------------|---------------------------------------------|
161
+ | `-r`, `--recursive` | Descend into subfolders |
162
+ | `--all` | Show empty files found in folders |
163
+ | `--format {pretty,tsv,json}` | Choose the output format |
164
+ | `--json` | Same as `--format json` |
165
+ | `--no-color` | Disable colour |
166
+ | `-v`, `--verbose` | Also warn about files with no checkboxes |
167
+
168
+ Exit code is `0` if at least one file has progress to show, `1` if none does, and `2` for usage errors.
169
+
170
+ ## Upgrading from 0.1
171
+
172
+ - Output is now a table row per file instead of the old "📊 Progress for:" block.
173
+ - Python 3.11 or newer is required.
174
+ - Plain checklist files need no changes.
175
+
176
+ ## Contributing
177
+
178
+ Feel free to submit issues or pull requests. This is a small tool, so contributions are always welcome.
179
+
180
+ ## License
181
+ This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details.
@@ -0,0 +1,146 @@
1
+ # Checkmarks
2
+
3
+ A small command-line dashboard for your progress. Point it at a folder of Markdown files (books, courses, tutorials) and get one progress bar per file.
4
+
5
+ ```
6
+ $ checkmarks ~/progress
7
+ Thinking, Fast and Slow [████------] 143/320 pages 45%
8
+ Gödel, Escher, Bach [██--------] 180/777 pages 23%
9
+ Rust tutorial [████------] 12/28 tasks 43%
10
+ NN hands-on [██--------] 2/8 tasks 25%
11
+ ```
12
+
13
+ Your Markdown files are the source of truth: checkmarks only reads and reports. It never changes your files and keeps no state of its own.
14
+
15
+ ## Installation
16
+
17
+ ### Option 1: Install via PyPI (Recommended)
18
+ ```bash
19
+ pip install checkmarks
20
+ ```
21
+
22
+ ### Option 2: Install from source
23
+
24
+ 1. Make sure you have Python 3.11+ installed.
25
+ 2. Clone or download this repository.
26
+ ```bash
27
+ git clone https://github.com/menisadi/checkmarks.git
28
+ cd checkmarks
29
+ pip install .
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ```bash
35
+ checkmarks todo.md # one file
36
+ checkmarks ~/progress # every .md file in a folder
37
+ checkmarks -r ~/progress # ...and in its subfolders
38
+ checkmarks a.md b.md notes/ # any mix of files and folders
39
+ fd -e md | checkmarks - # read paths from stdin
40
+ checkmarks # $CHECKMARKS_DIR if set, else the current folder
41
+ ```
42
+
43
+ In folders, files ending in `.md` or `.markdown` are read, sorted by path. With `-r`, folders whose names start with `.` (like `.git`) are skipped.
44
+
45
+ ## File format
46
+
47
+ ### Checklists
48
+
49
+ By default, checkmarks counts checkboxes:
50
+
51
+ ```markdown
52
+ # My Todo List
53
+
54
+ - [ ] Buy groceries
55
+ - [x] Write blog post
56
+ - [X] Nested items count too
57
+ 1. [ ] Ordered lists work as well
58
+ ```
59
+
60
+ - `[x]` and `[X]` are done; `[ ]` is pending.
61
+ - List items can start with `-`, `*`, `+`, `1.` or `1)`.
62
+ - Checkboxes inside fenced code blocks are ignored.
63
+
64
+ ### Counters
65
+
66
+ For things that aren't a list of tasks, like the pages of a book, add frontmatter with a `progress` key. It replaces the checkbox count:
67
+
68
+ ```markdown
69
+ ---
70
+ progress: 143/320
71
+ unit: pages
72
+ ---
73
+ # Dune
74
+ ```
75
+
76
+ Supported keys:
77
+
78
+ | Key | Meaning |
79
+ |------------|--------------------------------------------------------------------|
80
+ | `progress` | `done/total`, e.g. `143/320` |
81
+ | `unit` | Label after the count (default `tasks`, or `units` with `progress`) |
82
+ | `title` | Row title |
83
+
84
+ `unit` and `title` also work in checklist files. Other keys are ignored.
85
+
86
+ ### Titles
87
+
88
+ The row title is the frontmatter `title`, else the first `# Heading`, else the filename.
89
+
90
+ ## Output
91
+
92
+ On a terminal, checkmarks prints the aligned table shown above. Rows at 100% are green; set `NO_COLOR` or pass `--no-color` to turn colour off.
93
+
94
+ When output is piped, it switches to tab-separated rows with no header:
95
+
96
+ ```
97
+ title done total pct unit mode status path
98
+ ```
99
+
100
+ Use `--json` for a JSON array:
101
+
102
+ ```bash
103
+ $ checkmarks --json dune.md
104
+ [
105
+ {
106
+ "title": "Dune",
107
+ "done": 143,
108
+ "total": 320,
109
+ "pct": 44.7,
110
+ "unit": "pages",
111
+ "mode": "counter",
112
+ "status": "ok",
113
+ "path": "dune.md"
114
+ }
115
+ ]
116
+ ```
117
+
118
+ `status` is `ok`, `empty` (no checkboxes) or `error` (e.g. an invalid `progress` value). Problems are reported on stderr, and the row is still shown.
119
+
120
+ Empty files found in folders are hidden; pass `--all` to show them. Files you name directly are always shown.
121
+
122
+ ## Options
123
+
124
+ | Option | Description |
125
+ |------------------------------|---------------------------------------------|
126
+ | `-r`, `--recursive` | Descend into subfolders |
127
+ | `--all` | Show empty files found in folders |
128
+ | `--format {pretty,tsv,json}` | Choose the output format |
129
+ | `--json` | Same as `--format json` |
130
+ | `--no-color` | Disable colour |
131
+ | `-v`, `--verbose` | Also warn about files with no checkboxes |
132
+
133
+ Exit code is `0` if at least one file has progress to show, `1` if none does, and `2` for usage errors.
134
+
135
+ ## Upgrading from 0.1
136
+
137
+ - Output is now a table row per file instead of the old "📊 Progress for:" block.
138
+ - Python 3.11 or newer is required.
139
+ - Plain checklist files need no changes.
140
+
141
+ ## Contributing
142
+
143
+ Feel free to submit issues or pull requests. This is a small tool, so contributions are always welcome.
144
+
145
+ ## License
146
+ This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details.
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "checkmarks"
3
+ version = "0.2.0"
4
+ description = "Progress dashboard for Markdown checklists"
5
+ authors = [
6
+ { name = "menisadi", email = "menisadi@gmail.com" }
7
+ ]
8
+
9
+ readme = "README.md"
10
+
11
+ license = { file = "LICENSE" }
12
+
13
+ keywords = ["cli", "markdown", "checklists"]
14
+
15
+ requires-python = ">=3.11"
16
+
17
+ dependencies = []
18
+
19
+ [project.scripts]
20
+ checkmarks = "checkmarks.cli:main"
21
+
22
+ [project.urls]
23
+ "Homepage" = "https://github.com/menisadi/checkmarks"
24
+ "Issues" = "https://github.com/menisadi/checkmarks/issues"
25
+
26
+ [dependency-groups]
27
+ dev = ["pytest", "ruff", "ty"]
28
+
29
+ [build-system]
30
+ requires = ["setuptools>=61"]
31
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("checkmarks")
@@ -0,0 +1,85 @@
1
+ import argparse
2
+ import os
3
+ import shutil
4
+ import sys
5
+
6
+ from checkmarks.collect import collect
7
+ from checkmarks.parse import Item, parse_file
8
+ from checkmarks.render import render_json, render_pretty, render_tsv
9
+
10
+
11
+ def build_parser() -> argparse.ArgumentParser:
12
+ parser = argparse.ArgumentParser(
13
+ prog="checkmarks", description="Show progress for Markdown checklists"
14
+ )
15
+ parser.add_argument(
16
+ "paths",
17
+ nargs="*",
18
+ metavar="PATH",
19
+ help="files or directories to read; - reads paths from stdin "
20
+ "(default: $CHECKMARKS_DIR, else the current directory)",
21
+ )
22
+ parser.add_argument(
23
+ "-r", "--recursive", action="store_true", help="descend into subdirectories"
24
+ )
25
+ parser.add_argument(
26
+ "--all", action="store_true", help="show empty files found in directories"
27
+ )
28
+ fmt = parser.add_mutually_exclusive_group()
29
+ fmt.add_argument(
30
+ "--format",
31
+ choices=["pretty", "tsv", "json"],
32
+ help="output format (default: pretty on a terminal, else tsv)",
33
+ )
34
+ fmt.add_argument(
35
+ "--json",
36
+ action="store_const",
37
+ const="json",
38
+ dest="format",
39
+ help="same as --format json",
40
+ )
41
+ parser.add_argument("--no-color", action="store_true", help="disable colour")
42
+ parser.add_argument(
43
+ "-v", "--verbose", action="store_true", help="warn about empty files"
44
+ )
45
+ return parser
46
+
47
+
48
+ def warn(message: str) -> None:
49
+ print(f"checkmarks: {message}", file=sys.stderr)
50
+
51
+
52
+ def main(argv: list[str] | None = None) -> int:
53
+ """Main entry point for the CLI. Returns the exit code."""
54
+ args = build_parser().parse_args(argv)
55
+
56
+ sources, warnings = collect(args.paths, args.recursive, sys.stdin, os.environ)
57
+ for warning in warnings:
58
+ warn(warning)
59
+
60
+ items: list[Item] = []
61
+ for source in sources:
62
+ item = parse_file(source.path)
63
+ if item.status == "error" or (item.status == "empty" and args.verbose):
64
+ warn(f"{item.path}: {item.message}")
65
+ if item.status == "empty" and not source.explicit and not args.all:
66
+ continue
67
+ items.append(item)
68
+
69
+ is_tty = sys.stdout.isatty()
70
+ fmt = args.format or ("pretty" if is_tty else "tsv")
71
+ if fmt == "json":
72
+ output = render_json(items)
73
+ elif fmt == "tsv":
74
+ output = render_tsv(items)
75
+ else:
76
+ color = is_tty and not args.no_color and not os.environ.get("NO_COLOR")
77
+ width = shutil.get_terminal_size().columns
78
+ output = render_pretty(items, width, color)
79
+ sys.stdout.write(output)
80
+
81
+ return 0 if any(item.status == "ok" for item in items) else 1
82
+
83
+
84
+ if __name__ == "__main__":
85
+ raise SystemExit(main())
@@ -0,0 +1,74 @@
1
+ import os
2
+ from collections.abc import Iterator, Mapping
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import TextIO
6
+
7
+ MARKDOWN_SUFFIXES = {".md", ".markdown"}
8
+
9
+
10
+ @dataclass
11
+ class Source:
12
+ path: Path
13
+ explicit: bool
14
+
15
+
16
+ def expand_dir(directory: Path, recursive: bool) -> list[Path]:
17
+ """Returns the Markdown files in a directory, sorted by path."""
18
+ found: list[Path] = []
19
+ for root, dirnames, filenames in os.walk(directory):
20
+ if recursive:
21
+ dirnames[:] = [d for d in dirnames if not d.startswith(".")]
22
+ else:
23
+ dirnames[:] = []
24
+ root_path = Path(root)
25
+ found.extend(
26
+ root_path / name
27
+ for name in filenames
28
+ if Path(name).suffix.lower() in MARKDOWN_SUFFIXES
29
+ )
30
+ return sorted(found)
31
+
32
+
33
+ def iter_args(paths: list[str], stdin: TextIO, env: Mapping[str, str]) -> Iterator[str]:
34
+ """Yields path arguments, expanding `-` and applying the no-argument default."""
35
+ if not paths:
36
+ yield env.get("CHECKMARKS_DIR") or "."
37
+ return
38
+
39
+ for arg in paths:
40
+ if arg == "-":
41
+ yield from (line.strip() for line in stdin if line.strip())
42
+ else:
43
+ yield arg
44
+
45
+
46
+ def collect(
47
+ paths: list[str], recursive: bool, stdin: TextIO, env: Mapping[str, str]
48
+ ) -> tuple[list[Source], list[str]]:
49
+ """Resolves path arguments into sources, plus warnings for bad paths."""
50
+ sources: list[Source] = []
51
+ warnings: list[str] = []
52
+ seen: dict[Path, Source] = {}
53
+
54
+ def add(path: Path, explicit: bool) -> None:
55
+ key = path.resolve()
56
+ if key in seen:
57
+ # The first position wins, but an explicit mention always counts
58
+ seen[key].explicit = seen[key].explicit or explicit
59
+ return
60
+ source = Source(path, explicit)
61
+ seen[key] = source
62
+ sources.append(source)
63
+
64
+ for arg in iter_args(paths, stdin, env):
65
+ path = Path(arg)
66
+ if path.is_dir():
67
+ for file in expand_dir(path, recursive):
68
+ add(file, explicit=False)
69
+ elif path.exists():
70
+ add(path, explicit=True)
71
+ else:
72
+ warnings.append(f"{arg}: no such file or directory")
73
+
74
+ return sources, warnings
@@ -0,0 +1,141 @@
1
+ import re
2
+ from collections.abc import Iterable, Iterator
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ CHECKBOX_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+\[( |x|X)\](?:\s|$)")
8
+ FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})")
9
+ HEADING_RE = re.compile(r"^#\s+(.+)")
10
+ PROGRESS_RE = re.compile(r"^(\d+)\s*/\s*(\d+)$")
11
+
12
+
13
+ @dataclass
14
+ class Item:
15
+ path: Path
16
+ title: str
17
+ done: int | None
18
+ total: int | None
19
+ unit: str
20
+ mode: Literal["checklist", "counter"]
21
+ status: Literal["ok", "empty", "error"]
22
+ message: str | None = None
23
+
24
+
25
+ def unfenced_lines(lines: Iterable[str]) -> Iterator[str]:
26
+ """Yields the lines that are outside fenced code blocks."""
27
+ fence: str | None = None
28
+
29
+ for line in lines:
30
+ if fence is not None:
31
+ # A fence closes on the same character, at least as long as the opener
32
+ if re.fullmatch(rf"\s*{re.escape(fence[0])}{{{len(fence)},}}\s*", line):
33
+ fence = None
34
+ continue
35
+
36
+ fence_match = FENCE_RE.match(line)
37
+ if fence_match:
38
+ fence = fence_match.group(1)
39
+ continue
40
+
41
+ yield line
42
+
43
+
44
+ def count_checkboxes(text: str) -> tuple[int, int]:
45
+ """Returns (done, total) checkbox counts, ignoring fenced code blocks."""
46
+ done = 0
47
+ total = 0
48
+
49
+ for line in unfenced_lines(text.splitlines()):
50
+ box_match = CHECKBOX_RE.match(line)
51
+ if box_match:
52
+ total += 1
53
+ if box_match.group(1) in ("x", "X"):
54
+ done += 1
55
+
56
+ return done, total
57
+
58
+
59
+ def find_heading(lines: Iterable[str]) -> str | None:
60
+ """Returns the first level-1 heading outside fenced code blocks."""
61
+ for line in unfenced_lines(lines):
62
+ heading_match = HEADING_RE.match(line)
63
+ if heading_match:
64
+ return heading_match.group(1).strip()
65
+ return None
66
+
67
+
68
+ def parse_frontmatter(lines: list[str]) -> tuple[dict[str, str], int]:
69
+ """Returns (frontmatter keys, index of the first body line)."""
70
+ if not lines or lines[0] != "---":
71
+ return {}, 0
72
+
73
+ try:
74
+ end = lines.index("---", 1)
75
+ except ValueError:
76
+ # No closing delimiter: the file has no frontmatter
77
+ return {}, 0
78
+
79
+ meta: dict[str, str] = {}
80
+ for line in lines[1:end]:
81
+ key, sep, value = line.partition(":")
82
+ if not sep:
83
+ continue
84
+ value = value.strip()
85
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
86
+ value = value[1:-1]
87
+ meta[key.strip()] = value
88
+
89
+ return meta, end + 1
90
+
91
+
92
+ def parse_progress(value: str) -> tuple[int, int] | str:
93
+ """Returns (done, total), or an error message if the value is invalid."""
94
+ progress_match = PROGRESS_RE.match(value)
95
+ if not progress_match:
96
+ return f"unparseable progress: {value!r}"
97
+
98
+ done, total = int(progress_match.group(1)), int(progress_match.group(2))
99
+ if total == 0:
100
+ return "progress total is 0"
101
+ if done > total:
102
+ return f"progress {done}/{total} exceeds total"
103
+ return done, total
104
+
105
+
106
+ def parse_file(path: Path) -> Item:
107
+ """Parses a Markdown file into an Item."""
108
+ try:
109
+ text = path.read_text(encoding="utf-8-sig")
110
+ except (OSError, UnicodeDecodeError) as e:
111
+ return Item(
112
+ path=path,
113
+ title=path.stem,
114
+ done=None,
115
+ total=None,
116
+ unit="tasks",
117
+ mode="checklist",
118
+ status="error",
119
+ message=str(e),
120
+ )
121
+
122
+ lines = text.splitlines()
123
+ meta, body_start = parse_frontmatter(lines)
124
+ body = lines[body_start:]
125
+ title = meta.get("title") or find_heading(body) or path.stem
126
+
127
+ if "progress" in meta:
128
+ unit = meta.get("unit") or "units"
129
+ progress = parse_progress(meta["progress"])
130
+ if isinstance(progress, str):
131
+ return Item(path, title, None, None, unit, "counter", "error", progress)
132
+ done, total = progress
133
+ return Item(path, title, done, total, unit, "counter", "ok")
134
+
135
+ unit = meta.get("unit") or "tasks"
136
+ done, total = count_checkboxes("\n".join(body))
137
+ if total == 0:
138
+ return Item(
139
+ path, title, None, None, unit, "checklist", "empty", "no checkboxes found"
140
+ )
141
+ return Item(path, title, done, total, unit, "checklist", "ok")