lestash 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.
- lestash/__init__.py +3 -0
- lestash/__main__.py +6 -0
- lestash/cli/__init__.py +1 -0
- lestash/cli/config.py +85 -0
- lestash/cli/items.py +191 -0
- lestash/cli/main.py +56 -0
- lestash/cli/sources.py +222 -0
- lestash/core/__init__.py +10 -0
- lestash/core/config.py +108 -0
- lestash/core/database.py +236 -0
- lestash/core/exceptions.py +21 -0
- lestash/core/logging.py +252 -0
- lestash/models/__init__.py +7 -0
- lestash/models/item.py +46 -0
- lestash/models/source.py +26 -0
- lestash/models/tag.py +10 -0
- lestash/plugins/__init__.py +6 -0
- lestash/plugins/base.py +55 -0
- lestash/plugins/loader.py +29 -0
- lestash-0.1.0.dist-info/METADATA +152 -0
- lestash-0.1.0.dist-info/RECORD +23 -0
- lestash-0.1.0.dist-info/WHEEL +4 -0
- lestash-0.1.0.dist-info/entry_points.txt +2 -0
lestash/__init__.py
ADDED
lestash/__main__.py
ADDED
lestash/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI module for Le Stash."""
|
lestash/cli/config.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Config commands for Le Stash CLI."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from lestash.core.config import Config, get_config_path, init_config
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="Manage configuration.")
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("show")
|
|
15
|
+
def show_config() -> None:
|
|
16
|
+
"""Show current configuration."""
|
|
17
|
+
config_path = get_config_path()
|
|
18
|
+
|
|
19
|
+
if not config_path.exists():
|
|
20
|
+
console.print(f"[dim]No config file at {config_path}[/dim]")
|
|
21
|
+
console.print("[dim]Run 'lestash config init' to create one.[/dim]")
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
config = Config.load()
|
|
25
|
+
console.print(f"[bold]Config file:[/bold] {config_path}")
|
|
26
|
+
console.print()
|
|
27
|
+
|
|
28
|
+
data = config.model_dump()
|
|
29
|
+
for section, values in data.items():
|
|
30
|
+
console.print(f"[bold][{section}][/bold]")
|
|
31
|
+
if isinstance(values, dict):
|
|
32
|
+
for key, value in values.items():
|
|
33
|
+
console.print(f" {key} = {value}")
|
|
34
|
+
else:
|
|
35
|
+
console.print(f" {values}")
|
|
36
|
+
console.print()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command("init")
|
|
40
|
+
def init_config_cmd(
|
|
41
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Overwrite existing config")] = False,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Initialize configuration file with defaults."""
|
|
44
|
+
config_path = get_config_path()
|
|
45
|
+
|
|
46
|
+
if config_path.exists() and not force:
|
|
47
|
+
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
|
|
48
|
+
console.print("[dim]Use --force to overwrite.[/dim]")
|
|
49
|
+
raise typer.Exit(1)
|
|
50
|
+
|
|
51
|
+
init_config()
|
|
52
|
+
console.print(f"[green]Created config at {config_path}[/green]")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command("set")
|
|
56
|
+
def set_config(
|
|
57
|
+
key: Annotated[str, typer.Argument(help="Config key (e.g., general.database_path)")],
|
|
58
|
+
value: Annotated[str, typer.Argument(help="Value to set")],
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Set a configuration value."""
|
|
61
|
+
config = Config.load()
|
|
62
|
+
|
|
63
|
+
parts = key.split(".")
|
|
64
|
+
if len(parts) != 2:
|
|
65
|
+
console.print("[red]Key must be in format 'section.key'[/red]")
|
|
66
|
+
raise typer.Exit(1)
|
|
67
|
+
|
|
68
|
+
section, setting = parts
|
|
69
|
+
|
|
70
|
+
# Get current config as dict
|
|
71
|
+
data = config.model_dump()
|
|
72
|
+
|
|
73
|
+
if section not in data:
|
|
74
|
+
data[section] = {}
|
|
75
|
+
|
|
76
|
+
data[section][setting] = value
|
|
77
|
+
|
|
78
|
+
# Save updated config
|
|
79
|
+
try:
|
|
80
|
+
updated_config = Config(**data)
|
|
81
|
+
updated_config.save()
|
|
82
|
+
console.print(f"[green]Set {key} = {value}[/green]")
|
|
83
|
+
except Exception as e:
|
|
84
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
85
|
+
raise typer.Exit(1) from None
|
lestash/cli/items.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Item commands for Le Stash CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from lestash.core.config import Config
|
|
11
|
+
from lestash.core.database import get_connection
|
|
12
|
+
from lestash.models.item import Item
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Manage items in your knowledge base.")
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command("list")
|
|
19
|
+
def list_items(
|
|
20
|
+
source: Annotated[
|
|
21
|
+
str | None, typer.Option("--source", "-s", help="Filter by source type")
|
|
22
|
+
] = None,
|
|
23
|
+
own: Annotated[bool | None, typer.Option("--own", help="Show only your own content")] = None,
|
|
24
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Limit results")] = 20,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""List items in the knowledge base."""
|
|
27
|
+
config = Config.load()
|
|
28
|
+
|
|
29
|
+
with get_connection(config) as conn:
|
|
30
|
+
query = "SELECT * FROM items WHERE 1=1"
|
|
31
|
+
params: list = []
|
|
32
|
+
|
|
33
|
+
if source:
|
|
34
|
+
query += " AND source_type = ?"
|
|
35
|
+
params.append(source)
|
|
36
|
+
|
|
37
|
+
if own is not None:
|
|
38
|
+
query += " AND is_own_content = ?"
|
|
39
|
+
params.append(own)
|
|
40
|
+
|
|
41
|
+
query += " ORDER BY created_at DESC LIMIT ?"
|
|
42
|
+
params.append(limit)
|
|
43
|
+
|
|
44
|
+
cursor = conn.execute(query, params)
|
|
45
|
+
rows = cursor.fetchall()
|
|
46
|
+
|
|
47
|
+
if not rows:
|
|
48
|
+
console.print("[dim]No items found.[/dim]")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
table = Table(show_header=True, header_style="bold")
|
|
52
|
+
table.add_column("ID", style="dim")
|
|
53
|
+
table.add_column("Source")
|
|
54
|
+
table.add_column("Title / Content Preview")
|
|
55
|
+
table.add_column("Author")
|
|
56
|
+
table.add_column("Created")
|
|
57
|
+
|
|
58
|
+
for row in rows:
|
|
59
|
+
item = Item.from_row(row)
|
|
60
|
+
preview = (
|
|
61
|
+
item.title or item.content[:50] + "..." if len(item.content) > 50 else item.content
|
|
62
|
+
)
|
|
63
|
+
created = item.created_at.strftime("%Y-%m-%d") if item.created_at else "-"
|
|
64
|
+
table.add_row(
|
|
65
|
+
str(item.id),
|
|
66
|
+
item.source_type,
|
|
67
|
+
preview,
|
|
68
|
+
item.author or "-",
|
|
69
|
+
created,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
console.print(table)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.command("search")
|
|
76
|
+
def search_items(
|
|
77
|
+
query: Annotated[str, typer.Argument(help="Search query")],
|
|
78
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Limit results")] = 20,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Search items using full-text search."""
|
|
81
|
+
config = Config.load()
|
|
82
|
+
|
|
83
|
+
with get_connection(config) as conn:
|
|
84
|
+
cursor = conn.execute(
|
|
85
|
+
"""
|
|
86
|
+
SELECT items.* FROM items
|
|
87
|
+
JOIN items_fts ON items.id = items_fts.rowid
|
|
88
|
+
WHERE items_fts MATCH ?
|
|
89
|
+
ORDER BY rank
|
|
90
|
+
LIMIT ?
|
|
91
|
+
""",
|
|
92
|
+
(query, limit),
|
|
93
|
+
)
|
|
94
|
+
rows = cursor.fetchall()
|
|
95
|
+
|
|
96
|
+
if not rows:
|
|
97
|
+
console.print(f"[dim]No items found matching '{query}'.[/dim]")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
table = Table(show_header=True, header_style="bold")
|
|
101
|
+
table.add_column("ID", style="dim")
|
|
102
|
+
table.add_column("Source")
|
|
103
|
+
table.add_column("Title / Content Preview")
|
|
104
|
+
table.add_column("Author")
|
|
105
|
+
|
|
106
|
+
for row in rows:
|
|
107
|
+
item = Item.from_row(row)
|
|
108
|
+
preview = (
|
|
109
|
+
item.title or item.content[:50] + "..." if len(item.content) > 50 else item.content
|
|
110
|
+
)
|
|
111
|
+
table.add_row(
|
|
112
|
+
str(item.id),
|
|
113
|
+
item.source_type,
|
|
114
|
+
preview,
|
|
115
|
+
item.author or "-",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
console.print(table)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command("show")
|
|
122
|
+
def show_item(
|
|
123
|
+
item_id: Annotated[int, typer.Argument(help="Item ID to show")],
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Show details of a specific item."""
|
|
126
|
+
config = Config.load()
|
|
127
|
+
|
|
128
|
+
with get_connection(config) as conn:
|
|
129
|
+
cursor = conn.execute("SELECT * FROM items WHERE id = ?", (item_id,))
|
|
130
|
+
row = cursor.fetchone()
|
|
131
|
+
|
|
132
|
+
if not row:
|
|
133
|
+
console.print(f"[red]Item {item_id} not found.[/red]")
|
|
134
|
+
raise typer.Exit(1)
|
|
135
|
+
|
|
136
|
+
item = Item.from_row(row)
|
|
137
|
+
|
|
138
|
+
console.print(f"[bold]ID:[/bold] {item.id}")
|
|
139
|
+
console.print(f"[bold]Source:[/bold] {item.source_type}")
|
|
140
|
+
if item.source_id:
|
|
141
|
+
console.print(f"[bold]Source ID:[/bold] {item.source_id}")
|
|
142
|
+
if item.url:
|
|
143
|
+
console.print(f"[bold]URL:[/bold] {item.url}")
|
|
144
|
+
if item.title:
|
|
145
|
+
console.print(f"[bold]Title:[/bold] {item.title}")
|
|
146
|
+
console.print(f"[bold]Author:[/bold] {item.author or '-'}")
|
|
147
|
+
if item.created_at:
|
|
148
|
+
console.print(f"[bold]Created:[/bold] {item.created_at}")
|
|
149
|
+
console.print(f"[bold]Fetched:[/bold] {item.fetched_at}")
|
|
150
|
+
console.print(f"[bold]Own Content:[/bold] {item.is_own_content}")
|
|
151
|
+
console.print()
|
|
152
|
+
console.print("[bold]Content:[/bold]")
|
|
153
|
+
console.print(item.content)
|
|
154
|
+
|
|
155
|
+
if item.metadata:
|
|
156
|
+
console.print()
|
|
157
|
+
console.print("[bold]Metadata:[/bold]")
|
|
158
|
+
console.print(json.dumps(item.metadata, indent=2))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command("export")
|
|
162
|
+
def export_items(
|
|
163
|
+
output: Annotated[
|
|
164
|
+
str, typer.Option("--output", "-o", help="Output file path")
|
|
165
|
+
] = "lestash-export.json",
|
|
166
|
+
source: Annotated[
|
|
167
|
+
str | None, typer.Option("--source", "-s", help="Filter by source type")
|
|
168
|
+
] = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Export items to JSON."""
|
|
171
|
+
config = Config.load()
|
|
172
|
+
|
|
173
|
+
with get_connection(config) as conn:
|
|
174
|
+
query = "SELECT * FROM items"
|
|
175
|
+
params: list = []
|
|
176
|
+
|
|
177
|
+
if source:
|
|
178
|
+
query += " WHERE source_type = ?"
|
|
179
|
+
params.append(source)
|
|
180
|
+
|
|
181
|
+
query += " ORDER BY created_at DESC"
|
|
182
|
+
|
|
183
|
+
cursor = conn.execute(query, params)
|
|
184
|
+
rows = cursor.fetchall()
|
|
185
|
+
|
|
186
|
+
items = [Item.from_row(row).model_dump(mode="json") for row in rows]
|
|
187
|
+
|
|
188
|
+
with open(output, "w") as f:
|
|
189
|
+
json.dump(items, f, indent=2, default=str)
|
|
190
|
+
|
|
191
|
+
console.print(f"[green]Exported {len(items)} items to {output}[/green]")
|
lestash/cli/main.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Main CLI for Le Stash."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from lestash import __version__
|
|
6
|
+
from lestash.cli import config, items, sources
|
|
7
|
+
from lestash.core.database import init_database
|
|
8
|
+
from lestash.core.logging import get_console, get_logger, setup_logging
|
|
9
|
+
from lestash.plugins.loader import load_plugins
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
name="lestash",
|
|
13
|
+
help="Le Stash - Personal knowledge base CLI.",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
)
|
|
16
|
+
console = get_console()
|
|
17
|
+
logger = get_logger("cli.main")
|
|
18
|
+
|
|
19
|
+
# Register core command groups
|
|
20
|
+
app.add_typer(items.app, name="items")
|
|
21
|
+
app.add_typer(sources.app, name="sources")
|
|
22
|
+
app.add_typer(config.app, name="config")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def register_plugin_commands() -> None:
|
|
26
|
+
"""Discover and register plugin commands."""
|
|
27
|
+
plugins = load_plugins()
|
|
28
|
+
for name, plugin in plugins.items():
|
|
29
|
+
try:
|
|
30
|
+
plugin_app = plugin.get_commands()
|
|
31
|
+
app.add_typer(plugin_app, name=name)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
logger.warning(f"Failed to register commands for '{name}': {e}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.callback()
|
|
37
|
+
def main(
|
|
38
|
+
version: bool = typer.Option(False, "--version", "-v", help="Show version and exit."),
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Le Stash - Personal knowledge base CLI."""
|
|
41
|
+
# Initialize logging first
|
|
42
|
+
setup_logging()
|
|
43
|
+
|
|
44
|
+
if version:
|
|
45
|
+
console.print(f"lestash {__version__}")
|
|
46
|
+
raise typer.Exit()
|
|
47
|
+
|
|
48
|
+
# Ensure database exists
|
|
49
|
+
init_database()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Register plugin commands at import time
|
|
53
|
+
register_plugin_commands()
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
app()
|
lestash/cli/sources.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Source commands for Le Stash CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from lestash.core.config import Config
|
|
12
|
+
from lestash.core.database import get_connection
|
|
13
|
+
from lestash.plugins.loader import load_plugins
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(help="Manage content sources.")
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command("list")
|
|
20
|
+
def list_sources() -> None:
|
|
21
|
+
"""List installed source plugins."""
|
|
22
|
+
plugins = load_plugins()
|
|
23
|
+
|
|
24
|
+
if not plugins:
|
|
25
|
+
console.print("[dim]No source plugins installed.[/dim]")
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
table = Table(show_header=True, header_style="bold")
|
|
29
|
+
table.add_column("Name")
|
|
30
|
+
table.add_column("Description")
|
|
31
|
+
table.add_column("Status")
|
|
32
|
+
|
|
33
|
+
config = Config.load()
|
|
34
|
+
with get_connection(config) as conn:
|
|
35
|
+
for name, plugin in plugins.items():
|
|
36
|
+
cursor = conn.execute(
|
|
37
|
+
"SELECT enabled, last_sync FROM sources WHERE source_type = ?",
|
|
38
|
+
(name,),
|
|
39
|
+
)
|
|
40
|
+
row = cursor.fetchone()
|
|
41
|
+
|
|
42
|
+
if row:
|
|
43
|
+
status = "enabled" if row["enabled"] else "disabled"
|
|
44
|
+
if row["last_sync"]:
|
|
45
|
+
status += f" (last sync: {row['last_sync']})"
|
|
46
|
+
else:
|
|
47
|
+
status = "not configured"
|
|
48
|
+
|
|
49
|
+
table.add_row(name, plugin.description, status)
|
|
50
|
+
|
|
51
|
+
console.print(table)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command("sync")
|
|
55
|
+
def sync_source(
|
|
56
|
+
source_name: Annotated[str | None, typer.Argument(help="Source to sync (omit for all)")] = None,
|
|
57
|
+
all_sources: Annotated[bool, typer.Option("--all", help="Sync all enabled sources")] = False,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Sync items from a source."""
|
|
60
|
+
plugins = load_plugins()
|
|
61
|
+
|
|
62
|
+
if not plugins:
|
|
63
|
+
console.print("[red]No source plugins installed.[/red]")
|
|
64
|
+
raise typer.Exit(1)
|
|
65
|
+
|
|
66
|
+
if source_name:
|
|
67
|
+
sources_to_sync = [source_name]
|
|
68
|
+
elif all_sources:
|
|
69
|
+
sources_to_sync = list(plugins.keys())
|
|
70
|
+
else:
|
|
71
|
+
console.print("[red]Specify a source name or use --all[/red]")
|
|
72
|
+
raise typer.Exit(1)
|
|
73
|
+
|
|
74
|
+
config = Config.load()
|
|
75
|
+
|
|
76
|
+
for name in sources_to_sync:
|
|
77
|
+
if name not in plugins:
|
|
78
|
+
console.print(f"[red]Unknown source: {name}[/red]")
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
plugin = plugins[name]
|
|
82
|
+
plugin_config = config.get_plugin_config(name)
|
|
83
|
+
|
|
84
|
+
console.print(f"[bold]Syncing {name}...[/bold]")
|
|
85
|
+
|
|
86
|
+
with get_connection(config) as conn:
|
|
87
|
+
# Log sync start
|
|
88
|
+
started_at = datetime.now()
|
|
89
|
+
cursor = conn.execute(
|
|
90
|
+
"INSERT INTO sync_log (source_type, started_at, status) VALUES (?, ?, ?)",
|
|
91
|
+
(name, started_at, "running"),
|
|
92
|
+
)
|
|
93
|
+
sync_id = cursor.lastrowid
|
|
94
|
+
conn.commit()
|
|
95
|
+
|
|
96
|
+
items_added = 0
|
|
97
|
+
items_updated = 0
|
|
98
|
+
error_message = None
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
for item in plugin.sync(plugin_config):
|
|
102
|
+
# Try to insert, update on conflict
|
|
103
|
+
metadata_json = json.dumps(item.metadata) if item.metadata else None
|
|
104
|
+
cursor = conn.execute(
|
|
105
|
+
"""
|
|
106
|
+
INSERT INTO items (
|
|
107
|
+
source_type, source_id, url, title, content,
|
|
108
|
+
author, created_at, is_own_content, metadata
|
|
109
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
110
|
+
ON CONFLICT(source_type, source_id) DO UPDATE SET
|
|
111
|
+
url = excluded.url,
|
|
112
|
+
title = excluded.title,
|
|
113
|
+
content = excluded.content,
|
|
114
|
+
author = excluded.author,
|
|
115
|
+
is_own_content = excluded.is_own_content,
|
|
116
|
+
metadata = excluded.metadata
|
|
117
|
+
""",
|
|
118
|
+
(
|
|
119
|
+
item.source_type,
|
|
120
|
+
item.source_id,
|
|
121
|
+
item.url,
|
|
122
|
+
item.title,
|
|
123
|
+
item.content,
|
|
124
|
+
item.author,
|
|
125
|
+
item.created_at,
|
|
126
|
+
item.is_own_content,
|
|
127
|
+
metadata_json,
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
if cursor.rowcount > 0:
|
|
131
|
+
items_added += 1
|
|
132
|
+
|
|
133
|
+
conn.commit()
|
|
134
|
+
|
|
135
|
+
# Update source last_sync
|
|
136
|
+
conn.execute(
|
|
137
|
+
"""
|
|
138
|
+
INSERT INTO sources (source_type, last_sync)
|
|
139
|
+
VALUES (?, ?)
|
|
140
|
+
ON CONFLICT(source_type) DO UPDATE SET last_sync = excluded.last_sync
|
|
141
|
+
""",
|
|
142
|
+
(name, datetime.now()),
|
|
143
|
+
)
|
|
144
|
+
conn.commit()
|
|
145
|
+
|
|
146
|
+
status = "completed"
|
|
147
|
+
except Exception as e:
|
|
148
|
+
status = "failed"
|
|
149
|
+
error_message = str(e)
|
|
150
|
+
console.print(f"[red]Error syncing {name}: {e}[/red]")
|
|
151
|
+
|
|
152
|
+
# Update sync log
|
|
153
|
+
conn.execute(
|
|
154
|
+
"""
|
|
155
|
+
UPDATE sync_log SET
|
|
156
|
+
completed_at = ?,
|
|
157
|
+
status = ?,
|
|
158
|
+
items_added = ?,
|
|
159
|
+
items_updated = ?,
|
|
160
|
+
error_message = ?
|
|
161
|
+
WHERE id = ?
|
|
162
|
+
""",
|
|
163
|
+
(datetime.now(), status, items_added, items_updated, error_message, sync_id),
|
|
164
|
+
)
|
|
165
|
+
conn.commit()
|
|
166
|
+
|
|
167
|
+
if status == "completed":
|
|
168
|
+
console.print(f"[green]Synced {name}: {items_added} items added[/green]")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@app.command("status")
|
|
172
|
+
def source_status() -> None:
|
|
173
|
+
"""Show sync status for all sources."""
|
|
174
|
+
config = Config.load()
|
|
175
|
+
|
|
176
|
+
with get_connection(config) as conn:
|
|
177
|
+
cursor = conn.execute(
|
|
178
|
+
"""
|
|
179
|
+
SELECT source_type, started_at, completed_at, status,
|
|
180
|
+
items_added, items_updated, error_message
|
|
181
|
+
FROM sync_log
|
|
182
|
+
ORDER BY started_at DESC
|
|
183
|
+
LIMIT 20
|
|
184
|
+
"""
|
|
185
|
+
)
|
|
186
|
+
rows = cursor.fetchall()
|
|
187
|
+
|
|
188
|
+
if not rows:
|
|
189
|
+
console.print("[dim]No sync history.[/dim]")
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
table = Table(show_header=True, header_style="bold")
|
|
193
|
+
table.add_column("Source")
|
|
194
|
+
table.add_column("Started")
|
|
195
|
+
table.add_column("Status")
|
|
196
|
+
table.add_column("Items")
|
|
197
|
+
table.add_column("Error")
|
|
198
|
+
|
|
199
|
+
for row in rows:
|
|
200
|
+
started = row["started_at"][:16] if row["started_at"] else "-"
|
|
201
|
+
items = f"+{row['items_added']}" if row["items_added"] else "-"
|
|
202
|
+
error = (
|
|
203
|
+
row["error_message"][:30] + "..."
|
|
204
|
+
if row["error_message"] and len(row["error_message"]) > 30
|
|
205
|
+
else (row["error_message"] or "-")
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
status_style = {
|
|
209
|
+
"completed": "green",
|
|
210
|
+
"failed": "red",
|
|
211
|
+
"running": "yellow",
|
|
212
|
+
}.get(row["status"], "")
|
|
213
|
+
|
|
214
|
+
table.add_row(
|
|
215
|
+
row["source_type"],
|
|
216
|
+
started,
|
|
217
|
+
f"[{status_style}]{row['status']}[/{status_style}]" if status_style else row["status"],
|
|
218
|
+
items,
|
|
219
|
+
error,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
console.print(table)
|
lestash/core/__init__.py
ADDED
lestash/core/config.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Configuration management for Le Stash."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
import toml
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_config_dir() -> Path:
|
|
11
|
+
"""Get the configuration directory path."""
|
|
12
|
+
config_dir = Path.home() / ".config" / "lestash"
|
|
13
|
+
return config_dir
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_config_path() -> Path:
|
|
17
|
+
"""Get the configuration file path."""
|
|
18
|
+
return get_config_dir() / "config.toml"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_database_path() -> Path:
|
|
22
|
+
"""Get the default database path."""
|
|
23
|
+
return get_config_dir() / "lestash.db"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_log_path() -> Path:
|
|
27
|
+
"""Get the default log file path."""
|
|
28
|
+
return get_config_dir() / "logs" / "lestash.log"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GeneralConfig(BaseModel):
|
|
32
|
+
"""General configuration."""
|
|
33
|
+
|
|
34
|
+
database_path: str = ""
|
|
35
|
+
|
|
36
|
+
def model_post_init(self, __context: Any) -> None:
|
|
37
|
+
if not self.database_path:
|
|
38
|
+
self.database_path = str(get_database_path())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LoggingConfig(BaseModel):
|
|
42
|
+
"""Logging configuration."""
|
|
43
|
+
|
|
44
|
+
# Global log level
|
|
45
|
+
level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
|
|
46
|
+
|
|
47
|
+
# Console settings (diagnostic output to stderr)
|
|
48
|
+
console_enabled: bool = True
|
|
49
|
+
console_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
|
|
50
|
+
console_timestamps: bool = False
|
|
51
|
+
|
|
52
|
+
# File settings (persistent debug trail)
|
|
53
|
+
file_enabled: bool = True
|
|
54
|
+
file_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
|
55
|
+
file_path: str = ""
|
|
56
|
+
max_bytes: int = 10 * 1024 * 1024 # 10MB
|
|
57
|
+
backup_count: int = 5
|
|
58
|
+
file_format: Literal["simple", "detailed", "json"] = "detailed"
|
|
59
|
+
|
|
60
|
+
# Database settings (optional, queryable history)
|
|
61
|
+
db_enabled: bool = False
|
|
62
|
+
db_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
|
|
63
|
+
|
|
64
|
+
# Filter noisy third-party loggers
|
|
65
|
+
filters: dict[str, str] = {
|
|
66
|
+
"httpx": "WARNING",
|
|
67
|
+
"httpcore": "WARNING",
|
|
68
|
+
"urllib3": "WARNING",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
def model_post_init(self, __context: Any) -> None:
|
|
72
|
+
if not self.file_path:
|
|
73
|
+
self.file_path = str(get_log_path())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Config(BaseModel):
|
|
77
|
+
"""Application configuration."""
|
|
78
|
+
|
|
79
|
+
general: GeneralConfig = GeneralConfig()
|
|
80
|
+
logging: LoggingConfig = LoggingConfig()
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def load(cls) -> "Config":
|
|
84
|
+
"""Load configuration from file."""
|
|
85
|
+
config_path = get_config_path()
|
|
86
|
+
if config_path.exists():
|
|
87
|
+
data = toml.load(config_path)
|
|
88
|
+
return cls(**data)
|
|
89
|
+
return cls()
|
|
90
|
+
|
|
91
|
+
def save(self) -> None:
|
|
92
|
+
"""Save configuration to file."""
|
|
93
|
+
config_path = get_config_path()
|
|
94
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
with open(config_path, "w") as f:
|
|
96
|
+
toml.dump(self.model_dump(), f)
|
|
97
|
+
|
|
98
|
+
def get_plugin_config(self, plugin_name: str) -> dict[str, Any]:
|
|
99
|
+
"""Get configuration for a specific plugin."""
|
|
100
|
+
data = self.model_dump()
|
|
101
|
+
return data.get(plugin_name, {})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def init_config() -> Config:
|
|
105
|
+
"""Initialize configuration with defaults."""
|
|
106
|
+
config = Config()
|
|
107
|
+
config.save()
|
|
108
|
+
return config
|