Skip to content

Commit 14fa201

Browse files
committed
feat: markdown import support
1 parent 6e59441 commit 14fa201

5 files changed

Lines changed: 562 additions & 1 deletion

File tree

src/gitdo/cli.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
"""CLI interface for GitDo."""
22

3+
from pathlib import Path
4+
35
import click
46
from rich.console import Console
57
from rich.table import Table
68

79
from . import __version__
10+
from .markdown_parser import parse_markdown_file
811
from .storage import Storage
912

1013
console = Console()
@@ -111,6 +114,80 @@ def remove(task_id: str):
111114
raise click.Abort()
112115

113116

117+
@cli.command("import-md")
118+
@click.argument("file_path", type=click.Path(exists=True, path_type=Path))
119+
@click.option(
120+
"--skip-duplicates",
121+
is_flag=True,
122+
help="Skip tasks with duplicate titles",
123+
)
124+
@click.option(
125+
"--dry-run",
126+
is_flag=True,
127+
help="Preview tasks without importing",
128+
)
129+
def import_md(
130+
file_path: Path,
131+
*,
132+
skip_duplicates: bool,
133+
dry_run: bool,
134+
) -> None:
135+
"""
136+
Import tasks from a markdown file.
137+
138+
Scans for checkbox items in the format:
139+
- [ ] Task title (pending)
140+
- [x] Task title (completed)
141+
"""
142+
storage = Storage()
143+
if not storage.is_initialized():
144+
console.print("[red]Error:[/red] GitDo is not initialized. Run 'gitdo init' first.")
145+
raise click.Abort()
146+
147+
try:
148+
tasks = parse_markdown_file(file_path)
149+
except FileNotFoundError:
150+
console.print(f"[red]Error:[/red] File not found: {file_path}")
151+
raise click.Abort() from None
152+
except PermissionError:
153+
console.print(f"[red]Error:[/red] Cannot read file: {file_path}")
154+
raise click.Abort() from None
155+
156+
if not tasks:
157+
console.print(f"[yellow]No checkbox items found in {file_path}[/yellow]")
158+
return
159+
160+
# Display preview table
161+
table = Table(show_header=True, header_style="bold magenta")
162+
table.add_column("Task", style="")
163+
table.add_column("Status", width=12)
164+
165+
for task in tasks:
166+
status_color = "green" if task.status.value == "completed" else "yellow"
167+
table.add_row(
168+
task.title,
169+
f"[{status_color}]{task.status.value}[/{status_color}]",
170+
)
171+
172+
console.print(f"\n[bold]Found {len(tasks)} task(s) in {file_path}:[/bold]")
173+
console.print(table)
174+
175+
if dry_run:
176+
console.print("\n[dim]Dry run - no tasks were imported[/dim]")
177+
return
178+
179+
# Import tasks
180+
imported_count, skipped_count = storage.import_tasks(
181+
tasks,
182+
skip_duplicates=skip_duplicates,
183+
)
184+
185+
# Display results
186+
console.print(f"\n[green]✓[/green] Imported {imported_count} task(s)")
187+
if skipped_count > 0:
188+
console.print(f"[yellow]⚠[/yellow] Skipped {skipped_count} duplicate(s)")
189+
190+
114191
def main():
115192
"""Entry point for the CLI."""
116193
cli()

