nogic 0.0.1__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.
- nogic/__init__.py +3 -0
- nogic/api/__init__.py +23 -0
- nogic/api/client.py +390 -0
- nogic/commands/__init__.py +1 -0
- nogic/commands/init.py +125 -0
- nogic/commands/login.py +75 -0
- nogic/commands/projects.py +138 -0
- nogic/commands/reindex.py +117 -0
- nogic/commands/status.py +165 -0
- nogic/commands/sync.py +72 -0
- nogic/commands/telemetry_cmd.py +65 -0
- nogic/commands/watch.py +167 -0
- nogic/config.py +157 -0
- nogic/ignore.py +109 -0
- nogic/main.py +58 -0
- nogic/parsing/__init__.py +22 -0
- nogic/parsing/js_extractor.py +674 -0
- nogic/parsing/parser.py +220 -0
- nogic/parsing/python_extractor.py +484 -0
- nogic/parsing/types.py +80 -0
- nogic/storage/__init__.py +14 -0
- nogic/storage/relationships.py +322 -0
- nogic/storage/schema.py +154 -0
- nogic/storage/symbols.py +203 -0
- nogic/telemetry.py +142 -0
- nogic/ui.py +60 -0
- nogic/watcher/__init__.py +7 -0
- nogic/watcher/monitor.py +80 -0
- nogic/watcher/storage.py +185 -0
- nogic/watcher/sync.py +879 -0
- nogic-0.0.1.dist-info/METADATA +201 -0
- nogic-0.0.1.dist-info/RECORD +35 -0
- nogic-0.0.1.dist-info/WHEEL +4 -0
- nogic-0.0.1.dist-info/entry_points.txt +2 -0
- nogic-0.0.1.dist-info/licenses/LICENSE +21 -0
nogic/storage/symbols.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Symbol storage service for managing code symbols in SQLite.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from nogic.parsing.types import ExtractedFunction, ExtractedClass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class StoredSymbol:
|
|
15
|
+
"""A symbol retrieved from the database."""
|
|
16
|
+
|
|
17
|
+
id: str
|
|
18
|
+
name: str
|
|
19
|
+
file_id: int
|
|
20
|
+
kind: str
|
|
21
|
+
parent_symbol_id: Optional[str]
|
|
22
|
+
exported: bool
|
|
23
|
+
line_start: Optional[int]
|
|
24
|
+
line_end: Optional[int]
|
|
25
|
+
signature: Optional[str]
|
|
26
|
+
docstring: Optional[str]
|
|
27
|
+
extra_data: Optional[dict]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SymbolStorageService:
|
|
31
|
+
"""Service for storing and querying code symbols."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, conn: sqlite3.Connection):
|
|
34
|
+
self.conn = conn
|
|
35
|
+
self.conn.row_factory = sqlite3.Row
|
|
36
|
+
|
|
37
|
+
def _make_symbol_id(
|
|
38
|
+
self, file_path: str, name: str, parent_name: Optional[str] = None
|
|
39
|
+
) -> str:
|
|
40
|
+
"""Create a unique symbol ID from file path and names."""
|
|
41
|
+
if parent_name:
|
|
42
|
+
return f"{file_path}::{parent_name}::{name}"
|
|
43
|
+
return f"{file_path}::{name}"
|
|
44
|
+
|
|
45
|
+
def store_function(
|
|
46
|
+
self,
|
|
47
|
+
file_id: int,
|
|
48
|
+
file_path: str,
|
|
49
|
+
func: ExtractedFunction,
|
|
50
|
+
parent_symbol_id: Optional[str] = None,
|
|
51
|
+
) -> str:
|
|
52
|
+
"""Store a function or method symbol. Returns the symbol ID."""
|
|
53
|
+
if func.class_name:
|
|
54
|
+
symbol_id = self._make_symbol_id(file_path, func.name, func.class_name)
|
|
55
|
+
kind = "method"
|
|
56
|
+
else:
|
|
57
|
+
symbol_id = self._make_symbol_id(file_path, func.name)
|
|
58
|
+
kind = "function"
|
|
59
|
+
|
|
60
|
+
extra_data = {
|
|
61
|
+
"decorators": func.decorators,
|
|
62
|
+
"parameters": func.parameters,
|
|
63
|
+
"return_type": func.return_type,
|
|
64
|
+
"is_async": func.is_async,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
self.conn.execute(
|
|
68
|
+
"""
|
|
69
|
+
INSERT OR REPLACE INTO symbols
|
|
70
|
+
(id, name, file_id, kind, parent_symbol_id, exported, line_start, line_end, signature, docstring, extra_data)
|
|
71
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
72
|
+
""",
|
|
73
|
+
(
|
|
74
|
+
symbol_id,
|
|
75
|
+
func.name,
|
|
76
|
+
file_id,
|
|
77
|
+
kind,
|
|
78
|
+
parent_symbol_id,
|
|
79
|
+
0, # exported - would need export detection
|
|
80
|
+
func.start_line,
|
|
81
|
+
func.end_line,
|
|
82
|
+
func.signature,
|
|
83
|
+
func.docstring,
|
|
84
|
+
json.dumps(extra_data),
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return symbol_id
|
|
89
|
+
|
|
90
|
+
def store_class(
|
|
91
|
+
self,
|
|
92
|
+
file_id: int,
|
|
93
|
+
file_path: str,
|
|
94
|
+
cls: ExtractedClass,
|
|
95
|
+
) -> str:
|
|
96
|
+
"""Store a class symbol and its methods. Returns the class symbol ID."""
|
|
97
|
+
symbol_id = self._make_symbol_id(file_path, cls.name)
|
|
98
|
+
|
|
99
|
+
extra_data = {
|
|
100
|
+
"decorators": cls.decorators,
|
|
101
|
+
"bases": cls.bases,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
self.conn.execute(
|
|
105
|
+
"""
|
|
106
|
+
INSERT OR REPLACE INTO symbols
|
|
107
|
+
(id, name, file_id, kind, parent_symbol_id, exported, line_start, line_end, signature, docstring, extra_data)
|
|
108
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
109
|
+
""",
|
|
110
|
+
(
|
|
111
|
+
symbol_id,
|
|
112
|
+
cls.name,
|
|
113
|
+
file_id,
|
|
114
|
+
"class",
|
|
115
|
+
None,
|
|
116
|
+
0, # exported
|
|
117
|
+
cls.start_line,
|
|
118
|
+
cls.end_line,
|
|
119
|
+
f"class {cls.name}",
|
|
120
|
+
cls.docstring,
|
|
121
|
+
json.dumps(extra_data),
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Store methods with parent reference
|
|
126
|
+
for method in cls.methods:
|
|
127
|
+
self.store_function(file_id, file_path, method, parent_symbol_id=symbol_id)
|
|
128
|
+
|
|
129
|
+
return symbol_id
|
|
130
|
+
|
|
131
|
+
def delete_file_symbols(self, file_id: int) -> int:
|
|
132
|
+
"""Delete all symbols for a file. Returns count deleted."""
|
|
133
|
+
cursor = self.conn.execute(
|
|
134
|
+
"DELETE FROM symbols WHERE file_id = ?", (file_id,)
|
|
135
|
+
)
|
|
136
|
+
return cursor.rowcount
|
|
137
|
+
|
|
138
|
+
def get_symbol(self, symbol_id: str) -> Optional[StoredSymbol]:
|
|
139
|
+
"""Get a symbol by ID."""
|
|
140
|
+
row = self.conn.execute(
|
|
141
|
+
"SELECT * FROM symbols WHERE id = ?", (symbol_id,)
|
|
142
|
+
).fetchone()
|
|
143
|
+
|
|
144
|
+
if not row:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
return self._row_to_symbol(row)
|
|
148
|
+
|
|
149
|
+
def get_symbols_by_file(self, file_id: int) -> list[StoredSymbol]:
|
|
150
|
+
"""Get all symbols in a file."""
|
|
151
|
+
rows = self.conn.execute(
|
|
152
|
+
"SELECT * FROM symbols WHERE file_id = ?", (file_id,)
|
|
153
|
+
).fetchall()
|
|
154
|
+
|
|
155
|
+
return [self._row_to_symbol(row) for row in rows]
|
|
156
|
+
|
|
157
|
+
def get_symbols_by_name(self, name: str) -> list[StoredSymbol]:
|
|
158
|
+
"""Get all symbols with a given name."""
|
|
159
|
+
rows = self.conn.execute(
|
|
160
|
+
"SELECT * FROM symbols WHERE name = ?", (name,)
|
|
161
|
+
).fetchall()
|
|
162
|
+
|
|
163
|
+
return [self._row_to_symbol(row) for row in rows]
|
|
164
|
+
|
|
165
|
+
def get_symbols_by_kind(self, kind: str) -> list[StoredSymbol]:
|
|
166
|
+
"""Get all symbols of a given kind (class, function, method)."""
|
|
167
|
+
rows = self.conn.execute(
|
|
168
|
+
"SELECT * FROM symbols WHERE kind = ?", (kind,)
|
|
169
|
+
).fetchall()
|
|
170
|
+
|
|
171
|
+
return [self._row_to_symbol(row) for row in rows]
|
|
172
|
+
|
|
173
|
+
def search_symbols(self, query: str, limit: int = 50) -> list[StoredSymbol]:
|
|
174
|
+
"""Search symbols by name pattern."""
|
|
175
|
+
rows = self.conn.execute(
|
|
176
|
+
"SELECT * FROM symbols WHERE name LIKE ? LIMIT ?",
|
|
177
|
+
(f"%{query}%", limit),
|
|
178
|
+
).fetchall()
|
|
179
|
+
|
|
180
|
+
return [self._row_to_symbol(row) for row in rows]
|
|
181
|
+
|
|
182
|
+
def _row_to_symbol(self, row: sqlite3.Row) -> StoredSymbol:
|
|
183
|
+
"""Convert a database row to a StoredSymbol."""
|
|
184
|
+
extra_data = None
|
|
185
|
+
if row["extra_data"]:
|
|
186
|
+
try:
|
|
187
|
+
extra_data = json.loads(row["extra_data"])
|
|
188
|
+
except json.JSONDecodeError:
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
return StoredSymbol(
|
|
192
|
+
id=row["id"],
|
|
193
|
+
name=row["name"],
|
|
194
|
+
file_id=row["file_id"],
|
|
195
|
+
kind=row["kind"],
|
|
196
|
+
parent_symbol_id=row["parent_symbol_id"],
|
|
197
|
+
exported=bool(row["exported"]),
|
|
198
|
+
line_start=row["line_start"],
|
|
199
|
+
line_end=row["line_end"],
|
|
200
|
+
signature=row["signature"],
|
|
201
|
+
docstring=row["docstring"],
|
|
202
|
+
extra_data=extra_data,
|
|
203
|
+
)
|
nogic/telemetry.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Telemetry module for Nogic CLI — sends events to backend telemetry endpoints."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from nogic import __version__
|
|
12
|
+
from .config import GLOBAL_CONFIG_DIR, CONFIG_FILE
|
|
13
|
+
|
|
14
|
+
_client: httpx.Client | None = None
|
|
15
|
+
_disabled: bool = False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_api_url() -> str:
|
|
19
|
+
"""Get the backend API URL from config."""
|
|
20
|
+
from .config import Config
|
|
21
|
+
|
|
22
|
+
config = Config.load()
|
|
23
|
+
return config.api_url
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_anonymous_id() -> str:
|
|
27
|
+
"""Get anonymous ID based on API key (if logged in) or machine."""
|
|
28
|
+
from .config import Config
|
|
29
|
+
|
|
30
|
+
config = Config.load()
|
|
31
|
+
|
|
32
|
+
if config.api_key:
|
|
33
|
+
return hashlib.sha256(config.api_key.encode()).hexdigest()[:16]
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
login = os.getlogin()
|
|
37
|
+
except OSError:
|
|
38
|
+
login = os.getenv("USER", os.getenv("USERNAME", "unknown"))
|
|
39
|
+
machine_info = f"{platform.node()}-{platform.machine()}-{login}"
|
|
40
|
+
return hashlib.sha256(machine_info.encode()).hexdigest()[:16]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _is_telemetry_enabled() -> bool:
|
|
44
|
+
"""Check if telemetry is enabled (opt-out via env var or config)."""
|
|
45
|
+
if os.getenv("NOGIC_TELEMETRY_DISABLED", "").lower() in ("1", "true", "yes"):
|
|
46
|
+
return False
|
|
47
|
+
if os.getenv("DO_NOT_TRACK", "").lower() in ("1", "true", "yes"):
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
config_file = GLOBAL_CONFIG_DIR / CONFIG_FILE
|
|
51
|
+
if config_file.exists():
|
|
52
|
+
try:
|
|
53
|
+
with open(config_file, encoding="utf-8") as f:
|
|
54
|
+
data = json.load(f)
|
|
55
|
+
if data.get("telemetry_enabled") is False:
|
|
56
|
+
return False
|
|
57
|
+
except (json.JSONDecodeError, OSError):
|
|
58
|
+
pass
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get_client() -> httpx.Client | None:
|
|
63
|
+
"""Get or create the httpx client. Returns None if telemetry is disabled."""
|
|
64
|
+
global _client, _disabled
|
|
65
|
+
if _disabled:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
if not _is_telemetry_enabled():
|
|
69
|
+
_disabled = True
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
if _client is None:
|
|
73
|
+
_client = httpx.Client(timeout=5.0)
|
|
74
|
+
|
|
75
|
+
return _client
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def capture(event: str, properties: dict | None = None):
|
|
79
|
+
"""Capture a telemetry event by sending it to the backend."""
|
|
80
|
+
client = _get_client()
|
|
81
|
+
if client is None:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
props = properties or {}
|
|
86
|
+
props.update({
|
|
87
|
+
"cli_version": __version__,
|
|
88
|
+
"os": platform.system(),
|
|
89
|
+
"os_version": platform.release(),
|
|
90
|
+
"python_version": platform.python_version(),
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
api_url = _get_api_url()
|
|
94
|
+
client.post(
|
|
95
|
+
f"{api_url}/v1/telemetry",
|
|
96
|
+
json={
|
|
97
|
+
"event": event,
|
|
98
|
+
"properties": props,
|
|
99
|
+
"anonymous_id": _get_anonymous_id(),
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def identify(user_id: str | None = None, properties: dict | None = None):
|
|
107
|
+
"""Identify a user by sending to the backend."""
|
|
108
|
+
client = _get_client()
|
|
109
|
+
if client is None:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
if not user_id:
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
hashed_user_id = hashlib.sha256(user_id.encode()).hexdigest()[:16]
|
|
117
|
+
|
|
118
|
+
props = properties or {}
|
|
119
|
+
props.update({"os": platform.system()})
|
|
120
|
+
|
|
121
|
+
api_url = _get_api_url()
|
|
122
|
+
client.post(
|
|
123
|
+
f"{api_url}/v1/telemetry/identify",
|
|
124
|
+
json={
|
|
125
|
+
"anonymous_id": _get_anonymous_id(),
|
|
126
|
+
"user_id": hashed_user_id,
|
|
127
|
+
"properties": props,
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def shutdown():
|
|
135
|
+
"""Close the HTTP client."""
|
|
136
|
+
global _client
|
|
137
|
+
if _client is not None:
|
|
138
|
+
try:
|
|
139
|
+
_client.close()
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
_client = None
|
nogic/ui.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""CLI output styling utilities."""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
from rich.rule import Rule
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
error_console = Console(stderr=True)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def banner(title: str, subtitle: str | None = None):
|
|
12
|
+
"""Display a styled command header."""
|
|
13
|
+
console.print()
|
|
14
|
+
console.print(Rule(f"[bold cyan]{title}[/]", style="dim"))
|
|
15
|
+
if subtitle:
|
|
16
|
+
dim(f" {subtitle}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def dev_banner(url: str):
|
|
20
|
+
"""Display dev mode warning."""
|
|
21
|
+
console.print(Panel(
|
|
22
|
+
f"[yellow bold]DEV MODE[/] Using {url}",
|
|
23
|
+
border_style="yellow",
|
|
24
|
+
padding=(0, 1),
|
|
25
|
+
))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def success(msg: str):
|
|
29
|
+
console.print(f"[green bold]>[/] {msg}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def info(msg: str):
|
|
33
|
+
console.print(f"[blue]>[/] {msg}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def warn(msg: str):
|
|
37
|
+
console.print(f"[yellow bold]![/] {msg}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def error(msg: str):
|
|
41
|
+
error_console.print(f"[red bold]![/] {msg}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def dim(msg: str):
|
|
45
|
+
console.print(f"[dim]{msg}[/]")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def kv(key: str, value: str, indent: int = 2):
|
|
49
|
+
"""Print a key-value pair."""
|
|
50
|
+
pad = " " * indent
|
|
51
|
+
console.print(f"{pad}[dim]{key}:[/] {value}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def section(title: str):
|
|
55
|
+
console.print(f"\n[bold]{title}[/]")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def status_spinner(msg: str):
|
|
59
|
+
"""Return a Rich status context manager for spinners."""
|
|
60
|
+
return console.status(f"[dim]{msg}[/]", spinner="dots")
|
nogic/watcher/monitor.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""File system monitoring with watchdog."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from watchdog.observers import Observer
|
|
7
|
+
from watchdog.events import (
|
|
8
|
+
FileSystemEventHandler,
|
|
9
|
+
FileCreatedEvent,
|
|
10
|
+
FileModifiedEvent,
|
|
11
|
+
FileDeletedEvent,
|
|
12
|
+
FileMovedEvent,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = ["FileEventHandler", "FileMonitor"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FileEventHandler(FileSystemEventHandler):
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
on_change: Callable[[Path], None],
|
|
22
|
+
on_delete: Callable[[Path], None],
|
|
23
|
+
should_ignore: Callable[[Path], bool],
|
|
24
|
+
):
|
|
25
|
+
self.on_change = on_change
|
|
26
|
+
self.on_delete = on_delete
|
|
27
|
+
self.should_ignore = should_ignore
|
|
28
|
+
|
|
29
|
+
def _handle_change(self, path: Path):
|
|
30
|
+
if path.is_file() and not self.should_ignore(path):
|
|
31
|
+
self.on_change(path)
|
|
32
|
+
|
|
33
|
+
def _handle_delete(self, path: Path):
|
|
34
|
+
if not self.should_ignore(path):
|
|
35
|
+
self.on_delete(path)
|
|
36
|
+
|
|
37
|
+
def on_created(self, event: FileCreatedEvent):
|
|
38
|
+
if not event.is_directory:
|
|
39
|
+
self._handle_change(Path(event.src_path))
|
|
40
|
+
|
|
41
|
+
def on_modified(self, event: FileModifiedEvent):
|
|
42
|
+
if not event.is_directory:
|
|
43
|
+
self._handle_change(Path(event.src_path))
|
|
44
|
+
|
|
45
|
+
def on_deleted(self, event: FileDeletedEvent):
|
|
46
|
+
if not event.is_directory:
|
|
47
|
+
self._handle_delete(Path(event.src_path))
|
|
48
|
+
|
|
49
|
+
def on_moved(self, event: FileMovedEvent):
|
|
50
|
+
if not event.is_directory:
|
|
51
|
+
self._handle_delete(Path(event.src_path))
|
|
52
|
+
self._handle_change(Path(event.dest_path))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FileMonitor:
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
root_path: Path,
|
|
59
|
+
on_change: Callable[[Path], None],
|
|
60
|
+
on_delete: Callable[[Path], None],
|
|
61
|
+
should_ignore: Callable[[Path], bool],
|
|
62
|
+
):
|
|
63
|
+
self.root_path = root_path.resolve()
|
|
64
|
+
self.handler = FileEventHandler(
|
|
65
|
+
on_change=on_change,
|
|
66
|
+
on_delete=on_delete,
|
|
67
|
+
should_ignore=should_ignore,
|
|
68
|
+
)
|
|
69
|
+
self.observer = Observer()
|
|
70
|
+
|
|
71
|
+
def start(self):
|
|
72
|
+
self.observer.schedule(self.handler, str(self.root_path), recursive=True)
|
|
73
|
+
self.observer.start()
|
|
74
|
+
|
|
75
|
+
def stop(self):
|
|
76
|
+
self.observer.stop()
|
|
77
|
+
self.observer.join(timeout=5)
|
|
78
|
+
|
|
79
|
+
def is_alive(self) -> bool:
|
|
80
|
+
return self.observer.is_alive()
|
nogic/watcher/storage.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""SQLite storage layer for file tracking."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sqlite3
|
|
5
|
+
import hashlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Optional
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from nogic.storage.schema import (
|
|
12
|
+
create_schema,
|
|
13
|
+
get_schema_version,
|
|
14
|
+
set_schema_version,
|
|
15
|
+
migrate_schema,
|
|
16
|
+
SCHEMA_VERSION,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class FileRecord:
|
|
22
|
+
id: int
|
|
23
|
+
path: str
|
|
24
|
+
content_hash: str
|
|
25
|
+
last_modified: float
|
|
26
|
+
synced_at: Optional[float]
|
|
27
|
+
status: str # 'active' or 'deleted'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FileStorage:
|
|
31
|
+
def __init__(self, db_path: Path):
|
|
32
|
+
self.db_path = db_path
|
|
33
|
+
self._conn: Optional[sqlite3.Connection] = None
|
|
34
|
+
self._init_db()
|
|
35
|
+
|
|
36
|
+
def _init_db(self):
|
|
37
|
+
conn = self._connect()
|
|
38
|
+
current_version = get_schema_version(conn)
|
|
39
|
+
|
|
40
|
+
if current_version < SCHEMA_VERSION:
|
|
41
|
+
create_schema(conn)
|
|
42
|
+
set_schema_version(conn, SCHEMA_VERSION)
|
|
43
|
+
conn.commit()
|
|
44
|
+
|
|
45
|
+
# Restrict database file permissions
|
|
46
|
+
os.chmod(self.db_path, 0o600)
|
|
47
|
+
|
|
48
|
+
def _connect(self) -> sqlite3.Connection:
|
|
49
|
+
"""Return the shared connection, creating it if needed."""
|
|
50
|
+
if self._conn is None:
|
|
51
|
+
self._conn = sqlite3.connect(self.db_path)
|
|
52
|
+
self._conn.row_factory = sqlite3.Row
|
|
53
|
+
return self._conn
|
|
54
|
+
|
|
55
|
+
def close(self):
|
|
56
|
+
"""Close the database connection."""
|
|
57
|
+
if self._conn is not None:
|
|
58
|
+
self._conn.close()
|
|
59
|
+
self._conn = None
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def compute_hash(content: str) -> str:
|
|
63
|
+
return hashlib.sha256(content.encode()).hexdigest()
|
|
64
|
+
|
|
65
|
+
def get_file(self, path: str) -> Optional[FileRecord]:
|
|
66
|
+
conn = self._connect()
|
|
67
|
+
row = conn.execute(
|
|
68
|
+
"SELECT * FROM files WHERE path = ?", (path,)
|
|
69
|
+
).fetchone()
|
|
70
|
+
if row:
|
|
71
|
+
return FileRecord(**dict(row))
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def upsert_file(
|
|
75
|
+
self, path: str, content_hash: str, last_modified: float
|
|
76
|
+
) -> tuple[int, str]:
|
|
77
|
+
"""Insert or update file. Returns (file_id, event_type)."""
|
|
78
|
+
existing = self.get_file(path)
|
|
79
|
+
now = time.time()
|
|
80
|
+
|
|
81
|
+
conn = self._connect()
|
|
82
|
+
if existing is None:
|
|
83
|
+
cursor = conn.execute(
|
|
84
|
+
"""INSERT INTO files (path, content_hash, last_modified, status)
|
|
85
|
+
VALUES (?, ?, ?, 'active')""",
|
|
86
|
+
(path, content_hash, last_modified),
|
|
87
|
+
)
|
|
88
|
+
file_id = cursor.lastrowid
|
|
89
|
+
event_type = "created"
|
|
90
|
+
elif existing.status == "deleted":
|
|
91
|
+
conn.execute(
|
|
92
|
+
"""UPDATE files
|
|
93
|
+
SET content_hash = ?, last_modified = ?, status = 'active'
|
|
94
|
+
WHERE id = ?""",
|
|
95
|
+
(content_hash, last_modified, existing.id),
|
|
96
|
+
)
|
|
97
|
+
file_id = existing.id
|
|
98
|
+
event_type = "created"
|
|
99
|
+
elif existing.content_hash != content_hash:
|
|
100
|
+
conn.execute(
|
|
101
|
+
"""UPDATE files
|
|
102
|
+
SET content_hash = ?, last_modified = ?
|
|
103
|
+
WHERE id = ?""",
|
|
104
|
+
(content_hash, last_modified, existing.id),
|
|
105
|
+
)
|
|
106
|
+
file_id = existing.id
|
|
107
|
+
event_type = "modified"
|
|
108
|
+
else:
|
|
109
|
+
return existing.id, "unchanged"
|
|
110
|
+
|
|
111
|
+
conn.execute(
|
|
112
|
+
"""INSERT INTO sync_events (file_id, event_type, content_hash, timestamp)
|
|
113
|
+
VALUES (?, ?, ?, ?)""",
|
|
114
|
+
(file_id, event_type, content_hash, now),
|
|
115
|
+
)
|
|
116
|
+
conn.commit()
|
|
117
|
+
return file_id, event_type
|
|
118
|
+
|
|
119
|
+
def mark_deleted(self, path: str) -> Optional[int]:
|
|
120
|
+
"""Mark file as deleted. Returns file_id if found."""
|
|
121
|
+
existing = self.get_file(path)
|
|
122
|
+
if existing and existing.status == "active":
|
|
123
|
+
now = time.time()
|
|
124
|
+
conn = self._connect()
|
|
125
|
+
conn.execute(
|
|
126
|
+
"UPDATE files SET status = 'deleted' WHERE id = ?",
|
|
127
|
+
(existing.id,),
|
|
128
|
+
)
|
|
129
|
+
conn.execute(
|
|
130
|
+
"""INSERT INTO sync_events (file_id, event_type, content_hash, timestamp)
|
|
131
|
+
VALUES (?, 'deleted', ?, ?)""",
|
|
132
|
+
(existing.id, existing.content_hash, now),
|
|
133
|
+
)
|
|
134
|
+
conn.commit()
|
|
135
|
+
return existing.id
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
def mark_synced(self, file_id: int):
|
|
139
|
+
"""Mark file as synced."""
|
|
140
|
+
conn = self._connect()
|
|
141
|
+
conn.execute(
|
|
142
|
+
"UPDATE files SET synced_at = ? WHERE id = ?",
|
|
143
|
+
(time.time(), file_id),
|
|
144
|
+
)
|
|
145
|
+
conn.commit()
|
|
146
|
+
|
|
147
|
+
def get_pending_sync(self) -> list[FileRecord]:
|
|
148
|
+
"""Get files that need syncing (modified after last sync)."""
|
|
149
|
+
conn = self._connect()
|
|
150
|
+
rows = conn.execute(
|
|
151
|
+
"""SELECT * FROM files
|
|
152
|
+
WHERE status = 'active'
|
|
153
|
+
AND (synced_at IS NULL OR last_modified > synced_at)"""
|
|
154
|
+
).fetchall()
|
|
155
|
+
return [FileRecord(**dict(row)) for row in rows]
|
|
156
|
+
|
|
157
|
+
def get_all_active(self) -> list[FileRecord]:
|
|
158
|
+
"""Get all active files."""
|
|
159
|
+
conn = self._connect()
|
|
160
|
+
rows = conn.execute(
|
|
161
|
+
"SELECT * FROM files WHERE status = 'active'"
|
|
162
|
+
).fetchall()
|
|
163
|
+
return [FileRecord(**dict(row)) for row in rows]
|
|
164
|
+
|
|
165
|
+
def get_stats(self) -> dict:
|
|
166
|
+
"""Get storage statistics."""
|
|
167
|
+
conn = self._connect()
|
|
168
|
+
total = conn.execute(
|
|
169
|
+
"SELECT COUNT(*) FROM files WHERE status = 'active'"
|
|
170
|
+
).fetchone()[0]
|
|
171
|
+
synced = conn.execute(
|
|
172
|
+
"SELECT COUNT(*) FROM files WHERE status = 'active' AND synced_at IS NOT NULL"
|
|
173
|
+
).fetchone()[0]
|
|
174
|
+
return {
|
|
175
|
+
"total_files": total,
|
|
176
|
+
"synced_files": synced,
|
|
177
|
+
"pending_files": total - synced,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
def clear_all(self):
|
|
181
|
+
"""Clear all file records (for reindexing)."""
|
|
182
|
+
conn = self._connect()
|
|
183
|
+
conn.execute("DELETE FROM sync_events")
|
|
184
|
+
conn.execute("DELETE FROM files")
|
|
185
|
+
conn.commit()
|