lazyplan 0.1.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.
lazyplan/__init__.py ADDED
File without changes
lazyplan/app.py ADDED
@@ -0,0 +1,102 @@
1
+
2
+ from textual.theme import Theme
3
+ from textual.app import App
4
+
5
+ from lazyplan.models import Project
6
+ from lazyplan.storage import load_projects, save_projects, upsert_project, delete_project
7
+ from lazyplan.screens.main import MainScreen
8
+ from lazyplan.screens.detail import DetailScreen
9
+ from lazyplan.screens.editor import EditorScreen
10
+ from lazyplan.screens.confirm_delete import ConfirmDeleteScreen
11
+ from lazyplan.screens.github_screen import GithubScreen
12
+
13
+ arctic_theme = Theme(
14
+ name="onyx-violet",
15
+ primary="#A78BFA",
16
+ secondary="#7C3AED",
17
+ accent="#C4B5FD",
18
+ background="#0C0C10",
19
+ surface="#16151D",
20
+ panel="#100F17",
21
+ foreground="#EDEDF0",
22
+ boost="#0A0912",
23
+ success="#4ADE80",
24
+ warning="#FCD34D",
25
+ error="#F87171",
26
+ dark=True,
27
+ variables={
28
+ "block-cursor-text-style": "none",
29
+ "footer-key-foreground": "#A78BFA",
30
+ "input-selection-background": "#A78BFA 35%",
31
+ }
32
+ )
33
+
34
+
35
+ class LazyPlanApp(App):
36
+ """TUI para capturar y gestionar ideas de proyectos."""
37
+
38
+ CSS_PATH = "lazyplan.tcss"
39
+ TITLE = "LazyPlan"
40
+ DARK = True
41
+
42
+ # SCREENS = {
43
+ # "main": MainScreen,
44
+ # "detail": DetailScreen,
45
+ # "editor": EditorScreen,
46
+ # "confirm_delete": ConfirmDeleteScreen,
47
+ # "github": GithubScreen,
48
+ # }
49
+
50
+ def __init__(self):
51
+ super().__init__()
52
+ self._projects = load_projects()
53
+
54
+ def on_mount(self) -> None:
55
+ self.register_theme(arctic_theme)
56
+ self.theme = "onyx-violet"
57
+ self.push_screen(MainScreen(self._projects))
58
+
59
+ # ── Screen helpers ────────────────────────────────────────────────────────
60
+
61
+ def push_screen(self, screen, *args, **kwargs):
62
+ """Override para pasar argumentos a las screens por nombre."""
63
+ if isinstance(screen, str):
64
+ name = screen
65
+ if name == "detail" and args:
66
+ screen = DetailScreen(args[0])
67
+ elif name == "editor":
68
+ screen = EditorScreen(args[0] if args else None)
69
+ elif name == "confirm_delete" and args:
70
+ screen = ConfirmDeleteScreen(args[0])
71
+ elif name == "github" and args:
72
+ screen = GithubScreen(args[0])
73
+ else:
74
+ screen = self.SCREENS[name]()
75
+ return super().push_screen(screen, **kwargs)
76
+
77
+ # ── Data operations (llamadas desde cualquier screen) ────────────────────
78
+
79
+ def save_project(self, project: Project) -> None:
80
+ self._projects = upsert_project(self._projects, project)
81
+ save_projects(self._projects)
82
+ self._refresh_main()
83
+
84
+ def delete_project(self, project_id: str) -> None:
85
+ self._projects = delete_project(self._projects, project_id)
86
+ save_projects(self._projects)
87
+ self._refresh_main()
88
+
89
+ def _refresh_main(self) -> None:
90
+ """Actualiza la lista en MainScreen si está en el stack."""
91
+ for screen in self.screen_stack:
92
+ if isinstance(screen, MainScreen):
93
+ screen.refresh_projects(self._projects)
94
+ break
95
+
96
+
97
+ def run():
98
+ LazyPlanApp().run()
99
+
100
+
101
+ if __name__ == "__main__":
102
+ run()
lazyplan/cli.py ADDED
@@ -0,0 +1,53 @@
1
+ import typer
2
+ from lazyplan.storage import load_projects, save_projects, upsert_project
3
+ from lazyplan.models import Project, Status
4
+
5
+ app = typer.Typer(
6
+ name="lazyplan",
7
+ help="TUI para capturar y gestionar ideas de proyectos.",
8
+ no_args_is_help=False,
9
+ )
10
+
11
+
12
+ @app.callback(invoke_without_command=True)
13
+ def default(ctx: typer.Context):
14
+ """Abre la TUI si no se pasa ningún subcomando."""
15
+ if ctx.invoked_subcommand is None:
16
+ from lazyplan.app import LazyPlanApp
17
+ LazyPlanApp().run()
18
+
19
+
20
+ @app.command()
21
+ def new(
22
+ title: str = typer.Option(..., "-t", "--title", help="Título del proyecto"),
23
+ stack: str = typer.Option("", "-s", "--stack", help="Stack separado por comas"),
24
+ desc: str = typer.Option("", "-d", "--desc", help="Descripción"),
25
+ status: str = typer.Option("cruda", "--status", help="Estado inicial"),
26
+ ):
27
+ """Guarda un proyecto rápido sin abrir la TUI."""
28
+ stack_list = [s.strip() for s in stack.split(",") if s.strip()]
29
+ try:
30
+ st = Status(status)
31
+ except ValueError:
32
+ st = Status.CRUDA
33
+
34
+ project = Project(title=title, description=desc, stack=stack_list, status=st)
35
+ projects = load_projects()
36
+ projects = upsert_project(projects, project)
37
+ save_projects(projects)
38
+ typer.echo(f"✅ Proyecto '{title}' guardado [ID: {project.id}]")
39
+
40
+
41
+ @app.command()
42
+ def ls():
43
+ """Lista los proyectos en la terminal (sin TUI)."""
44
+ projects = load_projects()
45
+ if not projects:
46
+ typer.echo("Sin proyectos todavía. Usá: lazyplan new -t 'Mi idea'")
47
+ return
48
+ for p in projects:
49
+ typer.echo(f" {p.id} {p.status_label} {p.title} [{p.stack_str}]")
50
+
51
+
52
+ if __name__ == "__main__":
53
+ app()
lazyplan/github.py ADDED
@@ -0,0 +1,126 @@
1
+ """
2
+ Integración con GitHub CLI (gh) para LazyPlan.
3
+ Permite crear repos y hacer push inicial desde la TUI.
4
+ """
5
+
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+
11
+ @dataclass
12
+ class GitHubResult:
13
+ success: bool
14
+ url: str = ""
15
+ error: str = ""
16
+
17
+
18
+ def gh_available() -> bool:
19
+ """Devuelve True si gh está instalado y autenticado."""
20
+ try:
21
+ result = subprocess.run(
22
+ ["gh", "auth", "status"],
23
+ capture_output=True, text=True
24
+ )
25
+ return result.returncode == 0
26
+ except FileNotFoundError:
27
+ return False
28
+
29
+
30
+ def gh_username() -> str:
31
+ """Devuelve el username de GitHub autenticado."""
32
+ try:
33
+ result = subprocess.run(
34
+ ["gh", "api", "user", "--jq", ".login"],
35
+ capture_output=True, text=True
36
+ )
37
+ return result.stdout.strip()
38
+ except Exception:
39
+ return ""
40
+
41
+
42
+ def create_github_repo(
43
+ title: str,
44
+ description: str = "",
45
+ private: bool = True,
46
+ folder_path: str = "",
47
+ ) -> GitHubResult:
48
+ """
49
+ Crea un repo en GitHub y hace push inicial si hay carpeta.
50
+
51
+ Args:
52
+ title: Nombre del proyecto (se convierte a slug)
53
+ description: Descripción del proyecto
54
+ private: True = privado, False = público
55
+ folder_path: Ruta local del proyecto para git init + push
56
+
57
+ Returns:
58
+ GitHubResult con success, url y error
59
+ """
60
+ slug = _to_slug(title)
61
+ visibility = "--private" if private else "--public"
62
+
63
+ # 1. Crear el repo en GitHub
64
+ cmd = ["gh", "repo", "create", slug, visibility, "--confirm"]
65
+ if description:
66
+ cmd += ["--description", description[:255]]
67
+
68
+ result = subprocess.run(cmd, capture_output=True, text=True)
69
+ if result.returncode != 0:
70
+ return GitHubResult(
71
+ success=False,
72
+ error=result.stderr.strip() or "Error al crear el repositorio"
73
+ )
74
+
75
+ # URL del repo
76
+ username = gh_username()
77
+ repo_url = f"https://github.com/{username}/{slug}"
78
+
79
+ # 2. Si hay carpeta local, inicializar git y hacer push
80
+ if folder_path:
81
+ folder = Path(folder_path)
82
+ if folder.exists():
83
+ push_result = _git_init_and_push(folder, repo_url, slug)
84
+ if not push_result.success:
85
+ # El repo se creó pero el push falló: no es fatal
86
+ return GitHubResult(
87
+ success=True,
88
+ url=repo_url,
89
+ error=f"Repo creado pero push falló: {push_result.error}"
90
+ )
91
+
92
+ return GitHubResult(success=True, url=repo_url)
93
+
94
+
95
+ def _git_init_and_push(folder: Path, repo_url: str, slug: str) -> GitHubResult:
96
+ """git init + add + commit + push en la carpeta dada."""
97
+ commands = [
98
+ ["git", "init"],
99
+ ["git", "add", "."],
100
+ ["git", "commit", "-m", "Initial commit – LazyPlan 💤"],
101
+ ["git", "branch", "-M", "main"],
102
+ ["git", "remote", "add", "origin", f"{repo_url}.git"],
103
+ ["git", "push", "-u", "origin", "main"],
104
+ ]
105
+
106
+ for cmd in commands:
107
+ result = subprocess.run(
108
+ cmd, cwd=str(folder), capture_output=True, text=True
109
+ )
110
+ if result.returncode != 0:
111
+ return GitHubResult(
112
+ success=False,
113
+ error=f"Falló '{' '.join(cmd)}': {result.stderr.strip()}"
114
+ )
115
+
116
+ return GitHubResult(success=True, url=repo_url)
117
+
118
+
119
+ def _to_slug(title: str) -> str:
120
+ """Convierte un título a slug válido para GitHub."""
121
+ import re
122
+ slug = title.lower().strip()
123
+ slug = re.sub(r"[^\w\s-]", "", slug)
124
+ slug = re.sub(r"[\s_]+", "-", slug)
125
+ slug = re.sub(r"-+", "-", slug).strip("-")
126
+ return slug or "mi-proyecto"
lazyplan/lazyplan.tcss ADDED
@@ -0,0 +1,332 @@
1
+
2
+
3
+
4
+
5
+ /* ── Global ─────────────────────────────────────────────── */
6
+ Screen {
7
+ background: $background;
8
+ overflow-y: auto;
9
+ }
10
+ /* ── Main screen ────────────────────────────────────────── */
11
+ #main-layout {
12
+ height: 1fr;
13
+ }
14
+
15
+ #app-title {
16
+ color: $primary;
17
+ text-style: bold;
18
+ height: 1;
19
+ margin-bottom: 1;
20
+ margin-top: 1;
21
+ }
22
+
23
+
24
+ #search-bar {
25
+ height: 3;
26
+ padding: 0 1;
27
+ margin-bottom: 1;
28
+ }
29
+ #search {
30
+ width: 1fr;
31
+ border: round $secondary;
32
+ background: $background;
33
+ &:focus {
34
+ background-tint: $background;
35
+ }
36
+ }
37
+
38
+ #filter-bar {
39
+ height: 1;
40
+ padding: 0 2;
41
+ align: left middle;
42
+ }
43
+ #filter-label {
44
+ margin-right: 1;
45
+ color: $foreground;
46
+ }
47
+ .filter-btn {
48
+ padding: 0 2;
49
+ margin-right: 1;
50
+ color: $foreground;
51
+ }
52
+ .filter-btn.active {
53
+ color: $primary;
54
+ text-style: bold underline;
55
+ background: $boost;
56
+ }
57
+
58
+ #project-table {
59
+ height: 1fr;
60
+ border: round $secondary;
61
+ margin-bottom: 1;
62
+ margin-left: 1;
63
+ margin-right: 1;
64
+ background: $background;
65
+ &:focus {
66
+ background-tint: $background;
67
+ }
68
+ }
69
+ #status-bar {
70
+ padding: 0 2;
71
+ color: $foreground;
72
+ margin-bottom: 1;
73
+ }
74
+
75
+
76
+ /* ── Detail screen ──────────────────────────────────────── */
77
+ #detail-scroll {
78
+ height: 1fr;
79
+ overflow-y: auto;
80
+ background: $background;
81
+ }
82
+ #detail-content {
83
+ padding: 1 2;
84
+ height: auto;
85
+ }
86
+ /* Bloque 1 — Header */
87
+ #detail-header {
88
+ height: 3;
89
+ background: $boost;
90
+ border: round $secondary;
91
+ margin-bottom: 1;
92
+ }
93
+
94
+ #detail-title {
95
+ color: #FFF;
96
+ text-style: bold;
97
+ width: 1fr;
98
+ content-align: left middle;
99
+ }
100
+
101
+ #status-badge {
102
+ color: $primary;
103
+ text-style: bold;
104
+ content-align: right middle;
105
+ width: 1fr;
106
+ padding: 0 1;
107
+ }
108
+ #badges {
109
+ height: 2;
110
+ margin-bottom: 0;
111
+ background: $boost;
112
+ }
113
+
114
+ #id-badge {
115
+ color: $foreground;
116
+ }
117
+ /* Bloque 2 — Descripción */
118
+ #desc-block {
119
+ border: round $secondary;
120
+ background: $boost;
121
+ margin-bottom: 1;
122
+ padding: 0 2 0 2;
123
+ height: auto;
124
+ max-height: 10;
125
+ }
126
+ /* Bloque 3 — Info + Metadatos */
127
+ #info-block {
128
+ height: auto;
129
+ margin-bottom: 1;
130
+ }
131
+ /* Bloque 4 — Links + Fechas */
132
+ #links-block {
133
+ height: auto;
134
+ margin-bottom: 1;
135
+ }
136
+
137
+ /* Columnas compartidas */
138
+ .detail-col {
139
+ width: 1fr;
140
+ border: round $secondary;
141
+ background: $boost;
142
+ padding: 0 2;
143
+ height: auto;
144
+ max-height: 100%;
145
+ }
146
+ /* Títulos de bloque */
147
+ .block-title {
148
+ color: $foreground;
149
+ text-style: bold;
150
+ border-bottom: tall $secondary;
151
+ }
152
+
153
+ /* Campos */
154
+ .field-label {
155
+ color: $secondary;
156
+ height: 1;
157
+ margin-top: 1;
158
+ }
159
+
160
+ .field-value {
161
+ color: $foreground;
162
+ margin-bottom: 1;
163
+ }
164
+
165
+ #detail-desc {
166
+ color: $foreground;
167
+ margin-top: 1;
168
+ margin-bottom: 1;
169
+ }
170
+
171
+ #detail-links {
172
+ color: $foreground;
173
+ margin-bottom: 1;
174
+ }
175
+
176
+ #detail-hint {
177
+ margin-top: 1;
178
+ color: $secondary;
179
+ text-align: center;
180
+ height: 1;
181
+ }
182
+ /* ── Editor screen ──────────────────────────────────────── */
183
+ #editor-scroll {
184
+ height: 1fr;
185
+ overflow-y: auto;
186
+ background: $background;
187
+ }
188
+ #editor-form {
189
+ padding: 1 2;
190
+ height: auto;
191
+ }
192
+ #editor-title {
193
+ background: $boost;
194
+ color: $foreground;
195
+ text-style: bold;
196
+ padding: 0 2;
197
+ height: 1;
198
+ margin-bottom: 1;
199
+ }
200
+ .form-label {
201
+ text-style: bold;
202
+ color: $foreground;
203
+ margin-top: 1;
204
+ height: 1;
205
+ }
206
+ .form-hint {
207
+ color: $foreground;
208
+ text-style: italic;
209
+ padding: 0 2;
210
+ margin-bottom: 1;
211
+ }
212
+ #input-title {
213
+ margin-bottom: 0;
214
+ }
215
+ #input-desc {
216
+ height: 8;
217
+ border: tall $secondary;
218
+ margin-bottom: 0;
219
+ }
220
+ #input-stack {
221
+ margin-bottom: 0;
222
+ }
223
+ #input-status {
224
+ margin-bottom: 0;
225
+ }
226
+ #input-links {
227
+ margin-bottom: 0;
228
+ }
229
+ #form-error {
230
+ height: 1;
231
+ margin-top: 1;
232
+ }
233
+ #form-buttons {
234
+ margin-top: 2;
235
+ height: 3;
236
+ align: left middle;
237
+ }
238
+ #form-buttons Button {
239
+ margin-right: 2;
240
+ }
241
+ /* ── Confirm delete modal ───────────────────────────────── */
242
+ ConfirmDeleteScreen {
243
+ align: center middle;
244
+ }
245
+ #confirm-dialog {
246
+ width: 50;
247
+ height: auto;
248
+ padding: 2 3;
249
+ border: thick $error;
250
+ background: $surface;
251
+ }
252
+ #confirm-title {
253
+ text-style: bold;
254
+ color: $error;
255
+ margin-bottom: 1;
256
+ height: 1;
257
+ }
258
+ #confirm-body {
259
+ margin-bottom: 2;
260
+ }
261
+ #confirm-buttons {
262
+ height: 3;
263
+ align: left middle;
264
+ }
265
+ #confirm-buttons Button {
266
+ margin-right: 2;
267
+ }
268
+
269
+ /* ── GitHub modal ───────────────────────────────────────── */
270
+
271
+ GithubScreen {
272
+ align: center middle;
273
+ }
274
+
275
+ #gh-dialog {
276
+ width: 60;
277
+ height: auto;
278
+ padding: 2 3;
279
+ border: thick #A78BFA;
280
+ background: #16151D;
281
+ }
282
+
283
+ #gh-title {
284
+ text-style: bold;
285
+ color: #A78BFA;
286
+ margin-bottom: 1;
287
+ height: 1;
288
+ }
289
+
290
+ #gh-project-label {
291
+ color: #EDEDF0;
292
+ margin-bottom: 0;
293
+ height: 1;
294
+ }
295
+
296
+ #gh-slug-label {
297
+ color: #5C5A6E;
298
+ margin-bottom: 1;
299
+ height: 1;
300
+ }
301
+
302
+ #gh-private-row {
303
+ height: 3;
304
+ align: left middle;
305
+ margin-bottom: 1;
306
+ }
307
+
308
+ #gh-private-label {
309
+ color: #EDEDF0;
310
+ margin-right: 2;
311
+ width: auto;
312
+ content-align: left middle;
313
+ }
314
+
315
+ #gh-status {
316
+ height: 1;
317
+ margin-bottom: 1;
318
+ }
319
+
320
+ #gh-error-body {
321
+ color: #EDEDF0;
322
+ margin-bottom: 2;
323
+ }
324
+
325
+ #gh-buttons {
326
+ height: 3;
327
+ align: left middle;
328
+ }
329
+
330
+ #gh-buttons Button {
331
+ margin-right: 2;
332
+ }
lazyplan/models.py ADDED
@@ -0,0 +1,44 @@
1
+ from dataclasses import dataclass, field
2
+ from datetime import datetime
3
+ from enum import Enum
4
+ import uuid
5
+
6
+
7
+ class Status(str, Enum):
8
+ CRUDA = "cruda"
9
+ ACTIVA = "activa"
10
+ PAUSADA = "pausada"
11
+ DESCARTADA = "descartada"
12
+
13
+
14
+ STATUS_LABELS = {
15
+ Status.CRUDA: "🟡 Cruda",
16
+ Status.ACTIVA: "🟢 Activa",
17
+ Status.PAUSADA: "⚪ Pausada",
18
+ Status.DESCARTADA: "🔴 Descartada",
19
+ }
20
+
21
+
22
+ @dataclass
23
+ class Project:
24
+ title: str
25
+ id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
26
+ description: str = ""
27
+ stack: list[str] = field(default_factory=list)
28
+ status: Status = Status.CRUDA
29
+ links: list[str] = field(default_factory=list)
30
+ github_url: str = ""
31
+ folder_path: str = ""
32
+ created_at: str = field(default_factory=lambda: datetime.now().isoformat())
33
+ updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
34
+
35
+ def touch(self):
36
+ self.updated_at = datetime.now().isoformat()
37
+
38
+ @property
39
+ def stack_str(self) -> str:
40
+ return ", ".join(self.stack) if self.stack else "—"
41
+
42
+ @property
43
+ def status_label(self) -> str:
44
+ return STATUS_LABELS.get(self.status, self.status)
File without changes
@@ -0,0 +1,20 @@
1
+
2
+ from typing import TYPE_CHECKING, cast
3
+ from textual.screen import ModalScreen, Screen
4
+
5
+ if TYPE_CHECKING:
6
+ from lazyplan.app import LazyPlanApp
7
+
8
+ class BaseScreen(Screen):
9
+
10
+ # Esto es una propiedad para acceder al app como LazyPlanApp,
11
+ # y así tener acceso a los métodos y propiedades específicos de la app.
12
+ @property
13
+ def lazyplan(self) -> "LazyPlanApp":
14
+ return cast("LazyPlanApp", self.app)
15
+
16
+
17
+ class LazyPlanModalScreen(ModalScreen):
18
+ @property
19
+ def app(self) -> "LazyPlanApp":
20
+ return cast("LazyPlanApp", super().app)