footprinter-cli 1.0.0rc1__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.
- footprinter/__init__.py +8 -0
- footprinter/access.py +431 -0
- footprinter/api/__init__.py +1 -0
- footprinter/api/db.py +61 -0
- footprinter/api/entities.py +250 -0
- footprinter/api/search.py +47 -0
- footprinter/api/semantic.py +33 -0
- footprinter/api/server.py +66 -0
- footprinter/api/status.py +15 -0
- footprinter/bundled/__init__.py +0 -0
- footprinter/bundled/config.example.yaml +161 -0
- footprinter/bundled/patterns/context_patterns.yaml +18 -0
- footprinter/bundled/patterns/extensions.yaml +283 -0
- footprinter/bundled/patterns/filename_patterns.yaml +61 -0
- footprinter/bundled/patterns/mime_mappings.yaml +68 -0
- footprinter/bundled/patterns/salesforce_rules.yaml +84 -0
- footprinter/bundled/patterns/security_patterns.yaml +27 -0
- footprinter/bundled/samples/hidden-client-file-sample.txt +2 -0
- footprinter/bundled/samples/opaque-project-file-sample.txt +2 -0
- footprinter/bundled/samples/visible-file-sample.txt +2 -0
- footprinter/cli/__init__.py +135 -0
- footprinter/cli/__main__.py +6 -0
- footprinter/cli/_common.py +327 -0
- footprinter/cli/_policy_helpers.py +646 -0
- footprinter/cli/_prompt.py +220 -0
- footprinter/cli/_sample_seed.py +204 -0
- footprinter/cli/api_cmd.py +32 -0
- footprinter/cli/connect.py +591 -0
- footprinter/cli/data.py +879 -0
- footprinter/cli/delete.py +128 -0
- footprinter/cli/ingest.py +543 -0
- footprinter/cli/mcp_cmd.py +750 -0
- footprinter/cli/mcp_setup.py +306 -0
- footprinter/cli/search.py +393 -0
- footprinter/cli/search_cmd.py +69 -0
- footprinter/cli/setup.py +2001 -0
- footprinter/cli/status.py +747 -0
- footprinter/cli/status_cmd.py +104 -0
- footprinter/cli/upsert.py +794 -0
- footprinter/cli/vectorize_cmd.py +215 -0
- footprinter/cli/view.py +322 -0
- footprinter/connectors/__init__.py +171 -0
- footprinter/connectors/config_utils.py +141 -0
- footprinter/db/__init__.py +37 -0
- footprinter/db/browser.py +198 -0
- footprinter/db/chats.py +602 -0
- footprinter/db/clients.py +307 -0
- footprinter/db/emails.py +279 -0
- footprinter/db/files.py +724 -0
- footprinter/db/folders.py +659 -0
- footprinter/db/messages.py +192 -0
- footprinter/db/policies.py +151 -0
- footprinter/db/projects.py +673 -0
- footprinter/db/search.py +573 -0
- footprinter/db/sql_utils.py +168 -0
- footprinter/db/status.py +320 -0
- footprinter/db/uploads.py +70 -0
- footprinter/ingest/__init__.py +0 -0
- footprinter/ingest/adapters/__init__.py +33 -0
- footprinter/ingest/adapters/browser.py +54 -0
- footprinter/ingest/adapters/chat.py +57 -0
- footprinter/ingest/adapters/ingest.py +146 -0
- footprinter/ingest/adapters/local_files.py +68 -0
- footprinter/ingest/adapters/local_folders.py +52 -0
- footprinter/ingest/adapters/protocol.py +174 -0
- footprinter/ingest/browser_indexer.py +216 -0
- footprinter/ingest/chat_dedup.py +156 -0
- footprinter/ingest/chat_indexer.py +487 -0
- footprinter/ingest/chat_parsers/__init__.py +8 -0
- footprinter/ingest/chat_parsers/chatgpt_parser.py +229 -0
- footprinter/ingest/chat_parsers/claude_parser.py +161 -0
- footprinter/ingest/cli.py +827 -0
- footprinter/ingest/content_extractors.py +117 -0
- footprinter/ingest/database.py +36 -0
- footprinter/ingest/db/__init__.py +1 -0
- footprinter/ingest/db/connector_schema.py +47 -0
- footprinter/ingest/db/migration.py +315 -0
- footprinter/ingest/db/schema.py +1043 -0
- footprinter/ingest/db/security.py +6 -0
- footprinter/ingest/file_indexer.py +223 -0
- footprinter/ingest/file_scanner.py +277 -0
- footprinter/ingest/folder_indexer.py +226 -0
- footprinter/ingest/full_content_extractor.py +321 -0
- footprinter/ingest/orchestrator.py +112 -0
- footprinter/ingest/pipe_runner.py +200 -0
- footprinter/ingest/processing.py +165 -0
- footprinter/ingest/registry.py +186 -0
- footprinter/ingest/run_record.py +91 -0
- footprinter/ingest/status.py +346 -0
- footprinter/mcp/__init__.py +0 -0
- footprinter/mcp/__main__.py +5 -0
- footprinter/mcp/db.py +67 -0
- footprinter/mcp/errors.py +105 -0
- footprinter/mcp/extraction.py +226 -0
- footprinter/mcp/server.py +39 -0
- footprinter/mcp/tools/__init__.py +0 -0
- footprinter/mcp/tools/navigation.py +70 -0
- footprinter/mcp/tools/read.py +75 -0
- footprinter/mcp/tools/search.py +158 -0
- footprinter/mcp/tools/semantic.py +79 -0
- footprinter/mcp/tools/status.py +19 -0
- footprinter/paths.py +117 -0
- footprinter/permissions.py +1152 -0
- footprinter/semantic/__init__.py +13 -0
- footprinter/semantic/chunking.py +52 -0
- footprinter/semantic/embeddings.py +23 -0
- footprinter/semantic/hybrid_search.py +273 -0
- footprinter/semantic/vector_store.py +471 -0
- footprinter/services/__init__.py +49 -0
- footprinter/services/access_service.py +342 -0
- footprinter/services/chat_service.py +85 -0
- footprinter/services/client_service.py +267 -0
- footprinter/services/content_service.py +181 -0
- footprinter/services/email_service.py +89 -0
- footprinter/services/file_service.py +83 -0
- footprinter/services/folder_service.py +122 -0
- footprinter/services/includes.py +19 -0
- footprinter/services/ingest_service.py +231 -0
- footprinter/services/project_service.py +262 -0
- footprinter/services/roles.py +25 -0
- footprinter/services/search_service.py +177 -0
- footprinter/services/semantic_service.py +360 -0
- footprinter/services/status_service.py +18 -0
- footprinter/services/visit_service.py +65 -0
- footprinter/source_registry.py +194 -0
- footprinter/utils/__init__.py +7 -0
- footprinter/utils/hash_utils.py +59 -0
- footprinter/utils/logging_config.py +68 -0
- footprinter/utils/mime.py +30 -0
- footprinter/utils/text.py +6 -0
- footprinter/utils/time.py +11 -0
- footprinter/visibility.py +1264 -0
- footprinter_cli-1.0.0rc1.dist-info/LICENSE +21 -0
- footprinter_cli-1.0.0rc1.dist-info/METADATA +223 -0
- footprinter_cli-1.0.0rc1.dist-info/RECORD +138 -0
- footprinter_cli-1.0.0rc1.dist-info/WHEEL +5 -0
- footprinter_cli-1.0.0rc1.dist-info/entry_points.txt +2 -0
- footprinter_cli-1.0.0rc1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Connector registry — metadata and discovery for optional integrations.
|
|
2
|
+
|
|
3
|
+
Defines ConnectorSpec (the metadata dataclass) and discover_connectors()
|
|
4
|
+
which finds installed connector plugins via importlib.metadata entry points.
|
|
5
|
+
Helper functions check install status, config, and credentials.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib
|
|
9
|
+
import importlib.metadata
|
|
10
|
+
import importlib.util
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthType(str, Enum):
|
|
21
|
+
"""Authentication mechanism a connector uses — routing label for CLI dispatch."""
|
|
22
|
+
|
|
23
|
+
OAUTH2 = "oauth2"
|
|
24
|
+
BEARER = "bearer"
|
|
25
|
+
IMPORT = "import"
|
|
26
|
+
FILESYSTEM = "filesystem"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ConnectorSpec:
|
|
31
|
+
"""Metadata for an optional integration."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
extra: str
|
|
35
|
+
description: str
|
|
36
|
+
pipes: tuple[str, ...]
|
|
37
|
+
probe_module: str
|
|
38
|
+
config_sections: tuple[str, ...]
|
|
39
|
+
setup_hook: str # dotted path to callable
|
|
40
|
+
remove_packages: tuple[str, ...]
|
|
41
|
+
adapter_entries: dict[str, str] = field(default_factory=dict) # pipe_name → "module:ClassName"
|
|
42
|
+
services: tuple[str, ...] = ()
|
|
43
|
+
seed_prefix: str = "" # prefix for source_seed names (e.g. "gdrive")
|
|
44
|
+
schema_extensions: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
|
|
45
|
+
auth_type: AuthType = AuthType.OAUTH2
|
|
46
|
+
check_auth: str = "" # dotted path to callable(config) → auth status string
|
|
47
|
+
config_apply: str = "" # dotted path to callable(config, result) → None
|
|
48
|
+
health_check: str = "" # dotted path to callable(config) → list[dict]
|
|
49
|
+
read_file: str = "" # dotted path to callable(external_id, account, mime_type) → bytes|None
|
|
50
|
+
seed_label_fn: str = "" # dotted path to callable(display) → str
|
|
51
|
+
features: tuple[tuple[str, str, str, str], ...] = () # (name, probe_module, config_section, hint)
|
|
52
|
+
zero_result_checks: tuple[tuple[str, str], ...] = () # (pipe_name, count_key) for status warnings
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def discover_connectors() -> dict[str, ConnectorSpec]:
|
|
56
|
+
"""Discover connector plugins via entry points.
|
|
57
|
+
|
|
58
|
+
Each entry point in the ``footprinter.connectors`` group should be
|
|
59
|
+
either a :class:`ConnectorSpec` instance or a callable that returns one.
|
|
60
|
+
The entry point name becomes the connector key.
|
|
61
|
+
"""
|
|
62
|
+
connectors: dict[str, ConnectorSpec] = {}
|
|
63
|
+
eps = importlib.metadata.entry_points(group="footprinter.connectors")
|
|
64
|
+
for ep in eps:
|
|
65
|
+
try:
|
|
66
|
+
spec = ep.load()
|
|
67
|
+
if callable(spec):
|
|
68
|
+
spec = spec()
|
|
69
|
+
connectors[ep.name] = spec
|
|
70
|
+
except Exception:
|
|
71
|
+
logger.warning("Failed to load connector entry point: %s", ep.name)
|
|
72
|
+
return connectors
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_installed(spec: ConnectorSpec) -> bool:
|
|
76
|
+
"""Check if the connector's pip extra is installed."""
|
|
77
|
+
try:
|
|
78
|
+
return importlib.util.find_spec(spec.probe_module) is not None
|
|
79
|
+
except (ValueError, ModuleNotFoundError):
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_configured(spec: ConnectorSpec, config: dict) -> bool:
|
|
84
|
+
"""Check if any of the connector's config sections are enabled."""
|
|
85
|
+
for section in spec.config_sections:
|
|
86
|
+
if section in config and config[section].get("enabled"):
|
|
87
|
+
return True
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def has_credentials(spec: ConnectorSpec, config: dict) -> bool:
|
|
92
|
+
"""Check if the credentials file exists for any config section."""
|
|
93
|
+
for section in spec.config_sections:
|
|
94
|
+
if section in config:
|
|
95
|
+
creds_path = config[section].get("credentials_path")
|
|
96
|
+
if creds_path and Path(os.path.expanduser(creds_path)).exists():
|
|
97
|
+
return True
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_status(spec: ConnectorSpec, config: dict) -> str:
|
|
102
|
+
"""Return user-facing status: 'not available', 'available', or 'installed'."""
|
|
103
|
+
if not is_installed(spec):
|
|
104
|
+
return "not available"
|
|
105
|
+
if is_configured(spec, config):
|
|
106
|
+
return "installed"
|
|
107
|
+
return "available"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def resolve_hook(dotted_path: str):
|
|
111
|
+
"""Resolve a dotted path to a callable.
|
|
112
|
+
|
|
113
|
+
Returns the callable, or None if *dotted_path* is empty.
|
|
114
|
+
Raises ValueError on malformed paths, ImportError/AttributeError on bad paths.
|
|
115
|
+
"""
|
|
116
|
+
if not dotted_path:
|
|
117
|
+
return None
|
|
118
|
+
if "." not in dotted_path:
|
|
119
|
+
raise ValueError(f"Invalid hook path {dotted_path!r}: expected dotted path like 'module.func'")
|
|
120
|
+
module_path, func_name = dotted_path.rsplit(".", 1)
|
|
121
|
+
mod = importlib.import_module(module_path)
|
|
122
|
+
return getattr(mod, func_name)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def resolve_check_auth(spec: ConnectorSpec, config: dict) -> str | None:
|
|
126
|
+
"""Resolve and call a connector's check_auth callable.
|
|
127
|
+
|
|
128
|
+
Returns the auth status string (e.g. "authenticated", "expired"),
|
|
129
|
+
None if no check_auth is configured, or "error" on failure.
|
|
130
|
+
"""
|
|
131
|
+
if not spec.check_auth:
|
|
132
|
+
return None
|
|
133
|
+
try:
|
|
134
|
+
module_path, func_name = spec.check_auth.rsplit(".", 1)
|
|
135
|
+
mod = importlib.import_module(module_path)
|
|
136
|
+
fn = getattr(mod, func_name)
|
|
137
|
+
return str(fn(config))
|
|
138
|
+
except Exception:
|
|
139
|
+
logger.warning("check_auth failed for connector %s", spec.name, exc_info=True)
|
|
140
|
+
return "error"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_schema_specs(connectors: dict[str, ConnectorSpec] | None = None) -> list[ConnectorSpec]:
|
|
144
|
+
"""Return installed connector specs that declare schema extensions."""
|
|
145
|
+
if connectors is None:
|
|
146
|
+
connectors = discover_connectors()
|
|
147
|
+
return [s for s in connectors.values() if is_installed(s) and s.schema_extensions]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_connector_pipes(connectors: dict[str, ConnectorSpec] | None = None) -> dict[str, type]:
|
|
151
|
+
"""Discover adapter classes from installed connectors.
|
|
152
|
+
|
|
153
|
+
If *connectors* is provided, uses that dict; otherwise calls
|
|
154
|
+
:func:`discover_connectors`. For installed connectors, imports their
|
|
155
|
+
adapter modules and collects adapter classes via the explicit
|
|
156
|
+
``adapter_entries`` mapping. Returns ``{pipe_name: adapter_class}``.
|
|
157
|
+
|
|
158
|
+
Connectors whose pip extra is NOT installed are skipped entirely —
|
|
159
|
+
no import is attempted.
|
|
160
|
+
"""
|
|
161
|
+
if connectors is None:
|
|
162
|
+
connectors = discover_connectors()
|
|
163
|
+
sources: dict[str, type] = {}
|
|
164
|
+
for spec in connectors.values():
|
|
165
|
+
if not is_installed(spec):
|
|
166
|
+
continue
|
|
167
|
+
for pipe_name, entry in spec.adapter_entries.items():
|
|
168
|
+
module_path, class_name = entry.rsplit(":", 1)
|
|
169
|
+
module = importlib.import_module(module_path)
|
|
170
|
+
sources[pipe_name] = getattr(module, class_name)
|
|
171
|
+
return sources
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Framework utilities for connector configuration.
|
|
2
|
+
|
|
3
|
+
Convention-derived paths and seed entries shared across connectors.
|
|
4
|
+
Implements decisions D4 and D5 from connector-configuration-lifecycle.md.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import platform
|
|
14
|
+
import secrets
|
|
15
|
+
import uuid
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from cryptography.fernet import Fernet
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Magic header to distinguish encrypted files from legacy plaintext
|
|
23
|
+
_HEADER = b"FP_ENC\x01"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def credential_path(connector: str, account: str) -> Path:
|
|
27
|
+
"""Convention: ~/.config/footprinter/{connector}_{account}_token.json
|
|
28
|
+
|
|
29
|
+
Returns the portable tilde-form path. Callers needing a filesystem path
|
|
30
|
+
should call ``.expanduser()`` on the result.
|
|
31
|
+
"""
|
|
32
|
+
return Path(f"~/.config/footprinter/{connector}_{account}_token.json")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _salt_path() -> Path:
|
|
36
|
+
"""Path to the salt file used for token encryption key derivation."""
|
|
37
|
+
return Path(os.path.expanduser("~/.config/footprinter/.token_salt"))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_or_create_salt(salt_file: Path | None = None) -> bytes:
|
|
41
|
+
"""Read existing salt or generate a new 16-byte random salt."""
|
|
42
|
+
path = salt_file or _salt_path()
|
|
43
|
+
path = Path(os.path.expanduser(str(path)))
|
|
44
|
+
if path.exists():
|
|
45
|
+
return path.read_bytes()
|
|
46
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
salt = secrets.token_bytes(16)
|
|
48
|
+
path.write_bytes(salt)
|
|
49
|
+
return salt
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _derive_key(salt: bytes) -> bytes:
|
|
53
|
+
"""Derive a Fernet key from machine identity and salt.
|
|
54
|
+
|
|
55
|
+
Uses PBKDF2-HMAC-SHA256 with machine identity (MAC address + hostname)
|
|
56
|
+
as the password material. Returns a URL-safe base64-encoded 32-byte key
|
|
57
|
+
suitable for Fernet.
|
|
58
|
+
"""
|
|
59
|
+
machine_id = f"{uuid.getnode()}-{platform.node()}".encode()
|
|
60
|
+
raw = hashlib.pbkdf2_hmac("sha256", machine_id, salt, 480_000)
|
|
61
|
+
return base64.urlsafe_b64encode(raw)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def save_token(path: Path, data: str | bytes) -> None:
|
|
65
|
+
"""Encrypt *data* with Fernet and write the ciphertext to *path*."""
|
|
66
|
+
path = Path(os.path.expanduser(str(path)))
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
|
|
69
|
+
if isinstance(data, str):
|
|
70
|
+
raw = data.encode()
|
|
71
|
+
else:
|
|
72
|
+
raw = data
|
|
73
|
+
|
|
74
|
+
salt = _get_or_create_salt()
|
|
75
|
+
key = _derive_key(salt)
|
|
76
|
+
f = Fernet(key)
|
|
77
|
+
encrypted = f.encrypt(raw)
|
|
78
|
+
path.write_bytes(_HEADER + encrypted)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_token(path: Path) -> str | bytes | None:
|
|
82
|
+
"""Read and decrypt a token file.
|
|
83
|
+
|
|
84
|
+
Returns ``None`` if the file does not exist. Reads legacy plaintext files
|
|
85
|
+
transparently (files without the encryption header).
|
|
86
|
+
"""
|
|
87
|
+
path = Path(os.path.expanduser(str(path)))
|
|
88
|
+
if not path.exists():
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
raw = path.read_bytes()
|
|
92
|
+
|
|
93
|
+
if raw.startswith(_HEADER):
|
|
94
|
+
salt = _get_or_create_salt()
|
|
95
|
+
key = _derive_key(salt)
|
|
96
|
+
f = Fernet(key)
|
|
97
|
+
decrypted = f.decrypt(raw[len(_HEADER) :])
|
|
98
|
+
# Return same type that was saved: try UTF-8, fall back to bytes
|
|
99
|
+
try:
|
|
100
|
+
return decrypted.decode()
|
|
101
|
+
except UnicodeDecodeError:
|
|
102
|
+
return decrypted
|
|
103
|
+
|
|
104
|
+
# Legacy plaintext — return as string
|
|
105
|
+
try:
|
|
106
|
+
return raw.decode()
|
|
107
|
+
except UnicodeDecodeError:
|
|
108
|
+
return raw
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def source_seed_entry(
|
|
112
|
+
source_type: str,
|
|
113
|
+
account: str,
|
|
114
|
+
*,
|
|
115
|
+
name: str | None = None,
|
|
116
|
+
label: str | None = None,
|
|
117
|
+
) -> dict:
|
|
118
|
+
"""Build a source seed dict for config.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
source_type: Seed source type (e.g. "remote").
|
|
122
|
+
account: Account name.
|
|
123
|
+
name: Override the default ``{source_type}_{account}`` name.
|
|
124
|
+
label: Override the default ``{Source_type} ({account})`` label.
|
|
125
|
+
"""
|
|
126
|
+
return {
|
|
127
|
+
"name": name or f"{source_type}_{account}",
|
|
128
|
+
"source_type": source_type,
|
|
129
|
+
"account": account,
|
|
130
|
+
"label": label or f"{source_type.title()} ({account})",
|
|
131
|
+
"icon": "cloud",
|
|
132
|
+
"enabled": True,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def account_label(account: dict) -> str:
|
|
137
|
+
"""Return user-facing label for an account config entry.
|
|
138
|
+
|
|
139
|
+
Falls back to the internal name if label is missing or empty.
|
|
140
|
+
"""
|
|
141
|
+
return account.get("label") or account["name"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""footprinter.db — public Python API for querying indexed data.
|
|
2
|
+
|
|
3
|
+
Stable signatures, type hints, plain dict returns.
|
|
4
|
+
All functions take sqlite3.Connection as their first parameter.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from footprinter.db import (
|
|
8
|
+
browser,
|
|
9
|
+
chats,
|
|
10
|
+
clients,
|
|
11
|
+
emails,
|
|
12
|
+
files,
|
|
13
|
+
folders,
|
|
14
|
+
messages,
|
|
15
|
+
policies,
|
|
16
|
+
projects,
|
|
17
|
+
search,
|
|
18
|
+
sql_utils,
|
|
19
|
+
status,
|
|
20
|
+
uploads,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"browser",
|
|
25
|
+
"chats",
|
|
26
|
+
"clients",
|
|
27
|
+
"emails",
|
|
28
|
+
"files",
|
|
29
|
+
"folders",
|
|
30
|
+
"messages",
|
|
31
|
+
"policies",
|
|
32
|
+
"projects",
|
|
33
|
+
"search",
|
|
34
|
+
"sql_utils",
|
|
35
|
+
"status",
|
|
36
|
+
"uploads",
|
|
37
|
+
]
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Browser visit queries and write operations.
|
|
2
|
+
|
|
3
|
+
Provides list, detail lookups, and insert functions for the visits table.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sqlite3
|
|
7
|
+
from typing import Any, Dict, Optional, Union
|
|
8
|
+
|
|
9
|
+
from footprinter.db.sql_utils import paginate, paginated_response
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def list_visits(conn: sqlite3.Connection, *, limit: int = 50, page: int = 1) -> dict:
|
|
13
|
+
"""List browser visit entries ordered by visit_time descending.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
conn : sqlite3.Connection
|
|
18
|
+
limit : int
|
|
19
|
+
Maximum rows per page (default 50).
|
|
20
|
+
page : int
|
|
21
|
+
1-based page number (default 1).
|
|
22
|
+
|
|
23
|
+
Returns
|
|
24
|
+
-------
|
|
25
|
+
dict
|
|
26
|
+
``{"visits": [...], "pagination": {page, limit, total, total_pages}}``
|
|
27
|
+
"""
|
|
28
|
+
rows, pagination = paginate(
|
|
29
|
+
conn,
|
|
30
|
+
"SELECT COUNT(*) FROM visits bv WHERE bv.status != 'removed'",
|
|
31
|
+
"""
|
|
32
|
+
SELECT bv.id, bv.url, bv.title, bv.visit_time, bv.browser, bv.visit_count,
|
|
33
|
+
bv.client_id, bv.project_id,
|
|
34
|
+
client.name AS client_name, project.project_name,
|
|
35
|
+
bv.mcp_view, bv.mcp_read
|
|
36
|
+
FROM visits bv
|
|
37
|
+
LEFT JOIN clients client ON bv.client_id = client.id
|
|
38
|
+
LEFT JOIN projects project ON bv.project_id = project.id
|
|
39
|
+
WHERE bv.status != 'removed'
|
|
40
|
+
ORDER BY bv.visit_time DESC
|
|
41
|
+
LIMIT ? OFFSET ?
|
|
42
|
+
""",
|
|
43
|
+
[],
|
|
44
|
+
page=page,
|
|
45
|
+
limit=limit,
|
|
46
|
+
)
|
|
47
|
+
visits = [
|
|
48
|
+
{
|
|
49
|
+
"id": r["id"],
|
|
50
|
+
"url": r["url"],
|
|
51
|
+
"title": r["title"],
|
|
52
|
+
"visit_time": r["visit_time"],
|
|
53
|
+
"browser": r["browser"],
|
|
54
|
+
"visit_count": r["visit_count"],
|
|
55
|
+
"client_id": r["client_id"],
|
|
56
|
+
"project_id": r["project_id"],
|
|
57
|
+
"client_name": r["client_name"],
|
|
58
|
+
"project_name": r["project_name"],
|
|
59
|
+
"mcp_view": r["mcp_view"],
|
|
60
|
+
"mcp_read": r["mcp_read"],
|
|
61
|
+
}
|
|
62
|
+
for r in rows
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
return paginated_response("visits", visits, pagination)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_visit(conn: sqlite3.Connection, entry_id: int) -> dict | None:
|
|
69
|
+
"""Get a single browser history entry by ID.
|
|
70
|
+
|
|
71
|
+
Returns
|
|
72
|
+
-------
|
|
73
|
+
dict or None
|
|
74
|
+
Includes indexed_at. None if not found.
|
|
75
|
+
"""
|
|
76
|
+
cursor = conn.execute(
|
|
77
|
+
"""
|
|
78
|
+
SELECT bv.id, bv.url, bv.title, bv.visit_time, bv.browser, bv.visit_count,
|
|
79
|
+
bv.indexed_at, bv.status,
|
|
80
|
+
bv.client_id, bv.project_id,
|
|
81
|
+
client.name AS client_name, project.project_name,
|
|
82
|
+
bv.mcp_view, bv.mcp_read
|
|
83
|
+
FROM visits bv
|
|
84
|
+
LEFT JOIN clients client ON bv.client_id = client.id
|
|
85
|
+
LEFT JOIN projects project ON bv.project_id = project.id
|
|
86
|
+
WHERE bv.id = ? AND bv.status != 'removed'
|
|
87
|
+
""",
|
|
88
|
+
(entry_id,),
|
|
89
|
+
)
|
|
90
|
+
row = cursor.fetchone()
|
|
91
|
+
if row is None:
|
|
92
|
+
return None
|
|
93
|
+
return {
|
|
94
|
+
"id": row["id"],
|
|
95
|
+
"url": row["url"],
|
|
96
|
+
"title": row["title"],
|
|
97
|
+
"visit_time": row["visit_time"],
|
|
98
|
+
"browser": row["browser"],
|
|
99
|
+
"visit_count": row["visit_count"],
|
|
100
|
+
"indexed_at": row["indexed_at"],
|
|
101
|
+
"status": row["status"],
|
|
102
|
+
"client_id": row["client_id"],
|
|
103
|
+
"project_id": row["project_id"],
|
|
104
|
+
"client_name": row["client_name"],
|
|
105
|
+
"project_name": row["project_name"],
|
|
106
|
+
"mcp_view": row["mcp_view"],
|
|
107
|
+
"mcp_read": row["mcp_read"],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def update_visit_relationships(
|
|
112
|
+
conn: sqlite3.Connection,
|
|
113
|
+
visit_id: int,
|
|
114
|
+
*,
|
|
115
|
+
project_id: Optional[int] = None,
|
|
116
|
+
client_id: Optional[int] = None,
|
|
117
|
+
) -> Optional[bool]:
|
|
118
|
+
"""Update project and/or client assignment on a visit.
|
|
119
|
+
|
|
120
|
+
Only updates fields that are passed (not None). Pass ``0`` to clear
|
|
121
|
+
a field (set to NULL). Stamps ``assignment_source = 'user'``
|
|
122
|
+
when the column exists (app-scope DBs only).
|
|
123
|
+
Returns True on success, None if visit not found.
|
|
124
|
+
"""
|
|
125
|
+
cursor = conn.execute("SELECT id FROM visits WHERE id = ?", (visit_id,))
|
|
126
|
+
if cursor.fetchone() is None:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
if project_id is not None and project_id != 0:
|
|
130
|
+
proj = conn.execute("SELECT id FROM projects WHERE id = ?", (project_id,)).fetchone()
|
|
131
|
+
if not proj:
|
|
132
|
+
raise ValueError(f"No project with id {project_id}")
|
|
133
|
+
if client_id is not None and client_id != 0:
|
|
134
|
+
cli = conn.execute("SELECT id FROM clients WHERE id = ?", (client_id,)).fetchone()
|
|
135
|
+
if not cli:
|
|
136
|
+
raise ValueError(f"No client with id {client_id}")
|
|
137
|
+
|
|
138
|
+
sets: list[str] = []
|
|
139
|
+
params: list = []
|
|
140
|
+
if project_id is not None:
|
|
141
|
+
if project_id == 0:
|
|
142
|
+
sets.append("project_id = NULL")
|
|
143
|
+
else:
|
|
144
|
+
sets.append("project_id = ?")
|
|
145
|
+
params.append(project_id)
|
|
146
|
+
if client_id is not None:
|
|
147
|
+
if client_id == 0:
|
|
148
|
+
sets.append("client_id = NULL")
|
|
149
|
+
else:
|
|
150
|
+
sets.append("client_id = ?")
|
|
151
|
+
params.append(client_id)
|
|
152
|
+
if not sets:
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
sets.append("assignment_source = 'user'")
|
|
156
|
+
params.append(visit_id)
|
|
157
|
+
try:
|
|
158
|
+
conn.execute(f"UPDATE visits SET {', '.join(sets)} WHERE id = ?", params)
|
|
159
|
+
except sqlite3.OperationalError as e:
|
|
160
|
+
if "no such column" not in str(e):
|
|
161
|
+
raise
|
|
162
|
+
# assignment_source not present (tool-only DB)
|
|
163
|
+
sets.pop()
|
|
164
|
+
conn.execute(f"UPDATE visits SET {', '.join(sets)} WHERE id = ?", params)
|
|
165
|
+
conn.commit()
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# Write operations
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def insert_visit(conn: sqlite3.Connection, history_data: Dict[str, Any]) -> Union[int, bool]:
|
|
175
|
+
"""Insert a browser visit record.
|
|
176
|
+
|
|
177
|
+
Returns the row ID on success, or False if the visit already exists
|
|
178
|
+
(duplicate on url + visit_time + browser).
|
|
179
|
+
"""
|
|
180
|
+
cursor = conn.cursor()
|
|
181
|
+
cursor.execute(
|
|
182
|
+
"""
|
|
183
|
+
INSERT OR IGNORE INTO visits
|
|
184
|
+
(url, title, visit_time, browser, visit_count,
|
|
185
|
+
indexed_at, updated_at)
|
|
186
|
+
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
187
|
+
""",
|
|
188
|
+
(
|
|
189
|
+
history_data["url"],
|
|
190
|
+
history_data.get("title"),
|
|
191
|
+
history_data["visit_time"],
|
|
192
|
+
history_data["browser"],
|
|
193
|
+
history_data.get("visit_count", 1),
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
if cursor.rowcount == 0:
|
|
197
|
+
return False
|
|
198
|
+
return cursor.lastrowid
|