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.
- checkmarks-0.2.0/PKG-INFO +181 -0
- checkmarks-0.2.0/README.md +146 -0
- checkmarks-0.2.0/pyproject.toml +31 -0
- checkmarks-0.2.0/src/checkmarks/__init__.py +3 -0
- checkmarks-0.2.0/src/checkmarks/cli.py +85 -0
- checkmarks-0.2.0/src/checkmarks/collect.py +74 -0
- checkmarks-0.2.0/src/checkmarks/parse.py +141 -0
- checkmarks-0.2.0/src/checkmarks/render.py +144 -0
- checkmarks-0.2.0/src/checkmarks.egg-info/PKG-INFO +181 -0
- checkmarks-0.2.0/src/checkmarks.egg-info/SOURCES.txt +17 -0
- checkmarks-0.2.0/tests/test_cli.py +140 -0
- checkmarks-0.2.0/tests/test_collect.py +130 -0
- checkmarks-0.2.0/tests/test_parse.py +230 -0
- checkmarks-0.2.0/tests/test_render.py +188 -0
- checkmarks-0.1.0/PKG-INFO +0 -79
- checkmarks-0.1.0/README.md +0 -47
- checkmarks-0.1.0/checkmarks/__init__.py +0 -0
- checkmarks-0.1.0/checkmarks/cli.py +0 -58
- checkmarks-0.1.0/checkmarks.egg-info/PKG-INFO +0 -79
- checkmarks-0.1.0/checkmarks.egg-info/SOURCES.txt +0 -11
- checkmarks-0.1.0/pyproject.toml +0 -18
- checkmarks-0.1.0/tests/test_cli.py +0 -107
- {checkmarks-0.1.0 → checkmarks-0.2.0}/LICENSE +0 -0
- {checkmarks-0.1.0 → checkmarks-0.2.0}/setup.cfg +0 -0
- {checkmarks-0.1.0 → checkmarks-0.2.0/src}/checkmarks.egg-info/dependency_links.txt +0 -0
- {checkmarks-0.1.0 → checkmarks-0.2.0/src}/checkmarks.egg-info/entry_points.txt +0 -0
- {checkmarks-0.1.0 → checkmarks-0.2.0/src}/checkmarks.egg-info/top_level.txt +0 -0
|
@@ -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,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")
|