src/gitdo/markdown_parser.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Markdown file parser for extracting tasks."""
2+
3+
import re
4+
from pathlib import Path
5+
from uuid import uuid4
6+
7+
from .models import Task, TaskStatus
8+
9+
10+
def extract_checkbox_items(content: str) -> list[tuple[str, bool]]:
11+
"""Extract checkbox items from markdown content.
12+
13+
Args:
14+
content: Markdown file content
15+
16+
Returns:
17+
List of (title, is_completed) tuples
18+
"""
19+
# Pattern matches: - [ ] or - [x] or - [X] followed by task title
20+
# Supports various indentation levels
21+
pattern = r"^\s*-\s+\[([ xX])\]\s+(.+)$"
22+
items = []
23+
24+
for line in content.splitlines():
25+
match = re.match(pattern, line)
26+
if match:
27+
checkbox_state = match.group(1)
28+
title = match.group(2).strip()
29+
is_completed = checkbox_state.lower() == "x"
30+
items.append((title, is_completed))
31+
32+
return items
33+
34+
35+
def parse_markdown_file(file_path: Path) -> list[Task]:
36+
"""Parse markdown file and extract tasks.
37+
38+
Args:
39+
file_path: Path to markdown file
40+
41+
Returns:
42+
List of Task objects extracted from checkbox items
43+
44+
Raises:
45+
FileNotFoundError: If file doesn't exist
46+
PermissionError: If file cannot be read
47+
"""
48+
if not file_path.exists():
49+
raise FileNotFoundError(f"File not found: {file_path}")
50+
51+
try:
52+
content = file_path.read_text()
53+
except PermissionError as e:
54+
raise PermissionError(f"Cannot read file: {file_path}") from e
55+
56+
checkbox_items = extract_checkbox_items(content)
57+
tasks = []
58+
59+
for title, is_completed in checkbox_items:
60+
task = Task(
61+
id=str(uuid4()),
62+
title=title,
63+
status=TaskStatus.COMPLETED if is_completed else TaskStatus.PENDING,
64+
)
65+
if is_completed:
66+
task.complete()
67+
68+
tasks.append(task)
69+
70+
return tasks

src/gitdo/storage.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@ def __init__(self, base_path: Path | None = None):
2626

2727
@staticmethod
2828
def _find_gitdo_root(start_path: Path | None = None) -> Path | None:
29-
"""Find .gitdo/ folder by walking up directory tree.
29+
"""
30+
Find .gitdo/ folder by walking up directory tree.
3031
3132
Args:
3233
start_path: Starting directory. Defaults to current directory.
3334
3435
Returns:
3536
Path containing .gitdo/ folder, or None if not found.
37+
3638
"""
3739
current = start_path or Path.cwd()
3840
current = current.resolve()
@@ -136,6 +138,39 @@ def remove_task(self, task_id: str) -> bool:
136138
return True
137139
return False
138140

