cli-todo-jd 0.1.1__py3-none-any.whl → 0.2.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.
cli_todo_jd/cli_entry.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  from argparse import ArgumentParser
2
4
  from cli_todo_jd.main import (
3
5
  add_item_to_list,
@@ -5,75 +7,98 @@ from cli_todo_jd.main import (
5
7
  list_items_on_list,
6
8
  clear_list_of_items,
7
9
  cli_menu,
10
+ mark_item_as_done,
11
+ mark_item_as_not_done,
8
12
  )
13
+ from pathlib import Path
14
+ import typer
9
15
 
16
+ app = typer.Typer(help="A tiny todo CLI built with Typer.")
10
17
 
11
- def parser_optional_args(parser: ArgumentParser):
12
- parser.add_argument(
13
- "-f",
18
+
19
+ @app.command()
20
+ def add(
21
+ text: list[str] = typer.Argument(..., help="Todo item text (no quotes needed)."),
22
+ filepath: Path = typer.Option(
23
+ Path(".todo_list.db"),
14
24
  "--filepath",
15
- help="Path to the file to process",
16
- default="./.todo_list.json",
17
- )
25
+ "-f",
26
+ help="Path to the JSON file used for storage.",
27
+ ),
28
+ ) -> None:
29
+ full_text = " ".join(text).strip()
30
+ if not full_text:
31
+ raise typer.BadParameter("Todo item text cannot be empty.")
18
32
 
33
+ add_item_to_list(full_text, filepath)
34
+ typer.echo(f"Added: {full_text}")
19
35
 
20
- def add_item():
21
- parser = ArgumentParser(description="Add a todo item")
22
- parser.add_argument(
23
- "item",
24
- nargs="+",
25
- help="The todo item to add (use quotes or multiple words)",
26
- )
27
- parser_optional_args(parser)
28
36
 
29
- args = parser.parse_args()
30
- args.item = " ".join(args.item)
31
- add_item_to_list(args.item, args.filepath)
37
+ @app.command(name="list")
38
+ def list_(
39
+ filepath: Path = typer.Option(Path(".todo_list.db"), "--filepath", "-f"),
40
+ ) -> None:
41
+ list_items_on_list(filepath)
32
42
 
33
43
 
34
- def remove_item():
35
- parser = ArgumentParser(description="Remove a todo item by index")
36
- parser.add_argument(
37
- "index",
38
- type=int,
39
- help="The index of the todo item to remove (1-based)",
40
- )
41
- parser_optional_args(parser)
44
+ @app.command()
45
+ def remove(
46
+ index: int = typer.Argument(..., help="1-based index of item to remove."),
47
+ filepath: Path = typer.Option(Path(".todo_list.db"), "--filepath", "-f"),
48
+ ) -> None:
49
+ remove_item_from_list(index, filepath)
42
50
 
43
- args = parser.parse_args()
44
- remove_item_from_list(args.index, args.filepath)
45
51
 
52
+ @app.command()
53
+ def clear(
54
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
55
+ filepath: Path = typer.Option(Path(".todo_list.db"), "--filepath", "-f"),
56
+ ) -> None:
57
+ if not yes and not typer.confirm(f"Clear all todos in {filepath}?"):
58
+ typer.echo("Cancelled.")
59
+ raise typer.Exit(code=1)
46
60
 
47
- def list_items():
48
- parser = ArgumentParser(description="List all todo items")
49
- parser_optional_args(parser)
61
+ clear_list_of_items(filepath)
50
62
 
51
- args = parser.parse_args()
52
- list_items_on_list(args.filepath)
53
63
 
64
+ @app.command(name="menu")
65
+ def menu_(
66
+ filepath: Path = typer.Option(
67
+ Path(".todo_list.db"),
68
+ "--filepath",
69
+ "-f",
70
+ help="Path to the JSON file used for storage.",
71
+ ),
72
+ ) -> None:
73
+ cli_menu(filepath)
74
+ typer.echo("Exited menu.")
54
75
 
55
- def clear_list():
56
- parser = ArgumentParser(description="Clear all todo items")
57
- parser.add_argument(
58
- "-y",
59
- "--yes",
60
- action="store_true",
61
- help="Do not prompt for confirmation",
62
- )
63
- parser_optional_args(parser)
64
76
 
65
- args = parser.parse_args()
66
- list_items_on_list(args.filepath)
77
+ @app.command()
78
+ def done(
79
+ index: int = typer.Argument(..., help="1-based index of item to mark as done."),
80
+ filepath: Path = typer.Option(Path(".todo_list.db"), "--filepath", "-f"),
81
+ ) -> None:
82
+ mark_item_as_done(index, filepath)
83
+ list_(filepath=filepath)
67
84
 
68
- if not args.yes:
69
- resp = (
70
- input(f"Clear all todo items in '{args.filepath}'? [y/n]: ").strip().lower()
71
- )
72
- if resp not in ("y", "yes"):
73
- return
74
85
 
75
- # assuming remove_item_from_list(0/None) clears; otherwise replace with your clear implementation
76
- clear_list_of_items(args.filepath)
86
+ @app.command()
87
+ def not_done(
88
+ index: int = typer.Argument(..., help="1-based index of item to mark as done."),
89
+ filepath: Path = typer.Option(Path(".todo_list.db"), "--filepath", "-f"),
90
+ ) -> None:
91
+ mark_item_as_not_done(index, filepath)
92
+ list_(filepath=filepath)
93
+
94
+
95
+ def parser_optional_args(parser: ArgumentParser):
96
+ parser.add_argument(
97
+ "-f",
98
+ "--filepath",
99
+ help="Path to the file to process",
100
+ default="./.todo_list.db",
101
+ )
77
102
 
78
103
 
79
104
  def todo_menu():
@@ -82,3 +107,7 @@ def todo_menu():
82
107
  args = parser.parse_args()
83
108
 
84
109
  cli_menu(filepath=args.filepath)
110
+
111
+
112
+ if __name__ == "__main__":
113
+ app()
cli_todo_jd/main.py CHANGED
@@ -1,9 +1,12 @@
1
- import json
2
1
  from pathlib import Path
3
2
  import questionary
4
3
  from rich.console import Console
5
4
  from rich.table import Table
6
5
  from rich.padding import Padding
6
+ import sqlite3
7
+
8
+ from cli_todo_jd.storage.schema import ensure_schema
9
+ from cli_todo_jd.storage.migrate import migrate_from_json
7
10
 
8
11
 
9
12
  def main():
@@ -15,43 +18,109 @@ class TodoApp:
15
18
  A simple command-line todo application.
16
19
  """
17
20
 
18
- def __init__(self, file_path_to_json="./.todo_list.json"):
21
+ def __init__(self, file_path_to_db="./.todo_list.db"):
19
22
  self.todos = []
20
- self.file_path_to_json = Path(file_path_to_json)
21
- self._check_and_load_todos(self.file_path_to_json)
23
+ self.status = []
24
+ self.file_path_to_db = Path(file_path_to_db)
25
+ self._check_and_load_todos(self.file_path_to_db)
22
26
  self._console = Console()
23
27
 
24
- def add_todo(self, item):
25
- self.todos.append(item)
28
+ def add_todo(self, item: str) -> None:
29
+ item = (item or "").strip()
30
+ if not item:
31
+ print("Error: Todo item cannot be empty.")
32
+ return
33
+
34
+ try:
35
+ with sqlite3.connect(self.file_path_to_db) as conn:
36
+ ensure_schema(conn)
37
+ with conn:
38
+ conn.execute(
39
+ "INSERT INTO todos(item, done) VALUES (?, 0);", (item,)
40
+ )
41
+ except sqlite3.Error as e:
42
+ print(f"Error: Failed to add todo. ({e})")
43
+ return
44
+
26
45
  print(f'Added todo: "{item}"')
46
+ self._check_and_load_todos(self.file_path_to_db)
27
47
 
28
- def list_todos(self):
48
+ def list_todos(self) -> None:
49
+ # Always read fresh so output reflects the DB
50
+ self._check_and_load_todos(self.file_path_to_db)
29
51
  if not self.todos:
30
52
  print("No todos found.")
31
53
  return
32
54
  self._table_print()
33
55
 
34
- def remove_todo(self, index):
35
- try:
36
- removed = self.todos.pop(index - 1)
37
- print(f'Removed todo: "{removed}"')
38
- except IndexError:
56
+ def remove_todo(self, index: int) -> None:
57
+ # Maintain current UX: index refers to the displayed (1-based) ordering.
58
+ self._check_and_load_todos(self.file_path_to_db)
59
+
60
+ if index < 1 or index > len(self.todos):
39
61
  print("Error: Invalid todo index.")
62
+ return
63
+
64
+ try:
65
+ with sqlite3.connect(self.file_path_to_db) as conn:
66
+ ensure_schema(conn)
67
+
68
+ row = conn.execute(
69
+ "SELECT id, item FROM todos ORDER BY id LIMIT 1 OFFSET ?;",
70
+ (index - 1,),
71
+ ).fetchone()
72
+ if row is None:
73
+ print("Error: Invalid todo index.")
74
+ return
75
+
76
+ todo_id, removed_item = row
77
+ with conn:
78
+ conn.execute("DELETE FROM todos WHERE id = ?;", (todo_id,))
79
+ except sqlite3.Error as e:
80
+ print(f"Error: Failed to remove todo. ({e})")
81
+ return
82
+
83
+ print(f'Removed todo: "{removed_item}"')
84
+ self._check_and_load_todos(self.file_path_to_db)
85
+
86
+ def clear_all(self) -> None:
87
+ try:
88
+ with sqlite3.connect(self.file_path_to_db) as conn:
89
+ ensure_schema(conn)
90
+ with conn:
91
+ conn.execute("DELETE FROM todos;")
92
+ except sqlite3.Error as e:
93
+ print(f"Error: Failed to clear todos. ({e})")
94
+ return
95
+
96
+ self.todos = []
97
+ print("Cleared all todos.")
40
98
 
41
- def _check_and_load_todos(self, file_path):
42
- if file_path.exists():
43
- try:
44
- with file_path.open("r", encoding="utf-8") as f:
45
- self.todos = json.load(f)
46
- except (json.JSONDecodeError, OSError):
47
- print("Warning: Failed to load existing todos. Starting fresh.")
99
+ def _check_and_load_todos(self, file_path: Path) -> None:
100
+ # Create parent directory if needed
101
+ file_path.parent.mkdir(parents=True, exist_ok=True)
102
+
103
+ # Optional one-time migration: if the user still has a legacy JSON file and
104
+ # the DB is empty/new, import the items. This keeps upgrades smooth.
105
+ json_path = file_path.with_suffix(".json")
106
+ if json_path.exists() and file_path.suffix == ".db":
107
+ migrate_from_json(json_path=json_path, db_path=file_path, backup=True)
48
108
 
49
- def write_todos(self):
50
109
  try:
51
- with self.file_path_to_json.open("w", encoding="utf-8") as f:
52
- json.dump(self.todos, f, ensure_ascii=False, indent=2)
53
- except OSError as e:
54
- print(f"Warning: failed to save todos: {e}")
110
+ with sqlite3.connect(file_path) as conn:
111
+ ensure_schema(conn)
112
+ rows = conn.execute(
113
+ "SELECT id, item, done, created_at, done_at FROM todos ORDER BY id"
114
+ ).fetchall()
115
+
116
+ # In-memory list is used by the interactive menu for selection.
117
+ # Keep it as a simple list[str] for now.
118
+ self.todos = [row[1] for row in rows]
119
+ self.status = [row[2] for row in rows]
120
+ except sqlite3.Error as e:
121
+ print(f"Warning: Failed to load existing todos. Starting fresh. ({e})")
122
+ self.todos = []
123
+ self.status = []
55
124
 
56
125
  def _table_print(
57
126
  self,
@@ -61,29 +130,166 @@ class TodoApp:
61
130
  table = Table(
62
131
  title=title, header_style=style, border_style=style, show_lines=True
63
132
  )
64
- columns = ["ID", "Todo Item"]
133
+ columns = ["ID", "Todo Item", "Done"]
65
134
  for col in columns:
66
135
  table.add_column(str(col))
136
+
67
137
  for idx, todo in enumerate(self.todos, start=1):
68
- table.add_row(f"{idx}.", todo)
138
+ table.add_row(
139
+ f"{idx}.",
140
+ str(todo),
141
+ "[green]✔[/green]" if self.status[idx - 1] else "[red]✖[/red]",
142
+ )
143
+
69
144
  self._console.print(Padding(table, (2, 2)))
70
145
 
146
+ def mark_as_not_done(self, index: int) -> None:
147
+ self._check_and_load_todos(self.file_path_to_db)
148
+
149
+ if index < 1 or index > len(self.todos):
150
+ print("Error: Invalid todo index.")
151
+ return
152
+
153
+ try:
154
+ with sqlite3.connect(self.file_path_to_db) as conn:
155
+ ensure_schema(conn)
156
+
157
+ row = conn.execute(
158
+ "SELECT id, item FROM todos ORDER BY id LIMIT 1 OFFSET ?;",
159
+ (index - 1,),
160
+ ).fetchone()
161
+ if row is None:
162
+ print("Error: Invalid todo index.")
163
+ return
164
+
165
+ todo_id, item = row
166
+ with conn:
167
+ conn.execute(
168
+ "UPDATE todos SET done = 0, done_at = NULL WHERE id = ?;",
169
+ (todo_id,),
170
+ )
171
+ except sqlite3.Error as e:
172
+ print(f"Error: Failed to mark todo as not done. ({e})")
173
+ return
174
+
175
+ print(f'Marked todo as not done: "{item}"')
176
+ self._check_and_load_todos(self.file_path_to_db)
177
+
178
+ def mark_as_done(self, index: int) -> None:
179
+ self._check_and_load_todos(self.file_path_to_db)
180
+
181
+ if index < 1 or index > len(self.todos):
182
+ print("Error: Invalid todo index.")
183
+ return
184
+
185
+ try:
186
+ with sqlite3.connect(self.file_path_to_db) as conn:
187
+ ensure_schema(conn)
188
+
189
+ row = conn.execute(
190
+ "SELECT id, item FROM todos ORDER BY id LIMIT 1 OFFSET ?;",
191
+ (index - 1,),
192
+ ).fetchone()
193
+ if row is None:
194
+ print("Error: Invalid todo index.")
195
+ return
196
+
197
+ todo_id, item = row
198
+ with conn:
199
+ conn.execute(
200
+ "UPDATE todos SET done = ?, done_at = datetime('now') WHERE id = ?;",
201
+ (1, todo_id),
202
+ )
203
+ except sqlite3.Error as e:
204
+ print(f"Error: Failed to mark todo as done. ({e})")
205
+ return
206
+
207
+ print(f'Marked todo as done: "{item}"')
208
+ self._check_and_load_todos(self.file_path_to_db)
71
209
 
72
- def create_list(file_path_to_json: str = "./.todo_list.json"):
210
+ def update_done_data(self, index, done_value, done_at_value, todo_id):
211
+ text_done_value = "done" if done_value == 1 else "not done"
212
+ try:
213
+ with sqlite3.connect(self.file_path_to_db) as conn:
214
+ ensure_schema(conn)
215
+
216
+ row = conn.execute(
217
+ "SELECT id, item FROM todos ORDER BY id LIMIT 1 OFFSET ?;",
218
+ (index - 1,),
219
+ ).fetchone()
220
+ if row is None:
221
+ print("Error: Invalid todo index.")
222
+ return
223
+
224
+ todo_id, item = row
225
+ with conn:
226
+ if done_value:
227
+ conn.execute(
228
+ "UPDATE todos SET done = ?, done_at = datetime('now') WHERE id = ?;",
229
+ (1, todo_id),
230
+ )
231
+ else:
232
+ conn.execute(
233
+ "UPDATE todos SET done = ?, done_at = NULL WHERE id = ?;",
234
+ (0, todo_id),
235
+ )
236
+ except sqlite3.Error as e:
237
+ print(f"Error: Failed to mark todo as {text_done_value}. ({e})")
238
+ return
239
+
240
+ def edit_entry(self, index: int, new_text: str) -> None:
241
+ self._check_and_load_todos(self.file_path_to_db)
242
+
243
+ if index < 1 or index > len(self.todos):
244
+ print("Error: Invalid todo index.")
245
+ return
246
+
247
+ new_text = (new_text or "").strip()
248
+ if not new_text:
249
+ print("Error: Todo item cannot be empty.")
250
+ return
251
+
252
+ try:
253
+ with sqlite3.connect(self.file_path_to_db) as conn:
254
+ ensure_schema(conn)
255
+
256
+ row = conn.execute(
257
+ "SELECT id, item FROM todos ORDER BY id LIMIT 1 OFFSET ?;",
258
+ (index - 1,),
259
+ ).fetchone()
260
+ if row is None:
261
+ print("Error: Invalid todo index.")
262
+ return
263
+
264
+ todo_id, old_item = row
265
+ with conn:
266
+ conn.execute(
267
+ "UPDATE todos SET item = ? WHERE id = ?;",
268
+ (new_text, todo_id),
269
+ )
270
+ except sqlite3.Error as e:
271
+ print(f"Error: Failed to edit todo. ({e})")
272
+ return
273
+
274
+ print(f'Edited todo: "{old_item}" to "{new_text}"')
275
+ self._check_and_load_todos(self.file_path_to_db)
276
+
277
+
278
+ def create_list(file_path_to_db: str = "./.todo_list.db"):
73
279
  """
74
280
  Create a new todo list.
75
281
 
76
282
  Parameters
77
283
  ----------
78
284
  file_path_to_json : str, optional
79
- The file path to the JSON file for storing todos, by default "./.todo_list.json"
285
+ The file path to the JSON file for storing todos, by default "./.todo_list.db"
80
286
 
81
287
  Returns
82
288
  -------
83
289
  TodoApp
84
290
  An instance of the TodoApp class.
85
291
  """
86
- app = TodoApp(file_path_to_json=file_path_to_json)
292
+ app = TodoApp(file_path_to_db=file_path_to_db)
87
293
  return app
88
294
 
89
295
 
@@ -98,10 +304,9 @@ def add_item_to_list(item: str, filepath: str):
98
304
  filepath : str
99
305
  The file path to the JSON file for storing todos.
100
306
  """
101
- app = create_list(file_path_to_json=filepath)
307
+ app = create_list(file_path_to_db=filepath)
102
308
  app.add_todo(item)
103
309
  app.list_todos()
104
- app.write_todos()
105
310
 
106
311
 
107
312
  def list_items_on_list(filepath: str):
@@ -113,7 +318,7 @@ def list_items_on_list(filepath: str):
113
318
  filepath : str
114
319
  The file path to the JSON file for storing todos.
115
320
  """
116
- app = create_list(file_path_to_json=filepath)
321
+ app = create_list(file_path_to_db=filepath)
117
322
  app.list_todos()
118
323
 
119
324
 
@@ -128,10 +333,9 @@ def remove_item_from_list(index: int, filepath: str):
128
333
  filepath : str
129
334
  The file path to the JSON file for storing todos.
130
335
  """
131
- app = create_list(file_path_to_json=filepath)
336
+ app = create_list(file_path_to_db=filepath)
132
337
  app.remove_todo(index)
133
338
  app.list_todos()
134
- app.write_todos()
135
339
 
136
340
 
137
341
  def clear_list_of_items(filepath: str):
@@ -143,28 +347,37 @@ def clear_list_of_items(filepath: str):
143
347
  filepath : str
144
348
  The file path to the JSON file for storing todos.
145
349
  """
146
- app = create_list(file_path_to_json=filepath)
147
- app.todos = []
148
- print("Cleared all todos.")
149
- app.write_todos()
350
+ app = create_list(file_path_to_db=filepath)
351
+ app.clear_all()
352
+
353
+
354
+ def mark_item_as_done(index: int, filepath: str):
355
+ app = create_list(file_path_to_db=filepath)
356
+ app.mark_as_done(index)
357
+
358
+
359
+ def mark_item_as_not_done(index: int, filepath: str):
360
+ app = create_list(file_path_to_db=filepath)
361
+ app.mark_as_not_done(index)
150
362
 
151
363
 
152
- def cli_menu(filepath="./.todo_list.json"):
364
+ def cli_menu(filepath="./.todo_list.db"):
153
365
  """
154
366
  Display the command-line interface menu for the todo list.
155
367
 
156
368
  Parameters
157
369
  ----------
158
370
  filepath : str, optional
159
- The file path to the JSON file for storing todos, by default "./.todo_list.json"
371
+ The file path to the JSON file for storing todos, by default "./.todo_list.db"
160
372
  """
161
- app = create_list(file_path_to_json=filepath)
373
+ app = create_list(file_path_to_db=filepath)
162
374
  while True:
163
375
  action = questionary.select(
164
376
  "What would you like to do?",
165
377
  choices=[
166
378
  "Add todo",
167
379
  "List todos",
380
+ "Update todo status",
168
381
  "Remove todo",
169
382
  "Clear all todos",
170
383
  "Exit",
@@ -174,9 +387,33 @@ def cli_menu(filepath="./.todo_list.json"):
174
387
  if action == "Add todo":
175
388
  item = questionary.text("Enter the todo item:").ask()
176
389
  app.add_todo(item)
177
- app.write_todos()
178
390
  elif action == "List todos":
179
391
  app.list_todos()
392
+ elif action == "Update todo status":
393
+ if not app.todos:
394
+ print("No todos to update.")
395
+ continue
396
+ todo_choice = questionary.select(
397
+ "Select the todo to update:",
398
+ choices=["<Back>"] + app.todos,
399
+ ).ask()
400
+
401
+ if todo_choice == "<Back>":
402
+ continue
403
+
404
+ todo_index = app.todos.index(todo_choice) + 1
405
+ status_choice = questionary.select(
406
+ "Mark as:",
407
+ choices=["Done", "Not Done", "<Back>"],
408
+ ).ask()
409
+
410
+ if status_choice == "<Back>":
411
+ continue
412
+ elif status_choice == "Done":
413
+ app.mark_as_done(todo_index)
414
+ elif status_choice == "Not Done":
415
+ app.mark_as_not_done(todo_index)
416
+ app.list_todos()
180
417
  elif action == "Remove todo":
181
418
  if not app.todos:
182
419
  print("No todos to remove.")
@@ -191,24 +428,13 @@ def cli_menu(filepath="./.todo_list.json"):
191
428
 
192
429
  todo_to_remove = app.todos.index(todo_choice) + 1
193
430
  app.remove_todo(todo_to_remove)
194
- app.write_todos()
195
431
 
196
432
  elif action == "Clear all todos":
197
433
  confirm = questionary.confirm(
198
434
  "Are you sure you want to clear all todos?"
199
435
  ).ask()
200
436
  if confirm:
201
- app.todos = []
202
- print("Cleared all todos.")
203
- app.write_todos()
204
- elif action == "Clear all todos":
205
- confirm = questionary.confirm(
206
- "Are you sure you want to clear all todos?"
207
- ).ask()
208
- if confirm:
209
- app.todos = []
210
- print("Cleared all todos.")
211
- app.write_todos()
437
+ app.clear_all()
212
438
  elif action == "Exit":
213
439
  break
214
440
  else:
@@ -0,0 +1,6 @@
1
+ """Storage helpers (SQLite schema + migrations)."""
2
+
3
+ from .schema import ensure_schema, SCHEMA_VERSION
4
+ from .migrate import migrate_from_json
5
+
6
+ __all__ = ["ensure_schema", "SCHEMA_VERSION", "migrate_from_json"]
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from pathlib import Path
6
+ from typing import Iterable
7
+
8
+ from .schema import ensure_schema
9
+
10
+
11
+ def _iter_json_items(data: object) -> Iterable[str]:
12
+ """Yield todo item strings from supported legacy JSON formats.
13
+
14
+ Supported:
15
+ - ["item1", "item2", ...]
16
+ - [{"item": "..."}, {"text": "..."}, ...]
17
+ """
18
+ if isinstance(data, list):
19
+ for entry in data:
20
+ if isinstance(entry, str):
21
+ text = entry
22
+ elif isinstance(entry, dict):
23
+ text = entry.get("item") or entry.get("text")
24
+ if not isinstance(text, str):
25
+ continue
26
+ else:
27
+ continue
28
+
29
+ text = text.strip()
30
+ if text:
31
+ yield text
32
+
33
+
34
+ def migrate_from_json(
35
+ *,
36
+ json_path: Path,
37
+ db_path: Path,
38
+ backup: bool = True,
39
+ ) -> int:
40
+ """Migrate todos from a legacy JSON file into a SQLite database.
41
+
42
+ Parameters
43
+ ----------
44
+ json_path:
45
+ Path to legacy JSON file (e.g. `.todo_list.json`).
46
+ db_path:
47
+ Path to SQLite file (e.g. `.todo_list.db`).
48
+ backup:
49
+ If True, rename the JSON file to `.bak` after successful import.
50
+
51
+ Returns
52
+ -------
53
+ int
54
+ Number of rows inserted.
55
+
56
+ Behavior
57
+ --------
58
+ - If the JSON file doesn't exist, returns 0.
59
+ - If the database already has todos, does not import (returns 0).
60
+ (This avoids duplicate imports when multiple commands run.)
61
+ """
62
+
63
+ if not json_path.exists():
64
+ return 0
65
+
66
+ db_path.parent.mkdir(parents=True, exist_ok=True)
67
+
68
+ try:
69
+ raw = json_path.read_text(encoding="utf-8")
70
+ data = json.loads(raw) if raw.strip() else []
71
+ except (OSError, json.JSONDecodeError):
72
+ # Fail safe: don't destroy/rename the user's file.
73
+ return 0
74
+
75
+ items = list(_iter_json_items(data))
76
+ if not items:
77
+ return 0
78
+
79
+ inserted = 0
80
+ with sqlite3.connect(db_path) as conn:
81
+ ensure_schema(conn)
82
+
83
+ # Guard against double-import
84
+ existing = conn.execute("SELECT 1 FROM todos LIMIT 1;").fetchone()
85
+ if existing is not None:
86
+ return 0
87
+
88
+ with conn:
89
+ conn.executemany(
90
+ "INSERT INTO todos(item, done) VALUES (?, 0);", [(t,) for t in items]
91
+ )
92
+ inserted = conn.execute("SELECT changes();").fetchone()[0]
93
+
94
+ if backup:
95
+ try:
96
+ bak_path = json_path.with_suffix(json_path.suffix + ".bak")
97
+ if bak_path.exists():
98
+ # Avoid overwrite; add a numeric suffix
99
+ i = 1
100
+ while True:
101
+ candidate = json_path.with_suffix(json_path.suffix + f".bak{i}")
102
+ if not candidate.exists():
103
+ bak_path = candidate
104
+ break
105
+ i += 1
106
+ json_path.rename(bak_path)
107
+ except OSError:
108
+ # Backup failure shouldn't invalidate a successful migration
109
+ pass
110
+
111
+ return inserted
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+
5
+
6
+ SCHEMA_VERSION = 1
7
+
8
+
9
+ def ensure_schema(conn: sqlite3.Connection) -> None:
10
+ """Ensure required SQLite schema exists and is migrated.
11
+
12
+ Uses `PRAGMA user_version` for lightweight, in-app migrations.
13
+
14
+ Parameters
15
+ ----------
16
+ conn:
17
+ An open sqlite3 connection.
18
+
19
+ Notes
20
+ -----
21
+ - Call this once per process/command, right after connecting.
22
+ - Keep migrations idempotent and wrapped in a transaction.
23
+ """
24
+
25
+ # Improve concurrent CLI usage (separate processes) and durability.
26
+ # WAL is persistent for the database file once set.
27
+ conn.execute("PRAGMA journal_mode = WAL;")
28
+ conn.execute("PRAGMA foreign_keys = ON;")
29
+
30
+ current_version = conn.execute("PRAGMA user_version;").fetchone()[0]
31
+
32
+ # Fresh database
33
+ if current_version == 0:
34
+ with conn:
35
+ conn.execute(
36
+ """
37
+ CREATE TABLE IF NOT EXISTS todos (
38
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ item TEXT NOT NULL,
40
+ done INTEGER NOT NULL DEFAULT 0,
41
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
42
+ done_at TEXT
43
+ );
44
+ """
45
+ )
46
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_todos_done ON todos(done);")
47
+ conn.execute(f"PRAGMA user_version = {int(SCHEMA_VERSION)};")
48
+ return
49
+
50
+ # Incremental migrations
51
+ if current_version < 1:
52
+ # Example placeholder for future migrations.
53
+ # Keep each migration block small and bump user_version accordingly.
54
+ with conn:
55
+ conn.execute("PRAGMA user_version = 1;")
56
+ current_version = 1
57
+
58
+ # If you bump SCHEMA_VERSION, add `if current_version < N:` blocks above.
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-todo-jd
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
7
7
  Requires-Dist: questionary>=2.1.1
8
8
  Requires-Dist: rich>=14.2.0
9
+ Requires-Dist: typer>=0.21.1
9
10
  Provides-Extra: dev
10
11
  Requires-Dist: pre-commit; extra == "dev"
11
12
  Requires-Dist: pytest; extra == "dev"
@@ -20,16 +21,16 @@ A command line to do list with interactive menu
20
21
  This is a command line interface todo list. Once installed, there are two ways to interact
21
22
  with the list.
22
23
 
23
- ### `todo-menu`
24
+ ### `todo_menu`
24
25
 
25
- Once installed us `todo_menu` to launch into the interactive menu. From here you can add,
26
+ Once installed use `todo_menu` to launch into the interactive menu. From here you can add,
26
27
  remove, list, or clear your todo list. Items in your list are stored (by default) as
27
28
  `.todo_list.json`. The menu does also support optional filepaths using `-f` or `--filepath`.
28
29
 
29
30
 
30
31
  ### interacting with todo list without menu
31
32
 
32
- Alternatly you can interact directly using the following commands (`--filepath can be substituted for -f`)
33
+ Alternately you can interact directly using the following commands (`--filepath can be substituted for -f`)
33
34
 
34
35
  - `todo_add text --filepath optional_path_to_json` used to add an item to your list
35
36
  - `todo_remove index --filepath optional_path_to_json` used to remove item number `index`
@@ -0,0 +1,11 @@
1
+ cli_todo_jd/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ cli_todo_jd/cli_entry.py,sha256=sKp-aIWVldH8DiXv22bokjlIiJcAZshKHdLEqjM09HA,2877
3
+ cli_todo_jd/main.py,sha256=AN22ojW0iIXArJQmtEjQ1jSdAb7Z-xr8i4NIEE4xAQ0,13965
4
+ cli_todo_jd/storage/__init__.py,sha256=u0jMfDuUIEy9mor1dVeIiAE0Cap6FL77tW9GlsyskYU,210
5
+ cli_todo_jd/storage/migrate.py,sha256=Ij_0OwTvibow79KVilf2O2nfMuohj0raarVV7OcjwbY,3063
6
+ cli_todo_jd/storage/schema.py,sha256=r5BTtcRn8J72_b8NJlryYnd_aiuy0y00eX01QavKE98,1824
7
+ cli_todo_jd-0.2.0.dist-info/METADATA,sha256=rra5GQZYrK8xCKX3pUaW0wnmw_pujlKR8bpOIVH-a1U,2982
8
+ cli_todo_jd-0.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
9
+ cli_todo_jd-0.2.0.dist-info/entry_points.txt,sha256=BIfrMKcC340A79aXHg34nnhiDsXh7c9hPck1k-Rb28c,95
10
+ cli_todo_jd-0.2.0.dist-info/top_level.txt,sha256=hOnYr7w1JdQs6MlD1Uzjt24Ca8nvriOWNNq6NaqgHqM,12
11
+ cli_todo_jd-0.2.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.10.1)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ todo = cli_todo_jd.cli_entry:app
3
+ todo_menu = cli_todo_jd.cli_entry:todo_menu
@@ -1,8 +0,0 @@
1
- cli_todo_jd/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- cli_todo_jd/cli_entry.py,sha256=WFigVsmponzukNW-T_35ChdMWS3AVerdfKp2MePZzVo,2109
3
- cli_todo_jd/main.py,sha256=PfzsSz6whW8BMQ79PoCRvJO5oBN7qhpDFut_HtgjJns,5877
4
- cli_todo_jd-0.1.1.dist-info/METADATA,sha256=PhFUYCFtAyTd5u_CQ-b5Pn1K6Q_5nvYlxNcqigRLoWs,2951
5
- cli_todo_jd-0.1.1.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
6
- cli_todo_jd-0.1.1.dist-info/entry_points.txt,sha256=UtqZ1yqzeQNOOVC232_iqRQCcKu9hL9k5Q-BtyMZSGg,243
7
- cli_todo_jd-0.1.1.dist-info/top_level.txt,sha256=hOnYr7w1JdQs6MlD1Uzjt24Ca8nvriOWNNq6NaqgHqM,12
8
- cli_todo_jd-0.1.1.dist-info/RECORD,,
@@ -1,6 +0,0 @@
1
- [console_scripts]
2
- todo_add = cli_todo_jd.cli_entry:add_item
3
- todo_clear = cli_todo_jd.cli_entry:clear_list
4
- todo_list = cli_todo_jd.cli_entry:list_items
5
- todo_menu = cli_todo_jd.cli_entry:todo_menu
6
- todo_remove = cli_todo_jd.cli_entry:remove_item