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.
Files changed (138) hide show
  1. footprinter/__init__.py +8 -0
  2. footprinter/access.py +431 -0
  3. footprinter/api/__init__.py +1 -0
  4. footprinter/api/db.py +61 -0
  5. footprinter/api/entities.py +250 -0
  6. footprinter/api/search.py +47 -0
  7. footprinter/api/semantic.py +33 -0
  8. footprinter/api/server.py +66 -0
  9. footprinter/api/status.py +15 -0
  10. footprinter/bundled/__init__.py +0 -0
  11. footprinter/bundled/config.example.yaml +161 -0
  12. footprinter/bundled/patterns/context_patterns.yaml +18 -0
  13. footprinter/bundled/patterns/extensions.yaml +283 -0
  14. footprinter/bundled/patterns/filename_patterns.yaml +61 -0
  15. footprinter/bundled/patterns/mime_mappings.yaml +68 -0
  16. footprinter/bundled/patterns/salesforce_rules.yaml +84 -0
  17. footprinter/bundled/patterns/security_patterns.yaml +27 -0
  18. footprinter/bundled/samples/hidden-client-file-sample.txt +2 -0
  19. footprinter/bundled/samples/opaque-project-file-sample.txt +2 -0
  20. footprinter/bundled/samples/visible-file-sample.txt +2 -0
  21. footprinter/cli/__init__.py +135 -0
  22. footprinter/cli/__main__.py +6 -0
  23. footprinter/cli/_common.py +327 -0
  24. footprinter/cli/_policy_helpers.py +646 -0
  25. footprinter/cli/_prompt.py +220 -0
  26. footprinter/cli/_sample_seed.py +204 -0
  27. footprinter/cli/api_cmd.py +32 -0
  28. footprinter/cli/connect.py +591 -0
  29. footprinter/cli/data.py +879 -0
  30. footprinter/cli/delete.py +128 -0
  31. footprinter/cli/ingest.py +543 -0
  32. footprinter/cli/mcp_cmd.py +750 -0
  33. footprinter/cli/mcp_setup.py +306 -0
  34. footprinter/cli/search.py +393 -0
  35. footprinter/cli/search_cmd.py +69 -0
  36. footprinter/cli/setup.py +2001 -0
  37. footprinter/cli/status.py +747 -0
  38. footprinter/cli/status_cmd.py +104 -0
  39. footprinter/cli/upsert.py +794 -0
  40. footprinter/cli/vectorize_cmd.py +215 -0
  41. footprinter/cli/view.py +322 -0
  42. footprinter/connectors/__init__.py +171 -0
  43. footprinter/connectors/config_utils.py +141 -0
  44. footprinter/db/__init__.py +37 -0
  45. footprinter/db/browser.py +198 -0
  46. footprinter/db/chats.py +602 -0
  47. footprinter/db/clients.py +307 -0
  48. footprinter/db/emails.py +279 -0
  49. footprinter/db/files.py +724 -0
  50. footprinter/db/folders.py +659 -0
  51. footprinter/db/messages.py +192 -0
  52. footprinter/db/policies.py +151 -0
  53. footprinter/db/projects.py +673 -0
  54. footprinter/db/search.py +573 -0
  55. footprinter/db/sql_utils.py +168 -0
  56. footprinter/db/status.py +320 -0
  57. footprinter/db/uploads.py +70 -0
  58. footprinter/ingest/__init__.py +0 -0
  59. footprinter/ingest/adapters/__init__.py +33 -0
  60. footprinter/ingest/adapters/browser.py +54 -0
  61. footprinter/ingest/adapters/chat.py +57 -0
  62. footprinter/ingest/adapters/ingest.py +146 -0
  63. footprinter/ingest/adapters/local_files.py +68 -0
  64. footprinter/ingest/adapters/local_folders.py +52 -0
  65. footprinter/ingest/adapters/protocol.py +174 -0
  66. footprinter/ingest/browser_indexer.py +216 -0
  67. footprinter/ingest/chat_dedup.py +156 -0
  68. footprinter/ingest/chat_indexer.py +487 -0
  69. footprinter/ingest/chat_parsers/__init__.py +8 -0
  70. footprinter/ingest/chat_parsers/chatgpt_parser.py +229 -0
  71. footprinter/ingest/chat_parsers/claude_parser.py +161 -0
  72. footprinter/ingest/cli.py +827 -0
  73. footprinter/ingest/content_extractors.py +117 -0
  74. footprinter/ingest/database.py +36 -0
  75. footprinter/ingest/db/__init__.py +1 -0
  76. footprinter/ingest/db/connector_schema.py +47 -0
  77. footprinter/ingest/db/migration.py +315 -0
  78. footprinter/ingest/db/schema.py +1043 -0
  79. footprinter/ingest/db/security.py +6 -0
  80. footprinter/ingest/file_indexer.py +223 -0
  81. footprinter/ingest/file_scanner.py +277 -0
  82. footprinter/ingest/folder_indexer.py +226 -0
  83. footprinter/ingest/full_content_extractor.py +321 -0
  84. footprinter/ingest/orchestrator.py +112 -0
  85. footprinter/ingest/pipe_runner.py +200 -0
  86. footprinter/ingest/processing.py +165 -0
  87. footprinter/ingest/registry.py +186 -0
  88. footprinter/ingest/run_record.py +91 -0
  89. footprinter/ingest/status.py +346 -0
  90. footprinter/mcp/__init__.py +0 -0
  91. footprinter/mcp/__main__.py +5 -0
  92. footprinter/mcp/db.py +67 -0
  93. footprinter/mcp/errors.py +105 -0
  94. footprinter/mcp/extraction.py +226 -0
  95. footprinter/mcp/server.py +39 -0
  96. footprinter/mcp/tools/__init__.py +0 -0
  97. footprinter/mcp/tools/navigation.py +70 -0
  98. footprinter/mcp/tools/read.py +75 -0
  99. footprinter/mcp/tools/search.py +158 -0
  100. footprinter/mcp/tools/semantic.py +79 -0
  101. footprinter/mcp/tools/status.py +19 -0
  102. footprinter/paths.py +117 -0
  103. footprinter/permissions.py +1152 -0
  104. footprinter/semantic/__init__.py +13 -0
  105. footprinter/semantic/chunking.py +52 -0
  106. footprinter/semantic/embeddings.py +23 -0
  107. footprinter/semantic/hybrid_search.py +273 -0
  108. footprinter/semantic/vector_store.py +471 -0
  109. footprinter/services/__init__.py +49 -0
  110. footprinter/services/access_service.py +342 -0
  111. footprinter/services/chat_service.py +85 -0
  112. footprinter/services/client_service.py +267 -0
  113. footprinter/services/content_service.py +181 -0
  114. footprinter/services/email_service.py +89 -0
  115. footprinter/services/file_service.py +83 -0
  116. footprinter/services/folder_service.py +122 -0
  117. footprinter/services/includes.py +19 -0
  118. footprinter/services/ingest_service.py +231 -0
  119. footprinter/services/project_service.py +262 -0
  120. footprinter/services/roles.py +25 -0
  121. footprinter/services/search_service.py +177 -0
  122. footprinter/services/semantic_service.py +360 -0
  123. footprinter/services/status_service.py +18 -0
  124. footprinter/services/visit_service.py +65 -0
  125. footprinter/source_registry.py +194 -0
  126. footprinter/utils/__init__.py +7 -0
  127. footprinter/utils/hash_utils.py +59 -0
  128. footprinter/utils/logging_config.py +68 -0
  129. footprinter/utils/mime.py +30 -0
  130. footprinter/utils/text.py +6 -0
  131. footprinter/utils/time.py +11 -0
  132. footprinter/visibility.py +1264 -0
  133. footprinter_cli-1.0.0rc1.dist-info/LICENSE +21 -0
  134. footprinter_cli-1.0.0rc1.dist-info/METADATA +223 -0
  135. footprinter_cli-1.0.0rc1.dist-info/RECORD +138 -0
  136. footprinter_cli-1.0.0rc1.dist-info/WHEEL +5 -0
  137. footprinter_cli-1.0.0rc1.dist-info/entry_points.txt +2 -0
  138. 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