easydone 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.
easydone/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+
3
+ try:
4
+ # use the distribution name from pyproject 'project.name' (make sure they match)
5
+ __version__ = version("easydone-task-tracker")
6
+ except PackageNotFoundError:
7
+ # Package isn't installed (dev environment). Optional fallback:
8
+ __version__ = "0.0.0+dev"
easydone/__main__.py ADDED
@@ -0,0 +1,25 @@
1
+ from .cli import Parser
2
+ from .logic import TasksManager
3
+ from .storage import JSONHandler
4
+ from .format import describe_load_result, report_backup
5
+
6
+ def main() -> None:
7
+ # JSONHandler instance for load/save in .json.
8
+ handler = JSONHandler()
9
+ load_result = handler.load()
10
+ describe_load_result(load_result)
11
+
12
+ # TasksManager instance based on tasks loaded
13
+ manager = TasksManager(tasks_from_file=load_result.tasks)
14
+
15
+ # Parser instance for cli interface and argumments
16
+ cli = Parser(manager)
17
+ mutated = cli.start_parsing()
18
+
19
+ # save tasks before exiting only if mutated status
20
+ if mutated:
21
+ backup_result = handler.save(manager.tasks)
22
+ report_backup(backup_result)
23
+
24
+ if __name__ == "__main__":
25
+ main()
easydone/cli.py ADDED
@@ -0,0 +1,155 @@
1
+ from argparse import ArgumentParser, Namespace, SUPPRESS
2
+ from .logic import TasksManager
3
+ from .format import print_table
4
+ from . import __version__
5
+
6
+ SUPPORTED_STATUS = ["not-done", "done", "in-progress"]
7
+ SUPPORTED_PRIORITIES = ["low", "normal", "high", "urgent"]
8
+
9
+ class Parser():
10
+ """ A class to manage all the argument parser and command features. """
11
+
12
+ def __init__(self, manager: TasksManager) -> None:
13
+ """Initialize the CLIApp attributes"""
14
+ if not isinstance (manager, TasksManager):
15
+ raise TypeError()
16
+
17
+ self.tasks_manager = manager
18
+ self.build_parser()
19
+
20
+ def build_parser(self) -> None:
21
+ """ Create the main parser and sub-commands. """
22
+ description = "Simple and powerfull task tracker. Helps you manage your to-do list directly from the terminal."
23
+ self.main_parser = ArgumentParser(
24
+ prog = "EasyDone", description = description, allow_abbrev = False,
25
+ epilog = "Thanks for using %(prog)s! :)\nAll feedback is appreciated.", )
26
+
27
+ # Show the current version running.
28
+ self.main_parser.add_argument(
29
+ '-v', '--version', help='Display app version.', action="version",
30
+ version="%(prog)s " + __version__)
31
+
32
+ # Sub-commands parser
33
+ sub_pars = self.main_parser.add_subparsers(title="actions")
34
+
35
+ # 'NEW' command
36
+ # ====================
37
+
38
+ # Create parser for 'new' command
39
+ new_pars = sub_pars.add_parser("new", help="Create a new task")
40
+ new_pars.add_argument(
41
+ "description", type=str,
42
+ help="Description of the task to be added.")
43
+
44
+ # optional flag for indicating initial status
45
+ new_pars.add_argument(
46
+ "-s", "--status", type=str,
47
+ help="Optional initial status: defaults to 'not-done'.",
48
+ choices=SUPPORTED_STATUS, default=SUPPORTED_STATUS[0])
49
+
50
+ # optional flag for indicating initial priority
51
+ new_pars.add_argument(
52
+ "-p", "--priority", type=str,
53
+ help="Optional priority: defaults to 'low'.",
54
+ choices=SUPPORTED_PRIORITIES, default=SUPPORTED_PRIORITIES[0])
55
+
56
+ # assign 'new' method from TasksManager to func attr of the Namespace returned by parser
57
+ new_pars.set_defaults(func=self.tasks_manager.new)
58
+
59
+ # 'UPDATE' command
60
+ # ====================
61
+ update_pars = sub_pars.add_parser("update", help="Update a task.", argument_default=SUPPRESS)
62
+
63
+ update_pars.add_argument(
64
+ "id", type=str,
65
+ help="ID of the task to be updated.")
66
+
67
+ update_pars.add_argument(
68
+ "-d", "--description", metavar="new-description", type=str,
69
+ help="Update description.")
70
+
71
+ update_pars.add_argument(
72
+ "-p", "--priority", metavar="new-priority",
73
+ type=str, help="Update priority.")
74
+
75
+ update_pars.set_defaults(func=self.tasks_manager.update)
76
+
77
+ # 'MARK' command
78
+ # ====================
79
+ mark_pars = sub_pars.add_parser("mark", help="Mark a task with a new status.")
80
+
81
+ mark_pars.add_argument(
82
+ "id", type=str,
83
+ help="ID of the task to be marked.")
84
+
85
+ mark_pars.add_argument(
86
+ "new_status", type=str,
87
+ help="The new status for the task.",
88
+ choices=SUPPORTED_STATUS)
89
+
90
+ mark_pars.set_defaults(func=self.tasks_manager.mark)
91
+
92
+ # 'DELETE' command
93
+ # ====================
94
+ del_pars = sub_pars.add_parser("delete", help="Deletes a task.")
95
+
96
+ del_pars.add_argument(
97
+ "ids", type=str, nargs='+', metavar='id',
98
+ help="IDs of the tasks to be deleted.")
99
+
100
+ del_pars.add_argument(
101
+ "-f", "--forced", action="store_true",
102
+ help="If not used, the user will be prompted for confirmation.")
103
+
104
+ del_pars.set_defaults(func=self.tasks_manager.delete)
105
+
106
+ # 'LIST' command
107
+ # ====================
108
+ list_pars = sub_pars.add_parser("list", help="List all tasks.")
109
+
110
+ list_pars.add_argument(
111
+ "-s", "--status", type=str,
112
+ help="To list all tasks with a given status.",
113
+ choices=SUPPORTED_STATUS, default=None)
114
+
115
+ list_pars.add_argument(
116
+ "-p", "--priority", type=str,
117
+ help="To list all tasks with a given priority.",
118
+ choices=SUPPORTED_PRIORITIES, default=None)
119
+
120
+ list_pars.add_argument(
121
+ "--no-dates", action="store_true",
122
+ help="Not ouput dates.",)
123
+
124
+ list_pars.set_defaults(func=self.tasks_manager.list)
125
+
126
+ def start_parsing(self) -> bool:
127
+ """ Parses the arguments passed. Returns true if some command mutated state of any task. """
128
+ args: Namespace
129
+ try:
130
+ args = self.main_parser.parse_args()
131
+ except ValueError as exc:
132
+ self.main_parser.error(str(exc))
133
+ return False
134
+
135
+ if getattr(args, 'func', None) == self.tasks_manager.list:
136
+ filtered_ids = args.func(args)
137
+ print_table(self.tasks_manager.tasks, filtered_ids, no_dates=args.no_dates)
138
+ return False
139
+
140
+ if getattr(args, 'func', None) == self.tasks_manager.update:
141
+ has_update_target = hasattr(args, 'description') or hasattr(args, 'priority')
142
+ if not has_update_target:
143
+ self.main_parser.error("the update command requires at least one field change: --description or --priority")
144
+
145
+ if not hasattr(args, 'func'):
146
+ # in case user invokes the program without arguments like:
147
+ # >>> easydone
148
+ self.main_parser.print_help()
149
+ return False
150
+ else:
151
+ try:
152
+ args.func(args)
153
+ return True
154
+ except (ValueError, KeyError, AttributeError, TypeError) as exc:
155
+ self.main_parser.error(str(exc))
easydone/format.py ADDED
@@ -0,0 +1,164 @@
1
+ """Output formatting helpers.
2
+
3
+ This module centralizes all user-facing presentation logic so the CLI
4
+ parsing and task-management logic remain separate. The primary function
5
+ `print_table` prints a table of tasks. It attempts to use Rich for nicely
6
+ styled tables and colors; when Rich is not available it falls back to a
7
+ plain-text table so the application remains dependency-light.
8
+ """
9
+
10
+ from typing import Dict, List, Any
11
+ from .storage import LoadingResult, LoadStatus, CURRENT_SCHEMA_VERSION
12
+ from . import __version__
13
+
14
+ try:
15
+ # Import the specific Rich helpers we need. If they are not available,
16
+ # the import will raise and the code will fall back to plain text.
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+ from rich.text import Text
20
+ RICH_AVAILABLE = True
21
+ except Exception:
22
+ RICH_AVAILABLE = False
23
+
24
+
25
+ def _plain_print(tasks: Dict[str, dict],
26
+ ids: List[str],
27
+ no_dates: bool = False
28
+ ) -> None:
29
+ """Plain-text fallback used when Rich is unavailable."""
30
+ print("====================================")
31
+ print("EasyDone: Task-Tracker")
32
+ print("====================================")
33
+ for task_id in ids:
34
+ # Use .get() so older or partially missing task dictionaries still print.
35
+ task = tasks[task_id]
36
+ desc = task.get('description', 'unknown')
37
+ prior = task.get('priority', 'unknown')
38
+ stat = task.get('status', 'unknown')
39
+ create = '[' + str(task.get('created-at', 'unknown')) + ']' if not no_dates else ""
40
+ update = '[' + str(task.get('updated-at', 'unknown')) + ']' if not no_dates else ""
41
+ print(f"ID: {task_id} \"{desc}\" [{prior}] [{stat}] {create} {update}")
42
+ print("====================================")
43
+
44
+ def print_table(tasks: Dict[str, dict],
45
+ ids: List[str],
46
+ no_dates: bool = False
47
+ ) -> None:
48
+ """Print a tasks table using Rich when available, otherwise use plain text.
49
+
50
+ Columns: ID, Description, Priority, Status, Created, Updated.
51
+ The status and priority values are highlighted in color when Rich is present.
52
+ """
53
+ if not ids:
54
+ print("No tasks to show.")
55
+ return
56
+
57
+ if not RICH_AVAILABLE:
58
+ _plain_print(tasks, ids, no_dates=no_dates)
59
+ return
60
+
61
+ # Build a Rich table in the presentation layer. This keeps formatting and
62
+ # colors separated from task logic and CLI argument parsing.
63
+ console = Console() # type: ignore
64
+ table = Table(show_header=True, header_style="bold magenta") # type: ignore
65
+ table.add_column("ID", style="dim", no_wrap=True)
66
+ table.add_column("Description")
67
+ table.add_column("Priority", no_wrap=True)
68
+ table.add_column("Status", no_wrap=True)
69
+ if not no_dates:
70
+ table.add_column("Created", no_wrap=True)
71
+ table.add_column("Updated", no_wrap=True)
72
+
73
+ priority_styles = {
74
+ "low": "dim",
75
+ "normal": "",
76
+ "high": "bold yellow",
77
+ "urgent": "bold red",
78
+ }
79
+ status_styles = {
80
+ "not-done": "yellow",
81
+ "in-progress": "cyan",
82
+ "done": "green",
83
+ }
84
+
85
+ for task_id in ids:
86
+ task = tasks[task_id]
87
+ desc = task.get('description', 'unknown')
88
+ prior = task.get('priority', 'unknown')
89
+ stat = task.get('status', 'unknown')
90
+ create: str
91
+ update: str
92
+ if not no_dates:
93
+ create = task.get('created-at', 'unknown')
94
+ update = task.get('updated-at', 'unknown')
95
+ update = '-' if update is None else update
96
+ table.add_row(
97
+ task_id,
98
+ Text(desc, overflow='ellipsis'), # type: ignore
99
+ Text(prior, style=priority_styles.get(prior, "")), # type: ignore
100
+ Text(stat, style=status_styles.get(stat, "")), # type: ignore
101
+ create,
102
+ update,
103
+ )
104
+ else:
105
+ table.add_row(
106
+ task_id,
107
+ Text(desc, overflow='ellipsis'), # type: ignore
108
+ Text(prior, style=priority_styles.get(prior, "")), # type: ignore
109
+ Text(stat, style=status_styles.get(stat, "")), # type: ignore
110
+ )
111
+
112
+ console.print(table)
113
+
114
+ def describe_load_result(result: LoadingResult) -> None:
115
+ msg = ""
116
+ if result.status is LoadStatus.MISSING:
117
+ msg = f"{result.file_path} does not exist. Starting with an empty task list."
118
+
119
+ if result.status is LoadStatus.CORRUPTED:
120
+ msg = f"Warning: {result.file_path} is unreadable or malformed."
121
+ if result.backup_path:
122
+ msg += f" A copy was saved to {result.backup_path} for review."
123
+ else:
124
+ msg += f" Unable to save corrupted file: ({result.backup_exception})"
125
+
126
+ lines = []
127
+ if result.schema_mismatch:
128
+ lines.append(
129
+ f"Found schema version {result.found_schema_version} "
130
+ f"(app expects {CURRENT_SCHEMA_VERSION})"
131
+ )
132
+ if result.app_mismatch:
133
+ lines.append(
134
+ f"written by EasyDone {result.found_app_version} "
135
+ f"(running Easydone {__version__})"
136
+ )
137
+
138
+ if lines:
139
+ msg = "Warning: " + " and ".join(lines) + "."
140
+
141
+ if msg:
142
+ style = 'yellow'
143
+ else:
144
+ style = 'green'
145
+ msg = "Tasks loaded successfully."
146
+
147
+ if RICH_AVAILABLE:
148
+ console = Console() # type: ignore
149
+ console.print(Text(msg, style=style)) # type: ignore
150
+ else:
151
+ print(msg)
152
+
153
+ def report_backup(backup_result: dict[str, Any]):
154
+ if backup_result['backup_path']:
155
+ text = "Backup succesfully done"
156
+ style = 'green'
157
+ else:
158
+ text = f"Warning: Couldn't backup ({backup_result['backup_exception']})"
159
+ style = 'yellow'
160
+ if RICH_AVAILABLE:
161
+ console = Console() # type: ignore
162
+ console.print(Text(text=text, style=style)) # type: ignore
163
+ else:
164
+ print(text)
easydone/logic.py ADDED
@@ -0,0 +1,94 @@
1
+ from argparse import Namespace
2
+ from random import randint
3
+ from datetime import datetime
4
+
5
+ class TasksManager():
6
+ """ A class to manage all tasks logic. """
7
+ def __init__(self, tasks_from_file:dict[str, dict]):
8
+ self.tasks = tasks_from_file # internal dict for tracking tasks
9
+ self.ID_SIZE = 3 # number of digits to generate for an ID
10
+
11
+ def new(self, args: Namespace):
12
+ """ Create a new task. """
13
+ self.tasks[self.task_id()] = {
14
+ "description": args.description,
15
+ "status": args.status,
16
+ "priority": args.priority,
17
+ "created-at": str(datetime.now()).split(" ")[0],
18
+ "updated-at": None
19
+ }
20
+
21
+ def update(self, args: Namespace):
22
+ """ Updates a task. """
23
+ if args.id not in self.tasks:
24
+ raise KeyError("Unexistent task")
25
+
26
+ task = self.tasks[args.id]
27
+ if hasattr(args, 'description') and args.description == task['description']:
28
+ raise ValueError("New description must be different from the current description.")
29
+
30
+ if hasattr(args, 'priority') and args.priority == task['priority']:
31
+ raise ValueError("New priority must be different from the current priority.")
32
+
33
+ if hasattr(args, 'description'):
34
+ task['description'] = args.description
35
+
36
+ if hasattr(args, 'priority'):
37
+ task['priority'] = args.priority
38
+
39
+ task['updated-at'] = str(datetime.now()).split(" ")[0]
40
+
41
+ def mark(self, args: Namespace):
42
+ """Marking task as done, not done or in progress"""
43
+ if (id := args.id) not in self.tasks:
44
+ raise KeyError("Unexistent task")
45
+
46
+ self.tasks[args.id]["status"] = args.new_status
47
+ self.tasks[args.id]["updated-at"] = str(datetime.now()).split(" ")[0]
48
+
49
+ def delete(self, args: Namespace):
50
+ """ Deletes a list of tasks. """
51
+ for id in args.ids:
52
+ if id not in self.tasks:
53
+ raise KeyError(f"Unexistent task ({id})")
54
+
55
+ for id in set(args.ids):
56
+ if not args.forced:
57
+ if not yes_no(f"are you sure u want to delete the task {id}:\"{self.tasks[id]['description']}\" ?"):
58
+ continue
59
+
60
+ self.tasks.pop(id)
61
+
62
+ def list(self, args: Namespace) -> list[str]:
63
+ """ Shows all tasks. Filtered according to args.status and args.priority. """
64
+ ids = []
65
+
66
+ s_filt = args.status
67
+ p_filt = args.priority
68
+ for key, value in self.tasks.items():
69
+ # check if task passes filters
70
+ if (s_filt is None or value['status'] == s_filt) and (p_filt is None or value['priority'] == p_filt):
71
+ ids.append(key)
72
+
73
+ return ids
74
+
75
+ def task_id(self) -> str:
76
+ """ Returns a random id, formed by digits, the number of digits is determined by self.ID_SIZE"""
77
+ id = None
78
+ # keeps generating until gets an id that's not already present.
79
+ while id is None or id in self.tasks:
80
+ id = ""
81
+ for _ in range(self.ID_SIZE):
82
+ id += str(randint(0, 9))
83
+ return id
84
+
85
+ def yes_no(prompt: str) -> bool:
86
+ """Asks the user a yes/no question and returns True for yes and False for no."""
87
+ while True:
88
+ response = input(prompt + " (y/n): ").strip().lower()
89
+ if response in ['y', 'yes']:
90
+ return True
91
+ elif response in ['n', 'no']:
92
+ return False
93
+ else:
94
+ print("Invalid input. Please enter 'y' or 'n'.")
easydone/storage.py ADDED
@@ -0,0 +1,178 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import shutil
5
+ import tempfile
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Optional, NamedTuple, Any
9
+ from enum import Enum
10
+
11
+ from . import __version__
12
+
13
+ CURRENT_SCHEMA_VERSION = 1
14
+
15
+ def default_storage_path() -> Path:
16
+ """Return a stable, user-scoped path for EasyDone task data.
17
+
18
+ Behavior and rationale:
19
+ - If the EASYDONE_DATA_FILE environment variable is set, use it. This allows CI, tests, and advanced users to redirect storage to a custom file.
20
+ - Otherwise choose a platform-appropriate per-user application data directory:
21
+ * Windows: %APPDATA% (e.g. C:/Users/<user>/AppData/Roaming)
22
+ * macOS: ~/Library/Application Support
23
+ * Linux/other: XDG_DATA_HOME or ~/.local/share as a fallback
24
+ - The final file is placed under <base>/easydone/tasks.json so the data is predictable and independent of the current working directory.
25
+ """
26
+ # Allow an explicit override for testing or advanced usage. Expand ~ if present.
27
+ custom_path = os.environ.get("EASYDONE_DATA_FILE")
28
+ if custom_path:
29
+ return Path(custom_path).expanduser()
30
+
31
+ # Select a sensible base directory depending on the platform. Using a
32
+ # per-user application data directory avoids scattering data files across
33
+ # arbitrary working directories and follows common OS conventions.
34
+ if os.name == "nt":
35
+ # On Windows prefer APPDATA; fall back to a reasonable user path when
36
+ # APPDATA is not available in the environment.
37
+ base_dir = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
38
+ elif sys.platform == "darwin":
39
+ # macOS convention for app data
40
+ base_dir = Path.home() / "Library" / "Application Support"
41
+ else:
42
+ # Follow XDG Base Directory Specification when possible, otherwise use
43
+ # ~/.local/share as a conventional fallback for Linux and other Unix-like OSes.
44
+ base_dir = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
45
+
46
+ # Keep the final data file path deterministic and easy to locate.
47
+ return base_dir / "easydone" / "tasks.json"
48
+
49
+ class LoadStatus(Enum):
50
+ OK = "ok"
51
+ MISSING = "missing"
52
+ CORRUPTED = "corrupted"
53
+
54
+ class LoadingResult(NamedTuple):
55
+ tasks: dict[str, dict] = {}
56
+ status: LoadStatus = LoadStatus.OK
57
+ file_path: Optional[Path] = None
58
+ backup_path: Optional[Path] = None
59
+ backup_exception: Optional[Exception] = None
60
+ found_schema_version: Optional[int] = None
61
+ found_app_version: Optional[str] = None
62
+ schema_mismatch: bool = False
63
+ app_mismatch: bool = False
64
+
65
+ class JSONHandler():
66
+ def __init__(self, json_file: Optional[str] = None):
67
+ """
68
+ Initialize a storage handler with a stable absolute data file path.
69
+ """
70
+ self.app_version = __version__
71
+ self.json_file = Path(json_file).expanduser() if json_file else default_storage_path()
72
+
73
+ def _quarantine_path(self) -> Path:
74
+ """ Returns a quarantine path. """
75
+ file_path = self.json_file
76
+ timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
77
+ quarantine_path = file_path.with_name(
78
+ f"{file_path.stem}.corrupted-{timestamp}{file_path.suffix}"
79
+ )
80
+ return quarantine_path
81
+
82
+ def _backup_path(self) -> Path:
83
+ """ Returns a backup path. """
84
+ file_path = self.json_file
85
+ backup_path = file_path.with_suffix(file_path.suffix + '.bak')
86
+ return backup_path
87
+
88
+ def _backup(self, quarantine=False) -> dict[str, Any]:
89
+ """
90
+ Backups a file, returns the backup path if succeed, else returns the exception raised.
91
+ """
92
+ try:
93
+ file_path = self.json_file
94
+ backup_path = self._quarantine_path() if quarantine else self._backup_path()
95
+ shutil.copy2(file_path, backup_path)
96
+ return {"backup_path": backup_path, "backup_exception": None}
97
+ except (PermissionError, MemoryError, FileNotFoundError) as exc:
98
+ # remove backup file if any error happened
99
+ backup_path.unlink(missing_ok=True) # type: ignore
100
+ return {"backup_path": None, "backup_exception": exc}
101
+
102
+ def load(self) -> LoadingResult:
103
+ """Load tasks from the JSON file."""
104
+ # load file's content into payload or handle exception
105
+ try:
106
+ with open(self.json_file, 'r', encoding='utf-8') as file:
107
+ payload = json.load(file)
108
+ except FileNotFoundError:
109
+ return LoadingResult(
110
+ tasks={}, status=LoadStatus.MISSING, file_path=self.json_file
111
+ )
112
+ except (json.JSONDecodeError, OSError, TypeError, ValueError):
113
+ backup_result = self._backup(quarantine=True)
114
+ return LoadingResult(
115
+ tasks={}, status=LoadStatus.CORRUPTED,
116
+ file_path=self.json_file, **backup_result
117
+ )
118
+
119
+ # unexpected format of content loaded
120
+ if not isinstance(payload, dict):
121
+ backup_result = self._backup(quarantine=True)
122
+ return LoadingResult(
123
+ tasks={}, status=LoadStatus.CORRUPTED,
124
+ file_path=self.json_file, **backup_result
125
+ )
126
+
127
+ # try to get metadata from dict loaded
128
+ tasks = payload.get("tasks")
129
+ schema_version = payload.get("schema_version", 0)
130
+ app_version = payload.get("app_version", "unknown")
131
+
132
+ # unexpected format of tasks
133
+ if not isinstance(tasks, dict):
134
+ backup_result = self._backup(quarantine=True)
135
+ return LoadingResult(
136
+ tasks={}, status=LoadStatus.CORRUPTED,
137
+ file_path=self.json_file, **backup_result
138
+ )
139
+
140
+ return LoadingResult(
141
+ tasks=tasks, status=LoadStatus.OK, file_path=self.json_file,
142
+ found_schema_version = schema_version,
143
+ found_app_version = app_version,
144
+ schema_mismatch = schema_version != CURRENT_SCHEMA_VERSION,
145
+ app_mismatch = app_version != self.app_version
146
+ )
147
+
148
+ def save(self, tasks: dict[str, dict]) -> dict[str, Any]:
149
+ """Persist tasks with metadata so upgrades can be reviewed safely. Returns a dict containing backup results."""
150
+ if not isinstance(tasks, dict):
151
+ raise TypeError("tasks must be a dictionary of task records")
152
+
153
+ self.json_file.parent.mkdir(parents=True, exist_ok=True)
154
+
155
+ payload = {
156
+ "schema_version": CURRENT_SCHEMA_VERSION,
157
+ "app_version": self.app_version,
158
+ "saved_at": datetime.now().date().isoformat(),
159
+ "tasks": tasks,
160
+ }
161
+
162
+ # Keep the last known-good file before touching it.
163
+ backup_result = self._backup()
164
+
165
+ # Write to a temp file in the SAME directory (matters: os.replace across
166
+ # filesystems isn't atomic), then swap it in as one step.
167
+ fd, tmp_path = tempfile.mkstemp(
168
+ dir=self.json_file.parent, prefix=".tasks-", suffix=".tmp"
169
+ )
170
+ try:
171
+ with os.fdopen(fd, "w", encoding="utf-8") as tmp_file:
172
+ json.dump(payload, tmp_file, indent=4)
173
+ os.replace(tmp_path, self.json_file) # atomic on POSIX AND Windows
174
+ except Exception:
175
+ os.unlink(tmp_path) # don't leave stray .tmp files on failure
176
+ raise
177
+
178
+ return backup_result
@@ -0,0 +1,270 @@
1
+ Metadata-Version: 2.4
2
+ Name: easydone
3
+ Version: 0.2.0
4
+ Summary: A CLI app made with python for easy tasks management.
5
+ Author-email: Pedro Alberto Rosquete Ares <rosquetearespedro06@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Pedro Alberto Rosquete Ares
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: Documentation, https://github.com/prares-dev/easydone.git
29
+ Project-URL: Homepage, https://github.com/prares-dev/easydone.git
30
+ Project-URL: Repository, https://github.com/prares-dev/easydone.git
31
+ Requires-Python: >=3.9
32
+ Description-Content-Type: text/markdown
33
+ License-File: LICENSE
34
+ Requires-Dist: rich>=13.0
35
+ Provides-Extra: dev
36
+ Requires-Dist: pytest>=8.0; extra == "dev"
37
+ Dynamic: license-file
38
+
39
+ # easydone - Task Tracker App
40
+
41
+ > A focused command-line task manager for turning a messy to-do list into a clear next action.
42
+
43
+ easydone is a lightweight Python CLI for creating, updating, completing, deleting, and filtering tasks directly from your terminal. Tasks are saved as readable JSON, and the storage layer now includes **automatic backups, corruption quarantine, and atomic writes** to keep your data safe.
44
+
45
+ ## Why easydone?
46
+
47
+ - Fast terminal-first workflow
48
+ - Statuses for work in motion: `not-done`, `in-progress`, and `done`
49
+ - Four priority levels: `low`, `normal`, `high`, and `urgent`
50
+ - Filter tasks by status, priority, or both
51
+ - Human-readable JSON storage with no database setup
52
+ - **Bulletproof data handling**: atomic writes, automatic `.bak` backups, and quarantine of corrupted files
53
+ - Pretty terminal output with [Rich](https://github.com/Textualize/rich) (falls back to plain text if unavailable)
54
+
55
+ ## Project Structure
56
+
57
+ ```text
58
+ easydone/
59
+ ├── easydone/
60
+ │ ├── __init__.py # Package metadata (version, etc.)
61
+ │ ├── __main__.py # Application entry point
62
+ │ ├── cli.py # Argument parser and command dispatch
63
+ │ ├── logic.py # Task-management operations
64
+ │ ├── storage.py # JSON loading/saving with backup & quarantine
65
+ │ └── format.py # Output formatting (Rich / plain)
66
+ ├── tests/
67
+ │ ├── test_cli.py
68
+ │ ├── test_logic.py
69
+ │ ├── test_storage.py
70
+ │ └── test_format.py
71
+ ├── LICENSE
72
+ ├── pyproject.toml # Packaging and pytest configuration
73
+ └── README.md
74
+ ```
75
+
76
+ ## Quick Start
77
+
78
+ ### Requirements
79
+
80
+ - Python 3.9 or newer
81
+ - Windows PowerShell, macOS, or Linux terminal
82
+
83
+ From the project root, create a virtual environment and install `easydone` in editable mode:
84
+
85
+ ```powershell
86
+ py -m venv .venv
87
+ .\.venv\Scripts\Activate.ps1
88
+ py -m pip install -e .
89
+ ```
90
+
91
+ On macOS or Linux:
92
+
93
+ ```bash
94
+ python3 -m venv .venv
95
+ source .venv/bin/activate
96
+ python -m pip install -e .
97
+ ```
98
+
99
+ Or install directly from PyPI:
100
+
101
+ ```powershell
102
+ py -m pip install easydone
103
+ ```
104
+
105
+ You can now run the application:
106
+
107
+ ```shell
108
+ easydone
109
+ ```
110
+
111
+ To see all available commands:
112
+
113
+ ```shell
114
+ easydone --help
115
+ ```
116
+
117
+ ## Everyday Workflow
118
+
119
+ Create a task:
120
+
121
+ ```shell
122
+ easydone new "Read a book"
123
+ ```
124
+
125
+ Create a task with a status and priority:
126
+
127
+ ```shell
128
+ easydone new "Finish project report" --status in-progress --priority high
129
+ ```
130
+
131
+ List everything:
132
+
133
+ ```shell
134
+ easydone list
135
+ ```
136
+
137
+ Focus on urgent unfinished work:
138
+
139
+ ```shell
140
+ easydone list --status not-done --priority urgent
141
+ ```
142
+
143
+ Mark a task as complete:
144
+
145
+ ```shell
146
+ easydone mark 123 done
147
+ ```
148
+
149
+ ## Command Reference
150
+
151
+ ### `new`
152
+
153
+ Create a task. The description is required.
154
+
155
+ ```shell
156
+ easydone new DESCRIPTION [--status STATUS] [--priority PRIORITY]
157
+ ```
158
+
159
+ Options:
160
+
161
+ - `-s`, `--status`: `not-done`, `done`, or `in-progress`; defaults to `not-done`
162
+ - `-p`, `--priority`: `low`, `normal`, `high`, or `urgent`; defaults to `low`
163
+
164
+ ### `update`
165
+
166
+ Change the description and/or priority of an existing task.
167
+ The `--priority` option now validates against the four allowed values.
168
+
169
+ ```shell
170
+ easydone update TASK_ID [--description NEW_DESCRIPTION] [--priority NEW_PRIORITY]
171
+ ```
172
+
173
+ Examples:
174
+
175
+ ```shell
176
+ easydone update 123 --description "Read a novel"
177
+ easydone update 123 --priority high
178
+ ```
179
+
180
+ ### `mark`
181
+
182
+ Change the status of an existing task.
183
+
184
+ ```shell
185
+ easydone mark TASK_ID new-status
186
+ ```
187
+
188
+ ### `delete`
189
+
190
+ Delete one or more existing tasks. easydone tasks for confirmation for each ID unless `-f` or `--forced` is used.
191
+
192
+ ```shell
193
+ easydone delete TASK_ID [TASK_ID ...]
194
+ easydone delete TASK_ID [TASK_ID ...] --forced
195
+ ```
196
+
197
+ If you supply multiple IDs, all of them are validated before any deletion occurs. If any ID is invalid, the entire operation is aborted and no tasks are removed.
198
+
199
+ ### `list`
200
+
201
+ List all tasks or filter them by status and priority. You can omit dates with the `--no-dates` option.
202
+
203
+ ```shell
204
+ easydone list [--status STATUS] [--priority PRIORITY] [--no-dates]
205
+ ```
206
+
207
+ When both filters are supplied, a task must match both of them.
208
+
209
+ ## Data Storage
210
+
211
+ `easydone` stores task data in a user‑scoped application directory so it does not depend on where the command is launched from.
212
+
213
+ - **Windows**: `%APPDATA%\easydone\tasks.json`
214
+ - **macOS**: `~/Library/Application Support/easydone/tasks.json`
215
+ - **Linux**: `~/.local/share/easydone/tasks.json`
216
+
217
+ The app writes a small metadata wrapper with the file schema version and the version of easydone that saved it. This makes future upgrades safer and compatibility warnings explicit.
218
+
219
+ ```json
220
+ {
221
+ "schema_version": 1,
222
+ "app_version": "0.2.0",
223
+ "saved_at": "2026-08-28",
224
+ "tasks": {
225
+ "123": {
226
+ "description": "Finish project report",
227
+ "status": "in-progress",
228
+ "priority": "high",
229
+ "created-at": "2026-08-28",
230
+ "updated-at": null
231
+ }
232
+ }
233
+ }
234
+ ```
235
+
236
+ ### Safety & Recovery
237
+
238
+ `easydone` now protects your data in three ways:
239
+
240
+ 1. **Atomic writes**: Every save writes to a temporary file first, then swaps it atomically. A crash mid‑write never leaves a half‑written file.
241
+ 2. **Automatic backups**: Before every save, the current `tasks.json` is copied to `tasks.json.bak`. If something goes wrong, you can restore from this backup.
242
+ 3. **Corruption quarantine**: If easydone encounters an unreadable or malformed file on load, it copies that file to `tasks.corrupted-<timestamp>.json` instead of discarding it. You can inspect the quarantined file and recover data manually.
243
+
244
+ If an older file is found (different schema or app version), `easydone` still loads it but prints a detailed warning so you can review the data before saving again.
245
+
246
+ ## Development
247
+
248
+ Install the development dependency group:
249
+
250
+ ```shell
251
+ py -m pip install -e ".[dev]"
252
+ ```
253
+
254
+ Run the complete test suite:
255
+
256
+ ```shell
257
+ py -m pytest
258
+ ```
259
+
260
+ Run a specific test module:
261
+
262
+ ```shell
263
+ py -m pytest tests/logic_test.py
264
+ py -m pytest tests/storage_test.py
265
+ py -m pytest tests/format_test.py
266
+ ```
267
+
268
+ ## License
269
+
270
+ This project is available under the license in [LICENSE](LICENSE).
@@ -0,0 +1,12 @@
1
+ easydone/__init__.py,sha256=jfQbh7XOfARzUhtfwmWJ0n7_kINOSPZGPJpd-u6-55U,336
2
+ easydone/__main__.py,sha256=_X3PblOW4riqT1E7Jk1hKfgn6_Kooon8OeoN3lIsVLU,780
3
+ easydone/cli.py,sha256=IJbDOvjFrE37GeS5WY-UT20ZsZchzJuHzIdT_lSeI0I,6253
4
+ easydone/format.py,sha256=BiR-SoN4OpweG_HTZfGTG3dDcQQ00HpsFtJFu35HCBE,6116
5
+ easydone/logic.py,sha256=WpPl-UwTIN-1x0aZ1FR3-D6kn39XFqd0ameds7R6IZY,3656
6
+ easydone/storage.py,sha256=9wKoy6PQ2Htb60drp_y2Sq2F7BO0sSJklF9lNORgwkg,7582
7
+ easydone-0.2.0.dist-info/licenses/LICENSE,sha256=r6Bn3fQKU3hdDQE6nnYXNpI2sWnROJSE4BWlpRRNeh4,1105
8
+ easydone-0.2.0.dist-info/METADATA,sha256=MakHe69sr7FMAPIOdtXg9Bc_-EiK9bVzU8mE1mw7RAY,8202
9
+ easydone-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ easydone-0.2.0.dist-info/entry_points.txt,sha256=d9D0I3gq_ultiFP0U7czEj_k5EVkubxsST7ejbFTnoQ,52
11
+ easydone-0.2.0.dist-info/top_level.txt,sha256=PR_U4pT1FjXHw5vP9d2sggbqPSnx-SS_IcvXv3g2LPQ,9
12
+ easydone-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ easydone = easydone.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pedro Alberto Rosquete Ares
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ easydone