todoosy 0.1.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.
- todoosy-0.1.0/PKG-INFO +76 -0
- todoosy-0.1.0/README.md +58 -0
- todoosy-0.1.0/pyproject.toml +33 -0
- todoosy-0.1.0/setup.cfg +4 -0
- todoosy-0.1.0/tests/__init__.py +1 -0
- todoosy-0.1.0/tests/test_golden.py +226 -0
- todoosy-0.1.0/todoosy/__init__.py +40 -0
- todoosy-0.1.0/todoosy/formatter.py +136 -0
- todoosy-0.1.0/todoosy/linter.py +170 -0
- todoosy-0.1.0/todoosy/parser.py +366 -0
- todoosy-0.1.0/todoosy/query.py +112 -0
- todoosy-0.1.0/todoosy/scheme.py +60 -0
- todoosy-0.1.0/todoosy/types.py +85 -0
- todoosy-0.1.0/todoosy.egg-info/PKG-INFO +76 -0
- todoosy-0.1.0/todoosy.egg-info/SOURCES.txt +16 -0
- todoosy-0.1.0/todoosy.egg-info/dependency_links.txt +1 -0
- todoosy-0.1.0/todoosy.egg-info/requires.txt +4 -0
- todoosy-0.1.0/todoosy.egg-info/top_level.txt +3 -0
todoosy-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: todoosy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Todoosy - Markdown-based todo system parser, formatter, linter, and query engine
|
|
5
|
+
License: MIT
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
18
|
+
|
|
19
|
+
# Todoosy Python Library
|
|
20
|
+
|
|
21
|
+
Python implementation of the Todoosy format parser, formatter, linter, and query engine.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
For development:
|
|
30
|
+
```bash
|
|
31
|
+
pip install -e ".[dev]"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from todoosy import parse, format, lint, query_upcoming, query_misc, parse_scheme
|
|
38
|
+
|
|
39
|
+
# Parse a document
|
|
40
|
+
result = parse('''
|
|
41
|
+
# Work
|
|
42
|
+
|
|
43
|
+
- Task (due 2026-01-15 p1 2h)
|
|
44
|
+
|
|
45
|
+
# Misc
|
|
46
|
+
''')
|
|
47
|
+
|
|
48
|
+
# Access the AST
|
|
49
|
+
for item in result.ast.items:
|
|
50
|
+
print(f"{item.title_text}: due={item.metadata.due}")
|
|
51
|
+
|
|
52
|
+
# Format a document
|
|
53
|
+
formatted = format(input_text)
|
|
54
|
+
|
|
55
|
+
# Lint a document
|
|
56
|
+
warnings = lint(input_text)
|
|
57
|
+
for w in warnings.warnings:
|
|
58
|
+
print(f"{w.code}: {w.message}")
|
|
59
|
+
|
|
60
|
+
# Query upcoming items
|
|
61
|
+
scheme = parse_scheme(scheme_text)
|
|
62
|
+
upcoming = query_upcoming(input_text, scheme)
|
|
63
|
+
for item in upcoming.items:
|
|
64
|
+
print(f"{item.path}: {item.due}")
|
|
65
|
+
|
|
66
|
+
# Query misc items
|
|
67
|
+
misc = query_misc(input_text)
|
|
68
|
+
for item in misc.items:
|
|
69
|
+
print(item.title_text)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Running Tests
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pytest
|
|
76
|
+
```
|
todoosy-0.1.0/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Todoosy Python Library
|
|
2
|
+
|
|
3
|
+
Python implementation of the Todoosy format parser, formatter, linter, and query engine.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install -e .
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
For development:
|
|
12
|
+
```bash
|
|
13
|
+
pip install -e ".[dev]"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from todoosy import parse, format, lint, query_upcoming, query_misc, parse_scheme
|
|
20
|
+
|
|
21
|
+
# Parse a document
|
|
22
|
+
result = parse('''
|
|
23
|
+
# Work
|
|
24
|
+
|
|
25
|
+
- Task (due 2026-01-15 p1 2h)
|
|
26
|
+
|
|
27
|
+
# Misc
|
|
28
|
+
''')
|
|
29
|
+
|
|
30
|
+
# Access the AST
|
|
31
|
+
for item in result.ast.items:
|
|
32
|
+
print(f"{item.title_text}: due={item.metadata.due}")
|
|
33
|
+
|
|
34
|
+
# Format a document
|
|
35
|
+
formatted = format(input_text)
|
|
36
|
+
|
|
37
|
+
# Lint a document
|
|
38
|
+
warnings = lint(input_text)
|
|
39
|
+
for w in warnings.warnings:
|
|
40
|
+
print(f"{w.code}: {w.message}")
|
|
41
|
+
|
|
42
|
+
# Query upcoming items
|
|
43
|
+
scheme = parse_scheme(scheme_text)
|
|
44
|
+
upcoming = query_upcoming(input_text, scheme)
|
|
45
|
+
for item in upcoming.items:
|
|
46
|
+
print(f"{item.path}: {item.due}")
|
|
47
|
+
|
|
48
|
+
# Query misc items
|
|
49
|
+
misc = query_misc(input_text)
|
|
50
|
+
for item in misc.items:
|
|
51
|
+
print(item.title_text)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Running Tests
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pytest
|
|
58
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "todoosy"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Todoosy - Markdown-based todo system parser, formatter, linter, and query engine"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = [
|
|
24
|
+
"pytest>=7.0.0",
|
|
25
|
+
"pytest-cov>=4.0.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["."]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
python_files = ["test_*.py"]
|
todoosy-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Tests package
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Todoosy Golden Tests - Using shared test files
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from todoosy import (
|
|
12
|
+
parse,
|
|
13
|
+
format,
|
|
14
|
+
lint,
|
|
15
|
+
query_upcoming,
|
|
16
|
+
query_misc,
|
|
17
|
+
parse_scheme,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Get test data directory
|
|
21
|
+
TEST_DIR = Path(__file__).parent.parent.parent / 'testdata'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_test_cases():
|
|
25
|
+
"""Get all test case directories."""
|
|
26
|
+
if not TEST_DIR.exists():
|
|
27
|
+
return []
|
|
28
|
+
return sorted([d.name for d in TEST_DIR.iterdir() if d.is_dir()])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_file(test_case: str, filename: str) -> str | None:
|
|
32
|
+
"""Load a file from a test case directory."""
|
|
33
|
+
file_path = TEST_DIR / test_case / filename
|
|
34
|
+
if file_path.exists():
|
|
35
|
+
return file_path.read_text()
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_json(test_case: str, filename: str):
|
|
40
|
+
"""Load a JSON file from a test case directory."""
|
|
41
|
+
content = load_file(test_case, filename)
|
|
42
|
+
if content:
|
|
43
|
+
return json.loads(content)
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestParser:
|
|
48
|
+
@pytest.mark.parametrize('test_case', get_test_cases())
|
|
49
|
+
def test_parse(self, test_case):
|
|
50
|
+
input_md = load_file(test_case, 'input.md')
|
|
51
|
+
expected_ast = load_json(test_case, 'expected_ast.json')
|
|
52
|
+
|
|
53
|
+
if not input_md or not expected_ast:
|
|
54
|
+
pytest.skip(f'Missing input or expected_ast for {test_case}')
|
|
55
|
+
|
|
56
|
+
result = parse(input_md)
|
|
57
|
+
ast = result.ast
|
|
58
|
+
|
|
59
|
+
# Compare items count
|
|
60
|
+
assert len(ast.items) == len(expected_ast['items'])
|
|
61
|
+
|
|
62
|
+
# Compare each item's essential properties
|
|
63
|
+
for i, (actual, expected) in enumerate(zip(ast.items, expected_ast['items'])):
|
|
64
|
+
assert actual.type == expected['type'], f"Item {i} type mismatch"
|
|
65
|
+
assert actual.title_text == expected['title_text'], f"Item {i} title_text mismatch"
|
|
66
|
+
assert actual.metadata.due == expected['metadata']['due'], f"Item {i} due mismatch"
|
|
67
|
+
assert actual.metadata.priority == expected['metadata']['priority'], f"Item {i} priority mismatch"
|
|
68
|
+
assert actual.metadata.estimate_minutes == expected['metadata']['estimate_minutes'], f"Item {i} estimate mismatch"
|
|
69
|
+
assert actual.comments == expected['comments'], f"Item {i} comments mismatch"
|
|
70
|
+
assert len(actual.children) == len(expected['children']), f"Item {i} children count mismatch"
|
|
71
|
+
|
|
72
|
+
# Compare root_ids count
|
|
73
|
+
assert len(ast.root_ids) == len(expected_ast['root_ids'])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestFormatter:
|
|
77
|
+
@pytest.mark.parametrize('test_case', get_test_cases())
|
|
78
|
+
def test_format(self, test_case):
|
|
79
|
+
input_md = load_file(test_case, 'input.md')
|
|
80
|
+
expected_formatted = load_file(test_case, 'expected_formatted.md')
|
|
81
|
+
|
|
82
|
+
if not input_md or not expected_formatted:
|
|
83
|
+
pytest.skip(f'Missing input or expected_formatted for {test_case}')
|
|
84
|
+
|
|
85
|
+
formatted = format(input_md)
|
|
86
|
+
assert formatted == expected_formatted
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TestLinter:
|
|
90
|
+
@pytest.mark.parametrize('test_case', get_test_cases())
|
|
91
|
+
def test_lint(self, test_case):
|
|
92
|
+
input_md = load_file(test_case, 'input.md')
|
|
93
|
+
expected_warnings = load_json(test_case, 'expected_warnings.json')
|
|
94
|
+
scheme_text = load_file(test_case, 'scheme.md')
|
|
95
|
+
scheme = parse_scheme(scheme_text) if scheme_text else None
|
|
96
|
+
|
|
97
|
+
if not input_md or not expected_warnings:
|
|
98
|
+
pytest.skip(f'Missing input or expected_warnings for {test_case}')
|
|
99
|
+
|
|
100
|
+
result = lint(input_md, scheme)
|
|
101
|
+
|
|
102
|
+
actual_codes = sorted([w.code for w in result.warnings])
|
|
103
|
+
expected_codes = sorted([w['code'] for w in expected_warnings.get('warnings', [])])
|
|
104
|
+
assert actual_codes == expected_codes
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class TestQueryUpcoming:
|
|
108
|
+
@pytest.mark.parametrize('test_case', get_test_cases())
|
|
109
|
+
def test_query_upcoming(self, test_case):
|
|
110
|
+
input_md = load_file(test_case, 'input.md')
|
|
111
|
+
expected_upcoming = load_json(test_case, 'expected_upcoming.json')
|
|
112
|
+
scheme_text = load_file(test_case, 'scheme.md')
|
|
113
|
+
scheme = parse_scheme(scheme_text) if scheme_text else None
|
|
114
|
+
|
|
115
|
+
if not input_md or not expected_upcoming:
|
|
116
|
+
pytest.skip(f'Missing input or expected_upcoming for {test_case}')
|
|
117
|
+
|
|
118
|
+
result = query_upcoming(input_md, scheme)
|
|
119
|
+
|
|
120
|
+
assert len(result.items) == len(expected_upcoming['items'])
|
|
121
|
+
|
|
122
|
+
for actual, expected in zip(result.items, expected_upcoming['items']):
|
|
123
|
+
assert actual.due == expected['due']
|
|
124
|
+
assert actual.priority == expected['priority']
|
|
125
|
+
if 'priority_label' in expected:
|
|
126
|
+
assert actual.priority_label == expected['priority_label']
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TestQueryMisc:
|
|
130
|
+
@pytest.mark.parametrize('test_case', get_test_cases())
|
|
131
|
+
def test_query_misc(self, test_case):
|
|
132
|
+
input_md = load_file(test_case, 'input.md')
|
|
133
|
+
expected_misc = load_json(test_case, 'expected_misc.json')
|
|
134
|
+
|
|
135
|
+
if not input_md or not expected_misc:
|
|
136
|
+
pytest.skip(f'Missing input or expected_misc for {test_case}')
|
|
137
|
+
|
|
138
|
+
result = query_misc(input_md)
|
|
139
|
+
|
|
140
|
+
assert len(result.items) == len(expected_misc['items'])
|
|
141
|
+
|
|
142
|
+
for actual, expected in zip(result.items, expected_misc['items']):
|
|
143
|
+
assert actual.title_text == expected['title_text']
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TestSchemeParser:
|
|
147
|
+
@pytest.mark.parametrize('test_case', get_test_cases())
|
|
148
|
+
def test_parse_scheme(self, test_case):
|
|
149
|
+
scheme_text = load_file(test_case, 'scheme.md')
|
|
150
|
+
expected_scheme = load_json(test_case, 'expected_scheme.json')
|
|
151
|
+
|
|
152
|
+
if not scheme_text or not expected_scheme:
|
|
153
|
+
return # Scheme is optional
|
|
154
|
+
|
|
155
|
+
scheme = parse_scheme(scheme_text)
|
|
156
|
+
|
|
157
|
+
assert scheme.timezone == expected_scheme['timezone']
|
|
158
|
+
assert scheme.priorities == expected_scheme['priorities']
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TestEdgeCases:
|
|
162
|
+
def test_empty_document(self):
|
|
163
|
+
result = parse('')
|
|
164
|
+
assert len(result.ast.items) == 0
|
|
165
|
+
assert len(result.ast.root_ids) == 0
|
|
166
|
+
|
|
167
|
+
def test_whitespace_only(self):
|
|
168
|
+
result = parse(' \n\n ')
|
|
169
|
+
assert len(result.ast.items) == 0
|
|
170
|
+
|
|
171
|
+
def test_numbered_lists(self):
|
|
172
|
+
result = parse('# Tasks\n\n1. First task\n2. Second task')
|
|
173
|
+
assert len(result.ast.items) == 3
|
|
174
|
+
assert result.ast.items[1].title_text == 'First task'
|
|
175
|
+
assert result.ast.items[2].title_text == 'Second task'
|
|
176
|
+
|
|
177
|
+
def test_asterisk_lists(self):
|
|
178
|
+
result = parse('# Tasks\n\n* Task one\n* Task two')
|
|
179
|
+
assert len(result.ast.items) == 3
|
|
180
|
+
assert result.ast.items[1].title_text == 'Task one'
|
|
181
|
+
|
|
182
|
+
def test_2digit_year_dates(self):
|
|
183
|
+
result = parse('- Task (due 01/15/26)')
|
|
184
|
+
assert result.ast.items[0].metadata.due == '2026-01-15'
|
|
185
|
+
|
|
186
|
+
def test_estimate_days(self):
|
|
187
|
+
result = parse('- Task (2d)')
|
|
188
|
+
assert result.ast.items[0].metadata.estimate_minutes == 960
|
|
189
|
+
|
|
190
|
+
def test_formatter_adds_misc(self):
|
|
191
|
+
formatted = format('# Work\n\n- Task')
|
|
192
|
+
assert '# Misc' in formatted
|
|
193
|
+
|
|
194
|
+
def test_formatter_preserves_non_metadata_parens(self):
|
|
195
|
+
formatted = format('# Work\n\n- Call John (CEO)\n\n# Misc\n')
|
|
196
|
+
assert '(CEO)' in formatted
|
|
197
|
+
|
|
198
|
+
def test_linter_warns_missing_misc(self):
|
|
199
|
+
result = lint('# Work\n\n- Task')
|
|
200
|
+
assert any(w.code == 'MISC_MISSING' for w in result.warnings)
|
|
201
|
+
|
|
202
|
+
def test_linter_no_warnings_valid_doc(self):
|
|
203
|
+
result = lint('# Work\n\n- Task (due 2026-01-15 p1 2h)\n\n# Misc\n')
|
|
204
|
+
assert len(result.warnings) == 0
|
|
205
|
+
|
|
206
|
+
def test_scheme_empty(self):
|
|
207
|
+
scheme = parse_scheme('')
|
|
208
|
+
assert scheme.timezone is None
|
|
209
|
+
assert scheme.priorities == {}
|
|
210
|
+
|
|
211
|
+
def test_scheme_timezone_only(self):
|
|
212
|
+
scheme = parse_scheme('# Timezone\n\nEurope/London')
|
|
213
|
+
assert scheme.timezone == 'Europe/London'
|
|
214
|
+
assert scheme.priorities == {}
|
|
215
|
+
|
|
216
|
+
def test_scheme_various_bullets(self):
|
|
217
|
+
scheme = parse_scheme('''
|
|
218
|
+
# Priorities
|
|
219
|
+
|
|
220
|
+
- P0 - Critical
|
|
221
|
+
* P1 - High
|
|
222
|
+
P2 - Medium
|
|
223
|
+
''')
|
|
224
|
+
assert scheme.priorities['0'] == 'Critical'
|
|
225
|
+
assert scheme.priorities['1'] == 'High'
|
|
226
|
+
assert scheme.priorities['2'] == 'Medium'
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Todoosy - Markdown-based todo system
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .parser import parse, ParseResult
|
|
6
|
+
from .formatter import format
|
|
7
|
+
from .linter import lint, LintResult
|
|
8
|
+
from .query import query_upcoming, query_misc, UpcomingResult, MiscResult
|
|
9
|
+
from .scheme import parse_scheme
|
|
10
|
+
from .types import (
|
|
11
|
+
AST,
|
|
12
|
+
ItemNode,
|
|
13
|
+
ItemMetadata,
|
|
14
|
+
Warning,
|
|
15
|
+
UpcomingItem,
|
|
16
|
+
MiscItem,
|
|
17
|
+
Scheme,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
'parse',
|
|
22
|
+
'ParseResult',
|
|
23
|
+
'format',
|
|
24
|
+
'lint',
|
|
25
|
+
'LintResult',
|
|
26
|
+
'query_upcoming',
|
|
27
|
+
'query_misc',
|
|
28
|
+
'UpcomingResult',
|
|
29
|
+
'MiscResult',
|
|
30
|
+
'parse_scheme',
|
|
31
|
+
'AST',
|
|
32
|
+
'ItemNode',
|
|
33
|
+
'ItemMetadata',
|
|
34
|
+
'Warning',
|
|
35
|
+
'UpcomingItem',
|
|
36
|
+
'MiscItem',
|
|
37
|
+
'Scheme',
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
__version__ = '0.1.0'
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Todoosy Formatter
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .parser import parse
|
|
6
|
+
from .types import ItemNode, ItemMetadata
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def format_metadata(metadata: ItemMetadata) -> str:
|
|
10
|
+
"""Format metadata as a canonical string."""
|
|
11
|
+
parts: list[str] = []
|
|
12
|
+
|
|
13
|
+
if metadata.due:
|
|
14
|
+
parts.append(f"due {metadata.due}")
|
|
15
|
+
|
|
16
|
+
if metadata.priority is not None:
|
|
17
|
+
parts.append(f"p{metadata.priority}")
|
|
18
|
+
|
|
19
|
+
if metadata.estimate_minutes is not None:
|
|
20
|
+
minutes = metadata.estimate_minutes
|
|
21
|
+
if minutes % 480 == 0 and minutes >= 480:
|
|
22
|
+
parts.append(f"{minutes // 480}d")
|
|
23
|
+
elif minutes % 60 == 0 and minutes >= 60:
|
|
24
|
+
parts.append(f"{minutes // 60}h")
|
|
25
|
+
else:
|
|
26
|
+
parts.append(f"{minutes}m")
|
|
27
|
+
|
|
28
|
+
return f"({' '.join(parts)})" if parts else ''
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def format_item_line(item: ItemNode, indent: int = 0) -> str:
|
|
32
|
+
"""Format a single item line."""
|
|
33
|
+
indent_str = ' ' * indent
|
|
34
|
+
meta_str = format_metadata(item.metadata)
|
|
35
|
+
title_with_meta = f"{item.title_text} {meta_str}" if meta_str else item.title_text
|
|
36
|
+
|
|
37
|
+
if item.type == 'heading':
|
|
38
|
+
hashes = '#' * (item.level or 1)
|
|
39
|
+
return f"{hashes} {title_with_meta}"
|
|
40
|
+
|
|
41
|
+
return f"{indent_str}- {title_with_meta}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def format_comments(comments: list[str], is_list_item: bool, indent: int) -> list[str]:
|
|
45
|
+
"""Format comments with proper indentation."""
|
|
46
|
+
if not comments:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
if is_list_item:
|
|
50
|
+
indent_str = ' ' * (indent + 1)
|
|
51
|
+
return [f"{indent_str}{c}" for c in comments]
|
|
52
|
+
|
|
53
|
+
return list(comments)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def format(text: str) -> str:
|
|
57
|
+
"""Format a todoosy document."""
|
|
58
|
+
result = parse(text)
|
|
59
|
+
ast = result.ast
|
|
60
|
+
lines: list[str] = []
|
|
61
|
+
item_map = {item.id: item for item in ast.items}
|
|
62
|
+
|
|
63
|
+
# Track Misc section
|
|
64
|
+
misc_section_id = None
|
|
65
|
+
for item in ast.items:
|
|
66
|
+
if item.type == 'heading' and item.title_text == 'Misc' and item.level == 1:
|
|
67
|
+
misc_section_id = item.id
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
def format_item(item_id: str, list_indent: int = 0, is_under_misc: bool = False) -> None:
|
|
71
|
+
item = item_map[item_id]
|
|
72
|
+
|
|
73
|
+
# Skip Misc section during normal iteration
|
|
74
|
+
if item.id == misc_section_id and not is_under_misc:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
# Add blank line before headings (except at start)
|
|
78
|
+
if item.type == 'heading' and lines and lines[-1] != '':
|
|
79
|
+
lines.append('')
|
|
80
|
+
|
|
81
|
+
lines.append(format_item_line(item, list_indent))
|
|
82
|
+
|
|
83
|
+
# Add blank line after heading before comments or children
|
|
84
|
+
if item.type == 'heading':
|
|
85
|
+
lines.append('')
|
|
86
|
+
|
|
87
|
+
# Add comments
|
|
88
|
+
formatted_comments = format_comments(
|
|
89
|
+
item.comments,
|
|
90
|
+
item.type == 'list',
|
|
91
|
+
list_indent
|
|
92
|
+
)
|
|
93
|
+
lines.extend(formatted_comments)
|
|
94
|
+
|
|
95
|
+
# Add blank line after heading comments before children
|
|
96
|
+
if item.type == 'heading' and item.comments and item.children:
|
|
97
|
+
lines.append('')
|
|
98
|
+
|
|
99
|
+
# Format children
|
|
100
|
+
for child_id in item.children:
|
|
101
|
+
child = item_map[child_id]
|
|
102
|
+
if child.type == 'list':
|
|
103
|
+
next_indent = 0 if item.type == 'heading' else list_indent + 1
|
|
104
|
+
format_item(child_id, next_indent, is_under_misc)
|
|
105
|
+
else:
|
|
106
|
+
format_item(child_id, 0, is_under_misc)
|
|
107
|
+
|
|
108
|
+
# Format all root items except Misc
|
|
109
|
+
for root_id in ast.root_ids:
|
|
110
|
+
if root_id != misc_section_id:
|
|
111
|
+
format_item(root_id, 0, False)
|
|
112
|
+
|
|
113
|
+
# Add Misc section at the end
|
|
114
|
+
if lines and lines[-1] != '':
|
|
115
|
+
lines.append('')
|
|
116
|
+
lines.append('# Misc')
|
|
117
|
+
|
|
118
|
+
# Add Misc items if they exist
|
|
119
|
+
if misc_section_id:
|
|
120
|
+
misc_item = item_map[misc_section_id]
|
|
121
|
+
if misc_item.comments:
|
|
122
|
+
lines.append('')
|
|
123
|
+
lines.extend(misc_item.comments)
|
|
124
|
+
if misc_item.children:
|
|
125
|
+
lines.append('')
|
|
126
|
+
for child_id in misc_item.children:
|
|
127
|
+
child = item_map[child_id]
|
|
128
|
+
lines.append(format_item_line(child, 0))
|
|
129
|
+
formatted_comments = format_comments(
|
|
130
|
+
child.comments,
|
|
131
|
+
child.type == 'list',
|
|
132
|
+
0
|
|
133
|
+
)
|
|
134
|
+
lines.extend(formatted_comments)
|
|
135
|
+
|
|
136
|
+
return '\n'.join(lines) + '\n'
|