141+
def import_tasks(
142+
self,
143+
tasks: list[Task],
144+
*,
145+
skip_duplicates: bool = False,
146+
) -> tuple[int, int]:
147+
"""Import multiple tasks at once.
148+
149+
Args:
150+
tasks: List of tasks to import
151+
skip_duplicates: If True, skip tasks with duplicate titles
152+
153+
Returns:
154+
Tuple of (imported_count, skipped_count)
155+
"""
156+
existing_tasks = self.load_tasks()
157+
existing_titles = {task.title for task in existing_tasks} if skip_duplicates else set()
158+
159+
imported_count = 0
160+
skipped_count = 0
161+
162+
for task in tasks:
163+
if skip_duplicates and task.title in existing_titles:
164+
skipped_count += 1
165+
continue
166+
167+
existing_tasks.append(task)
168+
existing_titles.add(task.title)
169+
imported_count += 1
170+
171+
self._save_tasks(existing_tasks)
172+
return imported_count, skipped_count
173+
139174
def _save_tasks(self, tasks: list[Task]) -> None:
140175
"""Save tasks to storage.
141176

tests/test_cli.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,161 @@ def test_list_all_flag(runner):
145145
result = runner.invoke(cli, ["list", "--all"])
146146
assert "Test task" in result.output
147147
assert "completed" in result.output.lower()
148+
149+
150+
def test_import_md_basic(runner, tmp_path):
151+
"""Test import-md command with basic markdown file."""
152+
md_file = tmp_path / "tasks.md"
153+
md_file.write_text("""
154+
# Tasks
155+
156+
- [ ] Task 1
157+
- [x] Task 2
158+
- [ ] Task 3
159+
""")
160+
161+
with runner.isolated_filesystem():
162+
runner.invoke(cli, ["init"])
163+
result = runner.invoke(cli, ["import-md", str(md_file)])
164+
assert result.exit_code == 0
165+
assert "Imported 3 task(s)" in result.output
166+
assert "Task 1" in result.output
167+
assert "Task 2" in result.output
168+
assert "Task 3" in result.output
169+
170+
171+
def test_import_md_without_init(runner, tmp_path):
172+
"""Test import-md command without initialization."""
173+
md_file = tmp_path / "tasks.md"
174+
md_file.write_text("- [ ] Task 1")
175+
176+
with runner.isolated_filesystem():
177+
result = runner.invoke(cli, ["import-md", str(md_file)])
178+
assert result.exit_code != 0
179+
assert "not initialized" in result.output.lower()
180+
181+
182+
def test_import_md_file_not_found(runner):
183+
"""Test import-md command with non-existent file."""
184+
with runner.isolated_filesystem():
185+
runner.invoke(cli, ["init"])
186+
result = runner.invoke(cli, ["import-md", "/non/existent/file.md"])
187+
assert result.exit_code != 0
188+
189+
190+
def test_import_md_no_checkboxes(runner, tmp_path):
191+
"""Test import-md command with file without checkboxes."""
192+
md_file = tmp_path / "no_tasks.md"
193+
md_file.write_text("""
194+
# Just some text
195+
196+
No checkbox items here.
197+
""")
198+
199+
with runner.isolated_filesystem():
200+
runner.invoke(cli, ["init"])
201+
result = runner.invoke(cli, ["import-md", str(md_file)])
202+
assert result.exit_code == 0
203+
assert "No checkbox items found" in result.output
204+
205+
206+
def test_import_md_skip_duplicates(runner, tmp_path):
207+
"""Test import-md command with --skip-duplicates flag."""
208+
md_file = tmp_path / "tasks.md"
209+
md_file.write_text("""
210+
- [ ] Duplicate task
211+
- [ ] Unique task
212+
""")
213+
214+
with runner.isolated_filesystem():
215+
runner.invoke(cli, ["init"])
216+
# Add a task manually first
217+
runner.invoke(cli, ["add", "Duplicate task"])
218+
219+
# Import with skip-duplicates
220+
result = runner.invoke(cli, ["import-md", str(md_file), "--skip-duplicates"])
221+
assert result.exit_code == 0
222+
assert "Imported 1 task(s)" in result.output
223+
assert "Skipped 1 duplicate(s)" in result.output
224+
225+
226+
def test_import_md_dry_run(runner, tmp_path):
227+
"""Test import-md command with --dry-run flag."""
228+
md_file = tmp_path / "tasks.md"
229+
md_file.write_text("""
230+
- [ ] Task 1
231+
- [ ] Task 2
232+
""")
233+
234+
with runner.isolated_filesystem():
235+
runner.invoke(cli, ["init"])
236+
result = runner.invoke(cli, ["import-md", str(md_file), "--dry-run"])
237+
assert result.exit_code == 0
238+
assert "Task 1" in result.output
239+
assert "Task 2" in result.output
240+
assert "Dry run - no tasks were imported" in result.output
241+
242+
# Verify tasks were not actually imported
243+
list_result = runner.invoke(cli, ["list"])
244+
assert "No tasks found" in list_result.output
245+
246+
247+
def test_import_md_complex_file(runner, tmp_path):
248+
"""Test import-md with complex markdown file."""
249+
md_file = tmp_path / "complex.md"
250+
md_file.write_text("""
251+
# Project Tasks
252+
253+
## Phase 1
254+
- [x] Setup project
255+
- [ ] Write docs
256+
257+
## Phase 2
258+
- [ ] Implement feature A
259+
- [ ] Sub-task 1
260+
- [ ] Sub-task 2
261+
- [ ] Implement feature B
262+
263+
Some random text here.
264+
265+
## Phase 3
266+
- [ ] Deploy to production
267+
""")
268+
269+
with runner.isolated_filesystem():
270+
runner.invoke(cli, ["init"])
271+
result = runner.invoke(cli, ["import-md", str(md_file)])
272+
assert result.exit_code == 0
273+
assert "Imported 7 task(s)" in result.output
274+
275+
# Verify tasks are in storage
276+
list_result = runner.invoke(cli, ["list", "--all"])
277+
assert "Setup project" in list_result.output
278+
assert "Write docs" in list_result.output
279+
assert "Deploy to production" in list_result.output
280+
281+
282+
def test_import_md_preserves_status(runner, tmp_path):
283+
"""Test that import-md preserves task status (completed vs pending)."""
284+
md_file = tmp_path / "tasks.md"
285+
md_file.write_text("""
286+
- [ ] Pending task
287+
- [x] Completed task
288+
- [X] Another completed task
289+
""")
290+
291+
with runner.isolated_filesystem():
292+
runner.invoke(cli, ["init"])
293+
runner.invoke(cli, ["import-md", str(md_file)])
294+
295+
# Check that completed tasks show up in --all but not in default list
296+
default_list = runner.invoke(cli, ["list"])
297+
all_list = runner.invoke(cli, ["list", "--all"])
298+
299+
assert "Pending task" in default_list.output
300+
assert "Completed task" not in default_list.output
301+
assert "Another completed task" not in default_list.output
302+
303+
assert "Pending task" in all_list.output
304+
assert "Completed task" in all_list.output
305+
assert "Another completed task" in all_list.output

0 commit comments

Comments
 (0)