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,1264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Visibility resolution for Claude metadata access.
|
|
3
|
+
|
|
4
|
+
Two-tier model:
|
|
5
|
+
- Policies: Explicit rules (file:*, folder:*, source:*, project:*, client:*)
|
|
6
|
+
- Baseline: Hardcoded fallback (BASELINE_VISIBILITY = 'opaque')
|
|
7
|
+
|
|
8
|
+
Most-restrictive-wins semantics applies ONLY among matching policies.
|
|
9
|
+
hidden > opaque > visible
|
|
10
|
+
|
|
11
|
+
Hierarchy layers (checked for policies):
|
|
12
|
+
file:{id} → folder prefix → folder FK → project:{id} → client:{id} → source:*
|
|
13
|
+
email:{id} → project:{id} → client:{id} → account:{acct} → source:emails
|
|
14
|
+
chat:{id} → project:{id} → client:{id} → account:{acct} → source:chats
|
|
15
|
+
|
|
16
|
+
Resolution:
|
|
17
|
+
1. Collect explicit values from matching policies only
|
|
18
|
+
2. If any policy is 'hidden' → return 'hidden'
|
|
19
|
+
3. If any policy is 'opaque' → return 'opaque'
|
|
20
|
+
4. If any policy is 'visible' → return 'visible'
|
|
21
|
+
5. No policies matched → return BASELINE_VISIBILITY
|
|
22
|
+
|
|
23
|
+
Visibility states:
|
|
24
|
+
- 'hidden' - item doesn't exist to MCP (excluded from all results and counts)
|
|
25
|
+
- 'opaque' - appears with minimal info (id, content_type, source)
|
|
26
|
+
- 'visible' - full metadata returned
|
|
27
|
+
|
|
28
|
+
Hard Rule: Content read access can never exceed metadata visibility.
|
|
29
|
+
If an item is hidden or opaque, it cannot be read regardless of permission policy.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import os
|
|
33
|
+
import sqlite3
|
|
34
|
+
from typing import Dict, List, Literal, Optional, Tuple
|
|
35
|
+
|
|
36
|
+
from footprinter.db.policies import is_folder_path_scope
|
|
37
|
+
from footprinter.db.sql_utils import chunked_query as _chunked_query
|
|
38
|
+
|
|
39
|
+
VisibilityState = Literal["hidden", "opaque", "visible"]
|
|
40
|
+
|
|
41
|
+
# Hardcoded baseline - used when NO policies match
|
|
42
|
+
BASELINE_VISIBILITY: VisibilityState = "opaque"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_visibility(conn: sqlite3.Connection, item_type: str, item_id: int) -> VisibilityState:
|
|
46
|
+
"""
|
|
47
|
+
Resolve visibility for an item.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
conn: SQLite connection with row_factory = sqlite3.Row
|
|
51
|
+
item_type: 'file', 'email', 'chat', 'folder'
|
|
52
|
+
item_id: Row ID in the relevant table
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
'hidden', 'opaque', or 'visible'
|
|
56
|
+
"""
|
|
57
|
+
cursor = conn.cursor()
|
|
58
|
+
|
|
59
|
+
if item_type == "file":
|
|
60
|
+
return _get_file_visibility(cursor, item_id)
|
|
61
|
+
elif item_type == "email":
|
|
62
|
+
return _get_email_visibility(cursor, item_id)
|
|
63
|
+
elif item_type == "chat":
|
|
64
|
+
return _get_chat_visibility(cursor, item_id)
|
|
65
|
+
elif item_type == "folder":
|
|
66
|
+
return _get_folder_visibility(cursor, item_id)
|
|
67
|
+
elif item_type == "visit":
|
|
68
|
+
return _get_browser_visibility(cursor, item_id)
|
|
69
|
+
elif item_type == "project":
|
|
70
|
+
resolved, _ = _resolve_project_visibility_with_source(cursor, item_id)
|
|
71
|
+
return resolved
|
|
72
|
+
elif item_type == "client":
|
|
73
|
+
resolved, _ = _resolve_client_visibility_with_source(cursor, item_id)
|
|
74
|
+
return resolved
|
|
75
|
+
else:
|
|
76
|
+
return BASELINE_VISIBILITY # Baseline for unknown types
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_visibility_with_source(
|
|
80
|
+
conn: sqlite3.Connection, item_type: str, item_id: int
|
|
81
|
+
) -> Tuple[VisibilityState, str]:
|
|
82
|
+
"""
|
|
83
|
+
Resolve visibility and return the source that determined it.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
conn: SQLite connection with row_factory = sqlite3.Row
|
|
87
|
+
item_type: 'file', 'email', 'chat', 'folder'
|
|
88
|
+
item_id: Row ID in the relevant table
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Tuple of (resolved_visibility, source_scope)
|
|
92
|
+
e.g., ('visible', "folder:~/Work") or ('opaque', "baseline")
|
|
93
|
+
"""
|
|
94
|
+
cursor = conn.cursor()
|
|
95
|
+
|
|
96
|
+
if item_type == "file":
|
|
97
|
+
return _resolve_file_visibility_with_source(cursor, item_id)
|
|
98
|
+
elif item_type == "email":
|
|
99
|
+
return _resolve_email_visibility_with_source(cursor, item_id)
|
|
100
|
+
elif item_type == "chat":
|
|
101
|
+
return _resolve_chat_visibility_with_source(cursor, item_id)
|
|
102
|
+
elif item_type == "folder":
|
|
103
|
+
return _resolve_folder_visibility_with_source(cursor, item_id)
|
|
104
|
+
elif item_type == "project":
|
|
105
|
+
return _resolve_project_visibility_with_source(cursor, item_id)
|
|
106
|
+
elif item_type == "client":
|
|
107
|
+
return _resolve_client_visibility_with_source(cursor, item_id)
|
|
108
|
+
elif item_type == "visit":
|
|
109
|
+
return _resolve_browser_visibility_with_source(cursor, item_id)
|
|
110
|
+
else:
|
|
111
|
+
return (BASELINE_VISIBILITY, "baseline")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def batch_resolve_visibility(
|
|
115
|
+
conn: sqlite3.Connection, item_type: str, item_ids: List[int]
|
|
116
|
+
) -> Dict[int, Tuple[VisibilityState, str]]:
|
|
117
|
+
"""
|
|
118
|
+
Resolve visibility for multiple items efficiently.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
conn: SQLite connection with row_factory = sqlite3.Row
|
|
122
|
+
item_type: 'file', 'email', 'chat', 'folder', 'project', 'client'
|
|
123
|
+
item_ids: List of row IDs
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Dict mapping item_id to (visibility_state, source) tuple
|
|
127
|
+
"""
|
|
128
|
+
if not item_ids:
|
|
129
|
+
return {}
|
|
130
|
+
|
|
131
|
+
cursor = conn.cursor()
|
|
132
|
+
|
|
133
|
+
if item_type == "file":
|
|
134
|
+
return _batch_resolve_file_visibility(cursor, item_ids)
|
|
135
|
+
elif item_type == "project":
|
|
136
|
+
return _batch_resolve_project_visibility(cursor, item_ids)
|
|
137
|
+
elif item_type == "client":
|
|
138
|
+
return _batch_resolve_client_visibility(cursor, item_ids)
|
|
139
|
+
elif item_type == "email":
|
|
140
|
+
return _batch_resolve_email_visibility(cursor, item_ids)
|
|
141
|
+
elif item_type == "chat":
|
|
142
|
+
return _batch_resolve_chat_visibility(cursor, item_ids)
|
|
143
|
+
elif item_type == "folder":
|
|
144
|
+
return _batch_resolve_folder_visibility(cursor, item_ids)
|
|
145
|
+
elif item_type == "visit":
|
|
146
|
+
return _batch_resolve_browser_visibility(cursor, item_ids)
|
|
147
|
+
else:
|
|
148
|
+
return {id_: (BASELINE_VISIBILITY, "baseline") for id_ in item_ids}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def get_source_visibility(conn, scope: str) -> VisibilityState:
|
|
152
|
+
"""Get the visibility state for a source scope (e.g. 'source:browser')."""
|
|
153
|
+
cursor = conn.cursor()
|
|
154
|
+
result = _get_policy(cursor, scope)
|
|
155
|
+
if result is not None:
|
|
156
|
+
return result
|
|
157
|
+
state, _ = _get_global_baseline(cursor)
|
|
158
|
+
return state
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _batch_resolve_file_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
|
|
162
|
+
"""Batch resolve visibility for files."""
|
|
163
|
+
conn = cursor.connection
|
|
164
|
+
|
|
165
|
+
# Pre-fetch all visibility policies
|
|
166
|
+
cursor.execute("SELECT scope, setting FROM visibility_policies")
|
|
167
|
+
all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
|
|
168
|
+
|
|
169
|
+
# Global policy fallback
|
|
170
|
+
if "global" in all_policies:
|
|
171
|
+
global_baseline = (_resolve(all_policies["global"]), "global")
|
|
172
|
+
else:
|
|
173
|
+
global_baseline = (BASELINE_VISIBILITY, "baseline")
|
|
174
|
+
|
|
175
|
+
# Pre-fetch folder policies sorted by length for prefix matching
|
|
176
|
+
# Exclude numeric-only suffixes (folder:{id}) — those are item-level scopes, not paths
|
|
177
|
+
folder_policies = [
|
|
178
|
+
(scope, setting)
|
|
179
|
+
for scope, setting in all_policies.items()
|
|
180
|
+
if scope.startswith("folder:") and is_folder_path_scope(scope)
|
|
181
|
+
]
|
|
182
|
+
folder_policies.sort(key=lambda x: len(x[0]), reverse=True)
|
|
183
|
+
|
|
184
|
+
# Pre-fetch file data (chunked to stay under SQLite variable limit)
|
|
185
|
+
rows = _chunked_query(
|
|
186
|
+
cursor,
|
|
187
|
+
"""
|
|
188
|
+
SELECT file.id, file.path, file.project_id, file.folder_id, project.client_id
|
|
189
|
+
FROM files file
|
|
190
|
+
LEFT JOIN projects project ON file.project_id = project.id
|
|
191
|
+
WHERE file.id IN ({placeholders})
|
|
192
|
+
""",
|
|
193
|
+
item_ids,
|
|
194
|
+
)
|
|
195
|
+
files = {row["id"]: row for row in rows}
|
|
196
|
+
|
|
197
|
+
# Collect unique parent entity IDs for batch resolution
|
|
198
|
+
folder_ids = set()
|
|
199
|
+
project_ids = set()
|
|
200
|
+
client_ids = set()
|
|
201
|
+
for row in files.values():
|
|
202
|
+
if row["folder_id"]:
|
|
203
|
+
folder_ids.add(row["folder_id"])
|
|
204
|
+
if row["project_id"]:
|
|
205
|
+
project_ids.add(row["project_id"])
|
|
206
|
+
if row["client_id"]:
|
|
207
|
+
client_ids.add(row["client_id"])
|
|
208
|
+
|
|
209
|
+
# Batch resolve parent entities
|
|
210
|
+
folder_visibility = batch_resolve_visibility(conn, "folder", list(folder_ids)) if folder_ids else {}
|
|
211
|
+
project_visibility = batch_resolve_visibility(conn, "project", list(project_ids)) if project_ids else {}
|
|
212
|
+
client_visibility = batch_resolve_visibility(conn, "client", list(client_ids)) if client_ids else {}
|
|
213
|
+
|
|
214
|
+
results = {}
|
|
215
|
+
for file_id in item_ids:
|
|
216
|
+
if file_id not in files:
|
|
217
|
+
results[file_id] = (BASELINE_VISIBILITY, "not_found")
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
row = files[file_id]
|
|
221
|
+
policies: List[Tuple[Optional[VisibilityState], str]] = []
|
|
222
|
+
|
|
223
|
+
# 1. Item-level policy
|
|
224
|
+
item_scope = f"file:{file_id}"
|
|
225
|
+
if item_scope in all_policies:
|
|
226
|
+
policies.append((_resolve(all_policies[item_scope]), item_scope))
|
|
227
|
+
|
|
228
|
+
# 2. Folder prefix match (most specific first)
|
|
229
|
+
path = row["path"] or ""
|
|
230
|
+
if path:
|
|
231
|
+
for scope, setting in folder_policies:
|
|
232
|
+
prefix = scope[len("folder:") :]
|
|
233
|
+
if prefix.startswith("~"):
|
|
234
|
+
prefix = os.path.expanduser(prefix)
|
|
235
|
+
if path.startswith(prefix):
|
|
236
|
+
policies.append((_resolve(setting), scope))
|
|
237
|
+
break
|
|
238
|
+
|
|
239
|
+
# 3. Folder FK via full resolution (skip baseline)
|
|
240
|
+
folder_id = row["folder_id"]
|
|
241
|
+
if folder_id and folder_id in folder_visibility:
|
|
242
|
+
state, src = folder_visibility[folder_id]
|
|
243
|
+
if src != "baseline":
|
|
244
|
+
policies.append((state, f"folder:{folder_id} (via {src})"))
|
|
245
|
+
|
|
246
|
+
# 4. Project-level via full resolution (skip baseline)
|
|
247
|
+
project_id = row["project_id"]
|
|
248
|
+
if project_id and project_id in project_visibility:
|
|
249
|
+
state, src = project_visibility[project_id]
|
|
250
|
+
if src != "baseline":
|
|
251
|
+
policies.append((state, f"project:{project_id} (via {src})"))
|
|
252
|
+
|
|
253
|
+
# 5. Client-level via full resolution (skip baseline)
|
|
254
|
+
client_id = row["client_id"]
|
|
255
|
+
if client_id and client_id in client_visibility:
|
|
256
|
+
state, src = client_visibility[client_id]
|
|
257
|
+
if src != "baseline":
|
|
258
|
+
policies.append((state, f"client:{client_id} (via {src})"))
|
|
259
|
+
|
|
260
|
+
# 6. Source policy
|
|
261
|
+
source_scope = "source:files"
|
|
262
|
+
if source_scope in all_policies:
|
|
263
|
+
policies.append((_resolve(all_policies[source_scope]), source_scope))
|
|
264
|
+
|
|
265
|
+
# Resolve: most restrictive wins (hidden > opaque > visible)
|
|
266
|
+
for value, source in policies:
|
|
267
|
+
if value == "hidden":
|
|
268
|
+
results[file_id] = ("hidden", source)
|
|
269
|
+
break
|
|
270
|
+
else:
|
|
271
|
+
for value, source in policies:
|
|
272
|
+
if value == "opaque":
|
|
273
|
+
results[file_id] = ("opaque", source)
|
|
274
|
+
break
|
|
275
|
+
else:
|
|
276
|
+
for value, source in policies:
|
|
277
|
+
if value == "visible":
|
|
278
|
+
results[file_id] = ("visible", source)
|
|
279
|
+
break
|
|
280
|
+
else:
|
|
281
|
+
results[file_id] = global_baseline
|
|
282
|
+
|
|
283
|
+
return results
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _batch_resolve_project_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
|
|
287
|
+
"""Batch resolve visibility for projects."""
|
|
288
|
+
cursor.execute("SELECT scope, setting FROM visibility_policies")
|
|
289
|
+
all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
|
|
290
|
+
|
|
291
|
+
# Global policy fallback
|
|
292
|
+
if "global" in all_policies:
|
|
293
|
+
global_baseline = (_resolve(all_policies["global"]), "global")
|
|
294
|
+
else:
|
|
295
|
+
global_baseline = (BASELINE_VISIBILITY, "baseline")
|
|
296
|
+
|
|
297
|
+
# Pre-fetch project data for client_id (chunked)
|
|
298
|
+
rows = _chunked_query(
|
|
299
|
+
cursor,
|
|
300
|
+
"SELECT id, client_id FROM projects WHERE id IN ({placeholders})",
|
|
301
|
+
item_ids,
|
|
302
|
+
)
|
|
303
|
+
projects = {row["id"]: row for row in rows}
|
|
304
|
+
|
|
305
|
+
results = {}
|
|
306
|
+
for project_id in item_ids:
|
|
307
|
+
policies: List[Tuple[Optional[VisibilityState], str]] = []
|
|
308
|
+
|
|
309
|
+
# 1. Project-level policy
|
|
310
|
+
proj_scope = f"project:{project_id}"
|
|
311
|
+
if proj_scope in all_policies:
|
|
312
|
+
policies.append((_resolve(all_policies[proj_scope]), proj_scope))
|
|
313
|
+
|
|
314
|
+
# 2. Client-level policy
|
|
315
|
+
if project_id in projects and projects[project_id]["client_id"]:
|
|
316
|
+
client_scope = f"client:{projects[project_id]['client_id']}"
|
|
317
|
+
if client_scope in all_policies:
|
|
318
|
+
policies.append((_resolve(all_policies[client_scope]), client_scope))
|
|
319
|
+
|
|
320
|
+
# 3. Source policy for projects
|
|
321
|
+
source_scope = "source:projects"
|
|
322
|
+
if source_scope in all_policies:
|
|
323
|
+
policies.append((_resolve(all_policies[source_scope]), source_scope))
|
|
324
|
+
|
|
325
|
+
# Resolve: most restrictive wins
|
|
326
|
+
for value, source in policies:
|
|
327
|
+
if value == "hidden":
|
|
328
|
+
results[project_id] = ("hidden", source)
|
|
329
|
+
break
|
|
330
|
+
else:
|
|
331
|
+
for value, source in policies:
|
|
332
|
+
if value == "opaque":
|
|
333
|
+
results[project_id] = ("opaque", source)
|
|
334
|
+
break
|
|
335
|
+
else:
|
|
336
|
+
for value, source in policies:
|
|
337
|
+
if value == "visible":
|
|
338
|
+
results[project_id] = ("visible", source)
|
|
339
|
+
break
|
|
340
|
+
else:
|
|
341
|
+
results[project_id] = global_baseline
|
|
342
|
+
|
|
343
|
+
return results
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _batch_resolve_client_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
|
|
347
|
+
"""Batch resolve visibility for clients."""
|
|
348
|
+
cursor.execute("SELECT scope, setting FROM visibility_policies")
|
|
349
|
+
all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
|
|
350
|
+
|
|
351
|
+
# Global policy fallback
|
|
352
|
+
if "global" in all_policies:
|
|
353
|
+
global_baseline = (_resolve(all_policies["global"]), "global")
|
|
354
|
+
else:
|
|
355
|
+
global_baseline = (BASELINE_VISIBILITY, "baseline")
|
|
356
|
+
|
|
357
|
+
results = {}
|
|
358
|
+
for client_id in item_ids:
|
|
359
|
+
policies: List[Tuple[Optional[VisibilityState], str]] = []
|
|
360
|
+
|
|
361
|
+
# 1. Client-level policy
|
|
362
|
+
client_scope = f"client:{client_id}"
|
|
363
|
+
if client_scope in all_policies:
|
|
364
|
+
policies.append((_resolve(all_policies[client_scope]), client_scope))
|
|
365
|
+
|
|
366
|
+
# 2. Source policy for clients
|
|
367
|
+
source_scope = "source:clients"
|
|
368
|
+
if source_scope in all_policies:
|
|
369
|
+
policies.append((_resolve(all_policies[source_scope]), source_scope))
|
|
370
|
+
|
|
371
|
+
# Resolve: most restrictive wins
|
|
372
|
+
for value, source in policies:
|
|
373
|
+
if value == "hidden":
|
|
374
|
+
results[client_id] = ("hidden", source)
|
|
375
|
+
break
|
|
376
|
+
else:
|
|
377
|
+
for value, source in policies:
|
|
378
|
+
if value == "opaque":
|
|
379
|
+
results[client_id] = ("opaque", source)
|
|
380
|
+
break
|
|
381
|
+
else:
|
|
382
|
+
for value, source in policies:
|
|
383
|
+
if value == "visible":
|
|
384
|
+
results[client_id] = ("visible", source)
|
|
385
|
+
break
|
|
386
|
+
else:
|
|
387
|
+
results[client_id] = global_baseline
|
|
388
|
+
|
|
389
|
+
return results
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _batch_resolve_email_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
|
|
393
|
+
"""Batch resolve visibility for emails."""
|
|
394
|
+
conn = cursor.connection
|
|
395
|
+
|
|
396
|
+
cursor.execute("SELECT scope, setting FROM visibility_policies")
|
|
397
|
+
all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
|
|
398
|
+
|
|
399
|
+
# Global policy fallback
|
|
400
|
+
if "global" in all_policies:
|
|
401
|
+
global_baseline = (_resolve(all_policies["global"]), "global")
|
|
402
|
+
else:
|
|
403
|
+
global_baseline = (BASELINE_VISIBILITY, "baseline")
|
|
404
|
+
|
|
405
|
+
rows = _chunked_query(
|
|
406
|
+
cursor,
|
|
407
|
+
"""
|
|
408
|
+
SELECT email.id, email.account, email.project_id, project.client_id
|
|
409
|
+
FROM emails email
|
|
410
|
+
LEFT JOIN projects project ON email.project_id = project.id
|
|
411
|
+
WHERE email.id IN ({placeholders})
|
|
412
|
+
""",
|
|
413
|
+
item_ids,
|
|
414
|
+
)
|
|
415
|
+
emails = {row["id"]: row for row in rows}
|
|
416
|
+
|
|
417
|
+
# Collect unique parent entity IDs for batch resolution
|
|
418
|
+
project_ids = set()
|
|
419
|
+
client_ids = set()
|
|
420
|
+
for row in emails.values():
|
|
421
|
+
if row["project_id"]:
|
|
422
|
+
project_ids.add(row["project_id"])
|
|
423
|
+
if row["client_id"]:
|
|
424
|
+
client_ids.add(row["client_id"])
|
|
425
|
+
|
|
426
|
+
# Batch resolve parent entities
|
|
427
|
+
project_visibility = batch_resolve_visibility(conn, "project", list(project_ids)) if project_ids else {}
|
|
428
|
+
client_visibility = batch_resolve_visibility(conn, "client", list(client_ids)) if client_ids else {}
|
|
429
|
+
|
|
430
|
+
results = {}
|
|
431
|
+
for email_id in item_ids:
|
|
432
|
+
if email_id not in emails:
|
|
433
|
+
results[email_id] = (BASELINE_VISIBILITY, "not_found")
|
|
434
|
+
continue
|
|
435
|
+
|
|
436
|
+
row = emails[email_id]
|
|
437
|
+
policies: List[Tuple[Optional[VisibilityState], str]] = []
|
|
438
|
+
|
|
439
|
+
# 1. Item-level policy
|
|
440
|
+
item_scope = f"email:{email_id}"
|
|
441
|
+
if item_scope in all_policies:
|
|
442
|
+
policies.append((_resolve(all_policies[item_scope]), item_scope))
|
|
443
|
+
|
|
444
|
+
# 2. Project-level via full resolution (skip baseline)
|
|
445
|
+
project_id = row["project_id"]
|
|
446
|
+
if project_id and project_id in project_visibility:
|
|
447
|
+
state, src = project_visibility[project_id]
|
|
448
|
+
if src != "baseline":
|
|
449
|
+
policies.append((state, f"project:{project_id} (via {src})"))
|
|
450
|
+
|
|
451
|
+
# 3. Client-level via full resolution (skip baseline)
|
|
452
|
+
client_id = row["client_id"]
|
|
453
|
+
if client_id and client_id in client_visibility:
|
|
454
|
+
state, src = client_visibility[client_id]
|
|
455
|
+
if src != "baseline":
|
|
456
|
+
policies.append((state, f"client:{client_id} (via {src})"))
|
|
457
|
+
|
|
458
|
+
# 4. Account-level policy
|
|
459
|
+
account = row["account"] or ""
|
|
460
|
+
if account:
|
|
461
|
+
acct_scope = f"account:{account}"
|
|
462
|
+
if acct_scope in all_policies:
|
|
463
|
+
policies.append((_resolve(all_policies[acct_scope]), acct_scope))
|
|
464
|
+
|
|
465
|
+
# 5. Source policy
|
|
466
|
+
source_scope = "source:emails"
|
|
467
|
+
if source_scope in all_policies:
|
|
468
|
+
policies.append((_resolve(all_policies[source_scope]), source_scope))
|
|
469
|
+
|
|
470
|
+
# Resolve: most restrictive wins
|
|
471
|
+
for value, source in policies:
|
|
472
|
+
if value == "hidden":
|
|
473
|
+
results[email_id] = ("hidden", source)
|
|
474
|
+
break
|
|
475
|
+
else:
|
|
476
|
+
for value, source in policies:
|
|
477
|
+
if value == "opaque":
|
|
478
|
+
results[email_id] = ("opaque", source)
|
|
479
|
+
break
|
|
480
|
+
else:
|
|
481
|
+
for value, source in policies:
|
|
482
|
+
if value == "visible":
|
|
483
|
+
results[email_id] = ("visible", source)
|
|
484
|
+
break
|
|
485
|
+
else:
|
|
486
|
+
results[email_id] = global_baseline
|
|
487
|
+
|
|
488
|
+
return results
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _batch_resolve_chat_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
|
|
492
|
+
"""Batch resolve visibility for chats."""
|
|
493
|
+
conn = cursor.connection
|
|
494
|
+
|
|
495
|
+
cursor.execute("SELECT scope, setting FROM visibility_policies")
|
|
496
|
+
all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
|
|
497
|
+
|
|
498
|
+
# Global policy fallback
|
|
499
|
+
if "global" in all_policies:
|
|
500
|
+
global_baseline = (_resolve(all_policies["global"]), "global")
|
|
501
|
+
else:
|
|
502
|
+
global_baseline = (BASELINE_VISIBILITY, "baseline")
|
|
503
|
+
|
|
504
|
+
rows = _chunked_query(
|
|
505
|
+
cursor,
|
|
506
|
+
"""
|
|
507
|
+
SELECT chat.id, chat.account, chat.project_id, project.client_id
|
|
508
|
+
FROM chats chat
|
|
509
|
+
LEFT JOIN projects project ON chat.project_id = project.id
|
|
510
|
+
WHERE chat.id IN ({placeholders})
|
|
511
|
+
""",
|
|
512
|
+
item_ids,
|
|
513
|
+
)
|
|
514
|
+
convs = {row["id"]: row for row in rows}
|
|
515
|
+
|
|
516
|
+
# Collect unique parent entity IDs for batch resolution
|
|
517
|
+
project_ids = set()
|
|
518
|
+
client_ids = set()
|
|
519
|
+
for row in convs.values():
|
|
520
|
+
if row["project_id"]:
|
|
521
|
+
project_ids.add(row["project_id"])
|
|
522
|
+
if row["client_id"]:
|
|
523
|
+
client_ids.add(row["client_id"])
|
|
524
|
+
|
|
525
|
+
# Batch resolve parent entities
|
|
526
|
+
project_visibility = batch_resolve_visibility(conn, "project", list(project_ids)) if project_ids else {}
|
|
527
|
+
client_visibility = batch_resolve_visibility(conn, "client", list(client_ids)) if client_ids else {}
|
|
528
|
+
|
|
529
|
+
results = {}
|
|
530
|
+
for chat_id in item_ids:
|
|
531
|
+
if chat_id not in convs:
|
|
532
|
+
results[chat_id] = (BASELINE_VISIBILITY, "not_found")
|
|
533
|
+
continue
|
|
534
|
+
|
|
535
|
+
row = convs[chat_id]
|
|
536
|
+
policies: List[Tuple[Optional[VisibilityState], str]] = []
|
|
537
|
+
|
|
538
|
+
# 1. Item-level policy
|
|
539
|
+
item_scope = f"chat:{chat_id}"
|
|
540
|
+
if item_scope in all_policies:
|
|
541
|
+
policies.append((_resolve(all_policies[item_scope]), item_scope))
|
|
542
|
+
|
|
543
|
+
# 2. Project-level via full resolution (skip baseline)
|
|
544
|
+
project_id = row["project_id"]
|
|
545
|
+
if project_id and project_id in project_visibility:
|
|
546
|
+
state, src = project_visibility[project_id]
|
|
547
|
+
if src != "baseline":
|
|
548
|
+
policies.append((state, f"project:{project_id} (via {src})"))
|
|
549
|
+
|
|
550
|
+
# 3. Client-level via full resolution (skip baseline)
|
|
551
|
+
client_id = row["client_id"]
|
|
552
|
+
if client_id and client_id in client_visibility:
|
|
553
|
+
state, src = client_visibility[client_id]
|
|
554
|
+
if src != "baseline":
|
|
555
|
+
policies.append((state, f"client:{client_id} (via {src})"))
|
|
556
|
+
|
|
557
|
+
# 4. Account-level policy
|
|
558
|
+
account = row["account"] or ""
|
|
559
|
+
if account:
|
|
560
|
+
acct_scope = f"account:{account}"
|
|
561
|
+
if acct_scope in all_policies:
|
|
562
|
+
policies.append((_resolve(all_policies[acct_scope]), acct_scope))
|
|
563
|
+
|
|
564
|
+
# 5. Source policy
|
|
565
|
+
source_scope = "source:chats"
|
|
566
|
+
if source_scope in all_policies:
|
|
567
|
+
policies.append((_resolve(all_policies[source_scope]), source_scope))
|
|
568
|
+
|
|
569
|
+
# Resolve: most restrictive wins
|
|
570
|
+
for value, source in policies:
|
|
571
|
+
if value == "hidden":
|
|
572
|
+
results[chat_id] = ("hidden", source)
|
|
573
|
+
break
|
|
574
|
+
else:
|
|
575
|
+
for value, source in policies:
|
|
576
|
+
if value == "opaque":
|
|
577
|
+
results[chat_id] = ("opaque", source)
|
|
578
|
+
break
|
|
579
|
+
else:
|
|
580
|
+
for value, source in policies:
|
|
581
|
+
if value == "visible":
|
|
582
|
+
results[chat_id] = ("visible", source)
|
|
583
|
+
break
|
|
584
|
+
else:
|
|
585
|
+
results[chat_id] = global_baseline
|
|
586
|
+
|
|
587
|
+
return results
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _batch_resolve_folder_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
|
|
591
|
+
"""Batch resolve visibility for folders."""
|
|
592
|
+
conn = cursor.connection
|
|
593
|
+
|
|
594
|
+
cursor.execute("SELECT scope, setting FROM visibility_policies")
|
|
595
|
+
all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
|
|
596
|
+
|
|
597
|
+
# Global policy fallback
|
|
598
|
+
if "global" in all_policies:
|
|
599
|
+
global_baseline = (_resolve(all_policies["global"]), "global")
|
|
600
|
+
else:
|
|
601
|
+
global_baseline = (BASELINE_VISIBILITY, "baseline")
|
|
602
|
+
|
|
603
|
+
folder_policies = [
|
|
604
|
+
(scope, setting)
|
|
605
|
+
for scope, setting in all_policies.items()
|
|
606
|
+
if scope.startswith("folder:") and is_folder_path_scope(scope)
|
|
607
|
+
]
|
|
608
|
+
folder_policies.sort(key=lambda x: len(x[0]), reverse=True)
|
|
609
|
+
|
|
610
|
+
rows = _chunked_query(
|
|
611
|
+
cursor,
|
|
612
|
+
"""
|
|
613
|
+
SELECT folder.id, folder.path, folder.project_id, project.client_id
|
|
614
|
+
FROM folders folder
|
|
615
|
+
LEFT JOIN projects project ON folder.project_id = project.id
|
|
616
|
+
WHERE folder.id IN ({placeholders})
|
|
617
|
+
""",
|
|
618
|
+
item_ids,
|
|
619
|
+
)
|
|
620
|
+
folders = {row["id"]: row for row in rows}
|
|
621
|
+
|
|
622
|
+
# Collect unique parent entity IDs for batch resolution
|
|
623
|
+
project_ids = set()
|
|
624
|
+
client_ids = set()
|
|
625
|
+
for row in folders.values():
|
|
626
|
+
if row["project_id"]:
|
|
627
|
+
project_ids.add(row["project_id"])
|
|
628
|
+
if row["client_id"]:
|
|
629
|
+
client_ids.add(row["client_id"])
|
|
630
|
+
|
|
631
|
+
# Batch resolve parent entities
|
|
632
|
+
project_visibility = batch_resolve_visibility(conn, "project", list(project_ids)) if project_ids else {}
|
|
633
|
+
client_visibility = batch_resolve_visibility(conn, "client", list(client_ids)) if client_ids else {}
|
|
634
|
+
|
|
635
|
+
results = {}
|
|
636
|
+
for folder_id in item_ids:
|
|
637
|
+
if folder_id not in folders:
|
|
638
|
+
results[folder_id] = (BASELINE_VISIBILITY, "not_found")
|
|
639
|
+
continue
|
|
640
|
+
|
|
641
|
+
row = folders[folder_id]
|
|
642
|
+
policies: List[Tuple[Optional[VisibilityState], str]] = []
|
|
643
|
+
|
|
644
|
+
# 1. Item-level policy
|
|
645
|
+
item_scope = f"folder:{folder_id}"
|
|
646
|
+
if item_scope in all_policies:
|
|
647
|
+
policies.append((_resolve(all_policies[item_scope]), item_scope))
|
|
648
|
+
|
|
649
|
+
# 2. Folder prefix match
|
|
650
|
+
path = row["path"] or ""
|
|
651
|
+
if path:
|
|
652
|
+
for scope, setting in folder_policies:
|
|
653
|
+
prefix = scope[len("folder:") :]
|
|
654
|
+
if prefix.startswith("~"):
|
|
655
|
+
prefix = os.path.expanduser(prefix)
|
|
656
|
+
if path.startswith(prefix):
|
|
657
|
+
policies.append((_resolve(setting), scope))
|
|
658
|
+
break
|
|
659
|
+
|
|
660
|
+
# 3. Project-level via full resolution (skip baseline)
|
|
661
|
+
project_id = row["project_id"]
|
|
662
|
+
if project_id and project_id in project_visibility:
|
|
663
|
+
state, src = project_visibility[project_id]
|
|
664
|
+
if src != "baseline":
|
|
665
|
+
policies.append((state, f"project:{project_id} (via {src})"))
|
|
666
|
+
|
|
667
|
+
# 4. Client-level via full resolution (skip baseline)
|
|
668
|
+
client_id = row["client_id"]
|
|
669
|
+
if client_id and client_id in client_visibility:
|
|
670
|
+
state, src = client_visibility[client_id]
|
|
671
|
+
if src != "baseline":
|
|
672
|
+
policies.append((state, f"client:{client_id} (via {src})"))
|
|
673
|
+
|
|
674
|
+
# 5. Source policy
|
|
675
|
+
source_scope = "source:folders"
|
|
676
|
+
if source_scope in all_policies:
|
|
677
|
+
policies.append((_resolve(all_policies[source_scope]), source_scope))
|
|
678
|
+
|
|
679
|
+
# Resolve: most restrictive wins
|
|
680
|
+
for value, source in policies:
|
|
681
|
+
if value == "hidden":
|
|
682
|
+
results[folder_id] = ("hidden", source)
|
|
683
|
+
break
|
|
684
|
+
else:
|
|
685
|
+
for value, source in policies:
|
|
686
|
+
if value == "opaque":
|
|
687
|
+
results[folder_id] = ("opaque", source)
|
|
688
|
+
break
|
|
689
|
+
else:
|
|
690
|
+
for value, source in policies:
|
|
691
|
+
if value == "visible":
|
|
692
|
+
results[folder_id] = ("visible", source)
|
|
693
|
+
break
|
|
694
|
+
else:
|
|
695
|
+
results[folder_id] = global_baseline
|
|
696
|
+
|
|
697
|
+
return results
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _resolve(value: Optional[str]) -> Optional[VisibilityState]:
|
|
701
|
+
"""Convert a visibility value to state or None (no policy)."""
|
|
702
|
+
if value in ("hidden", "opaque", "visible"):
|
|
703
|
+
return value
|
|
704
|
+
return None # 'inherit' or NULL means no policy
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _get_policy(cursor, scope: str) -> Optional[VisibilityState]:
|
|
708
|
+
"""Look up a visibility_policies row."""
|
|
709
|
+
cursor.execute("SELECT setting FROM visibility_policies WHERE scope = ?", (scope,))
|
|
710
|
+
row = cursor.fetchone()
|
|
711
|
+
if row:
|
|
712
|
+
return _resolve(row["setting"])
|
|
713
|
+
return None
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _get_global_baseline(cursor) -> Tuple[VisibilityState, str]:
|
|
717
|
+
"""Get global policy or fall back to hardcoded baseline."""
|
|
718
|
+
row = cursor.execute("SELECT setting FROM visibility_policies WHERE scope = 'global'").fetchone()
|
|
719
|
+
if row:
|
|
720
|
+
return (_resolve(row["setting"]), "global")
|
|
721
|
+
return (BASELINE_VISIBILITY, "baseline")
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _resolve_parent_with_source(
|
|
725
|
+
conn: sqlite3.Connection, item_type: str, item_id: int
|
|
726
|
+
) -> Optional[Tuple[VisibilityState, str]]:
|
|
727
|
+
"""Resolve parent entity visibility, returning None if baseline.
|
|
728
|
+
|
|
729
|
+
This is used when resolving file/folder visibility to check parent
|
|
730
|
+
entities (folder, project, client). If the parent resolves to baseline,
|
|
731
|
+
we return None so that baseline doesn't propagate down the hierarchy.
|
|
732
|
+
"""
|
|
733
|
+
state, source = resolve_visibility_with_source(conn, item_type, item_id)
|
|
734
|
+
if source == "baseline":
|
|
735
|
+
return None
|
|
736
|
+
return (state, source)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _get_file_visibility(cursor, file_id: int) -> VisibilityState:
|
|
740
|
+
"""Resolve file visibility using policies."""
|
|
741
|
+
resolved, _ = _resolve_file_visibility_with_source(cursor, file_id)
|
|
742
|
+
return resolved
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _resolve_file_visibility_with_source(cursor, file_id: int) -> Tuple[VisibilityState, str]:
|
|
746
|
+
"""Resolve file visibility with source tracking."""
|
|
747
|
+
cursor.execute(
|
|
748
|
+
"""
|
|
749
|
+
SELECT file.path, file.project_id, file.folder_id, project.client_id
|
|
750
|
+
FROM files file
|
|
751
|
+
LEFT JOIN projects project ON file.project_id = project.id
|
|
752
|
+
WHERE file.id = ?
|
|
753
|
+
""",
|
|
754
|
+
(file_id,),
|
|
755
|
+
)
|
|
756
|
+
row = cursor.fetchone()
|
|
757
|
+
if not row:
|
|
758
|
+
return (BASELINE_VISIBILITY, "not_found")
|
|
759
|
+
|
|
760
|
+
# Collect matching policies only (not baseline)
|
|
761
|
+
policies: List[Tuple[Optional[VisibilityState], str]] = []
|
|
762
|
+
|
|
763
|
+
# 1. Item-level policy (file:{id})
|
|
764
|
+
item_policy = _get_policy(cursor, f"file:{file_id}")
|
|
765
|
+
if item_policy is not None:
|
|
766
|
+
policies.append((item_policy, f"file:{file_id}"))
|
|
767
|
+
|
|
768
|
+
# 2. Folder prefix match (most specific first)
|
|
769
|
+
path = row["path"] or ""
|
|
770
|
+
if path:
|
|
771
|
+
cursor.execute(
|
|
772
|
+
"""
|
|
773
|
+
SELECT scope, setting FROM visibility_policies
|
|
774
|
+
WHERE scope LIKE 'folder:%'
|
|
775
|
+
ORDER BY LENGTH(scope) DESC
|
|
776
|
+
"""
|
|
777
|
+
)
|
|
778
|
+
for folder_row in cursor.fetchall():
|
|
779
|
+
if not is_folder_path_scope(folder_row["scope"]):
|
|
780
|
+
continue
|
|
781
|
+
prefix = folder_row["scope"][len("folder:") :]
|
|
782
|
+
if prefix.startswith("~"):
|
|
783
|
+
prefix = os.path.expanduser(prefix)
|
|
784
|
+
if path.startswith(prefix):
|
|
785
|
+
policies.append((_resolve(folder_row["setting"]), folder_row["scope"]))
|
|
786
|
+
break # Only use most specific folder match
|
|
787
|
+
|
|
788
|
+
# 3. Folder-level via full resolution (folder:{id}) - skip baseline
|
|
789
|
+
folder_id = row["folder_id"]
|
|
790
|
+
if folder_id:
|
|
791
|
+
conn = cursor.connection
|
|
792
|
+
result = _resolve_parent_with_source(conn, "folder", folder_id)
|
|
793
|
+
if result:
|
|
794
|
+
state, src = result
|
|
795
|
+
policies.append((state, f"folder:{folder_id} (via {src})"))
|
|
796
|
+
|
|
797
|
+
# 4. Project-level via full resolution (project:{id}) - skip baseline
|
|
798
|
+
project_id = row["project_id"]
|
|
799
|
+
if project_id:
|
|
800
|
+
conn = cursor.connection
|
|
801
|
+
result = _resolve_parent_with_source(conn, "project", project_id)
|
|
802
|
+
if result:
|
|
803
|
+
state, src = result
|
|
804
|
+
policies.append((state, f"project:{project_id} (via {src})"))
|
|
805
|
+
|
|
806
|
+
# 5. Client-level via full resolution (client:{id}) - skip baseline
|
|
807
|
+
client_id = row["client_id"]
|
|
808
|
+
if client_id:
|
|
809
|
+
conn = cursor.connection
|
|
810
|
+
result = _resolve_parent_with_source(conn, "client", client_id)
|
|
811
|
+
if result:
|
|
812
|
+
state, src = result
|
|
813
|
+
policies.append((state, f"client:{client_id} (via {src})"))
|
|
814
|
+
|
|
815
|
+
# 6. Source policy
|
|
816
|
+
source_policy = _get_policy(cursor, "source:files")
|
|
817
|
+
if source_policy is not None:
|
|
818
|
+
policies.append((source_policy, "source:files"))
|
|
819
|
+
|
|
820
|
+
# MOST-RESTRICTIVE-WINS among matching policies only:
|
|
821
|
+
# hidden > opaque > visible
|
|
822
|
+
for value, source in policies:
|
|
823
|
+
if value == "hidden":
|
|
824
|
+
return ("hidden", source)
|
|
825
|
+
|
|
826
|
+
for value, source in policies:
|
|
827
|
+
if value == "opaque":
|
|
828
|
+
return ("opaque", source)
|
|
829
|
+
|
|
830
|
+
for value, source in policies:
|
|
831
|
+
if value == "visible":
|
|
832
|
+
return ("visible", source)
|
|
833
|
+
|
|
834
|
+
# No policies matched → use global policy or baseline
|
|
835
|
+
return _get_global_baseline(cursor)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _get_email_visibility(cursor, email_id: int) -> VisibilityState:
|
|
839
|
+
"""Resolve visibility for an email using policies."""
|
|
840
|
+
resolved, _ = _resolve_email_visibility_with_source(cursor, email_id)
|
|
841
|
+
return resolved
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _resolve_email_visibility_with_source(cursor, email_id: int) -> Tuple[VisibilityState, str]:
|
|
845
|
+
"""Resolve email visibility with source tracking.
|
|
846
|
+
|
|
847
|
+
Chain: email:{id} → project:{id} → client:{id} → account:{acct} → source:emails
|
|
848
|
+
"""
|
|
849
|
+
cursor.execute(
|
|
850
|
+
"""
|
|
851
|
+
SELECT email.account, email.project_id, project.client_id
|
|
852
|
+
FROM emails email
|
|
853
|
+
LEFT JOIN projects project ON email.project_id = project.id
|
|
854
|
+
WHERE email.id = ?
|
|
855
|
+
""",
|
|
856
|
+
(email_id,),
|
|
857
|
+
)
|
|
858
|
+
row = cursor.fetchone()
|
|
859
|
+
if not row:
|
|
860
|
+
return (BASELINE_VISIBILITY, "not_found")
|
|
861
|
+
|
|
862
|
+
# Collect matching policies only (not baseline)
|
|
863
|
+
policies: List[Tuple[Optional[VisibilityState], str]] = []
|
|
864
|
+
|
|
865
|
+
# 1. Item-level policy (email:{id})
|
|
866
|
+
item_policy = _get_policy(cursor, f"email:{email_id}")
|
|
867
|
+
if item_policy is not None:
|
|
868
|
+
policies.append((item_policy, f"email:{email_id}"))
|
|
869
|
+
|
|
870
|
+
# 2. Project-level via full resolution (project:{id}) - skip baseline
|
|
871
|
+
project_id = row["project_id"]
|
|
872
|
+
if project_id:
|
|
873
|
+
conn = cursor.connection
|
|
874
|
+
result = _resolve_parent_with_source(conn, "project", project_id)
|
|
875
|
+
if result:
|
|
876
|
+
state, src = result
|
|
877
|
+
policies.append((state, f"project:{project_id} (via {src})"))
|
|
878
|
+
|
|
879
|
+
# 3. Client-level via full resolution (client:{id}) - skip baseline
|
|
880
|
+
client_id = row["client_id"]
|
|
881
|
+
if client_id:
|
|
882
|
+
conn = cursor.connection
|
|
883
|
+
result = _resolve_parent_with_source(conn, "client", client_id)
|
|
884
|
+
if result:
|
|
885
|
+
state, src = result
|
|
886
|
+
policies.append((state, f"client:{client_id} (via {src})"))
|
|
887
|
+
|
|
888
|
+
# 4. Account-level policy
|
|
889
|
+
account = row["account"] or ""
|
|
890
|
+
if account:
|
|
891
|
+
account_policy = _get_policy(cursor, f"account:{account}")
|
|
892
|
+
if account_policy is not None:
|
|
893
|
+
policies.append((account_policy, f"account:{account}"))
|
|
894
|
+
|
|
895
|
+
# 5. Source policy
|
|
896
|
+
source_policy = _get_policy(cursor, "source:emails")
|
|
897
|
+
if source_policy is not None:
|
|
898
|
+
policies.append((source_policy, "source:emails"))
|
|
899
|
+
|
|
900
|
+
# MOST-RESTRICTIVE-WINS among matching policies only:
|
|
901
|
+
# hidden > opaque > visible
|
|
902
|
+
for value, source in policies:
|
|
903
|
+
if value == "hidden":
|
|
904
|
+
return ("hidden", source)
|
|
905
|
+
|
|
906
|
+
for value, source in policies:
|
|
907
|
+
if value == "opaque":
|
|
908
|
+
return ("opaque", source)
|
|
909
|
+
|
|
910
|
+
for value, source in policies:
|
|
911
|
+
if value == "visible":
|
|
912
|
+
return ("visible", source)
|
|
913
|
+
|
|
914
|
+
# No policies matched → use global policy or baseline
|
|
915
|
+
return _get_global_baseline(cursor)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _get_chat_visibility(cursor, chat_id: int) -> VisibilityState:
|
|
919
|
+
"""Resolve visibility for a chat using policies."""
|
|
920
|
+
resolved, _ = _resolve_chat_visibility_with_source(cursor, chat_id)
|
|
921
|
+
return resolved
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _resolve_chat_visibility_with_source(cursor, chat_id: int) -> Tuple[VisibilityState, str]:
|
|
925
|
+
"""Resolve chat visibility with source tracking.
|
|
926
|
+
|
|
927
|
+
Chain: chat:{id} → project:{id} → client:{id} → account:{acct} → source:chats
|
|
928
|
+
"""
|
|
929
|
+
cursor.execute(
|
|
930
|
+
"""
|
|
931
|
+
SELECT chat.account, chat.project_id, project.client_id
|
|
932
|
+
FROM chats chat
|
|
933
|
+
LEFT JOIN projects project ON chat.project_id = project.id
|
|
934
|
+
WHERE chat.id = ?
|
|
935
|
+
""",
|
|
936
|
+
(chat_id,),
|
|
937
|
+
)
|
|
938
|
+
row = cursor.fetchone()
|
|
939
|
+
if not row:
|
|
940
|
+
return (BASELINE_VISIBILITY, "not_found")
|
|
941
|
+
|
|
942
|
+
# Collect matching policies only (not baseline)
|
|
943
|
+
policies: List[Tuple[Optional[VisibilityState], str]] = []
|
|
944
|
+
|
|
945
|
+
# 1. Item-level policy (chat:{id})
|
|
946
|
+
item_policy = _get_policy(cursor, f"chat:{chat_id}")
|
|
947
|
+
if item_policy is not None:
|
|
948
|
+
policies.append((item_policy, f"chat:{chat_id}"))
|
|
949
|
+
|
|
950
|
+
# 2. Project-level via full resolution (project:{id}) - skip baseline
|
|
951
|
+
project_id = row["project_id"]
|
|
952
|
+
if project_id:
|
|
953
|
+
conn = cursor.connection
|
|
954
|
+
result = _resolve_parent_with_source(conn, "project", project_id)
|
|
955
|
+
if result:
|
|
956
|
+
state, src = result
|
|
957
|
+
policies.append((state, f"project:{project_id} (via {src})"))
|
|
958
|
+
|
|
959
|
+
# 3. Client-level via full resolution (client:{id}) - skip baseline
|
|
960
|
+
client_id = row["client_id"]
|
|
961
|
+
if client_id:
|
|
962
|
+
conn = cursor.connection
|
|
963
|
+
result = _resolve_parent_with_source(conn, "client", client_id)
|
|
964
|
+
if result:
|
|
965
|
+
state, src = result
|
|
966
|
+
policies.append((state, f"client:{client_id} (via {src})"))
|
|
967
|
+
|
|
968
|
+
# 4. Account-level policy (e.g., account:claude)
|
|
969
|
+
account = row["account"] or ""
|
|
970
|
+
if account:
|
|
971
|
+
account_policy = _get_policy(cursor, f"account:{account}")
|
|
972
|
+
if account_policy is not None:
|
|
973
|
+
policies.append((account_policy, f"account:{account}"))
|
|
974
|
+
|
|
975
|
+
# 5. Source policy (source:chats)
|
|
976
|
+
source_policy = _get_policy(cursor, "source:chats")
|
|
977
|
+
if source_policy is not None:
|
|
978
|
+
policies.append((source_policy, "source:chats"))
|
|
979
|
+
|
|
980
|
+
# MOST-RESTRICTIVE-WINS among matching policies only:
|
|
981
|
+
# hidden > opaque > visible
|
|
982
|
+
for value, source in policies:
|
|
983
|
+
if value == "hidden":
|
|
984
|
+
return ("hidden", source)
|
|
985
|
+
|
|
986
|
+
for value, source in policies:
|
|
987
|
+
if value == "opaque":
|
|
988
|
+
return ("opaque", source)
|
|
989
|
+
|
|
990
|
+
for value, source in policies:
|
|
991
|
+
if value == "visible":
|
|
992
|
+
return ("visible", source)
|
|
993
|
+
|
|
994
|
+
# No policies matched → use global policy or baseline
|
|
995
|
+
return _get_global_baseline(cursor)
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _get_folder_visibility(cursor, folder_id: int) -> VisibilityState:
|
|
999
|
+
"""Resolve visibility for an indexed folder using policies."""
|
|
1000
|
+
resolved, _ = _resolve_folder_visibility_with_source(cursor, folder_id)
|
|
1001
|
+
return resolved
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _resolve_folder_visibility_with_source(cursor, folder_id: int) -> Tuple[VisibilityState, str]:
|
|
1005
|
+
"""Resolve folder visibility with source tracking."""
|
|
1006
|
+
cursor.execute(
|
|
1007
|
+
"""
|
|
1008
|
+
SELECT folder.path, folder.project_id, project.client_id
|
|
1009
|
+
FROM folders folder
|
|
1010
|
+
LEFT JOIN projects project ON folder.project_id = project.id
|
|
1011
|
+
WHERE folder.id = ?
|
|
1012
|
+
""",
|
|
1013
|
+
(folder_id,),
|
|
1014
|
+
)
|
|
1015
|
+
row = cursor.fetchone()
|
|
1016
|
+
if not row:
|
|
1017
|
+
return (BASELINE_VISIBILITY, "not_found")
|
|
1018
|
+
|
|
1019
|
+
# Collect matching policies only (not baseline)
|
|
1020
|
+
policies: List[Tuple[Optional[VisibilityState], str]] = []
|
|
1021
|
+
|
|
1022
|
+
# 1. Item-level policy (folder:{id})
|
|
1023
|
+
item_policy = _get_policy(cursor, f"folder:{folder_id}")
|
|
1024
|
+
if item_policy is not None:
|
|
1025
|
+
policies.append((item_policy, f"folder:{folder_id}"))
|
|
1026
|
+
|
|
1027
|
+
# 2. Folder prefix match (most specific first)
|
|
1028
|
+
path = row["path"] or ""
|
|
1029
|
+
if path:
|
|
1030
|
+
cursor.execute(
|
|
1031
|
+
"""
|
|
1032
|
+
SELECT scope, setting FROM visibility_policies
|
|
1033
|
+
WHERE scope LIKE 'folder:%'
|
|
1034
|
+
ORDER BY LENGTH(scope) DESC
|
|
1035
|
+
"""
|
|
1036
|
+
)
|
|
1037
|
+
for folder_row in cursor.fetchall():
|
|
1038
|
+
if not is_folder_path_scope(folder_row["scope"]):
|
|
1039
|
+
continue
|
|
1040
|
+
prefix = folder_row["scope"][len("folder:") :]
|
|
1041
|
+
if prefix.startswith("~"):
|
|
1042
|
+
prefix = os.path.expanduser(prefix)
|
|
1043
|
+
if path.startswith(prefix):
|
|
1044
|
+
policies.append((_resolve(folder_row["setting"]), folder_row["scope"]))
|
|
1045
|
+
break # Only use most specific folder match
|
|
1046
|
+
|
|
1047
|
+
# 3. Project-level via full resolution (project:{id}) - skip baseline
|
|
1048
|
+
project_id = row["project_id"]
|
|
1049
|
+
if project_id:
|
|
1050
|
+
conn = cursor.connection
|
|
1051
|
+
result = _resolve_parent_with_source(conn, "project", project_id)
|
|
1052
|
+
if result:
|
|
1053
|
+
state, src = result
|
|
1054
|
+
policies.append((state, f"project:{project_id} (via {src})"))
|
|
1055
|
+
|
|
1056
|
+
# 4. Client-level via full resolution (client:{id}) - skip baseline
|
|
1057
|
+
client_id = row["client_id"]
|
|
1058
|
+
if client_id:
|
|
1059
|
+
conn = cursor.connection
|
|
1060
|
+
result = _resolve_parent_with_source(conn, "client", client_id)
|
|
1061
|
+
if result:
|
|
1062
|
+
state, src = result
|
|
1063
|
+
policies.append((state, f"client:{client_id} (via {src})"))
|
|
1064
|
+
|
|
1065
|
+
# 5. Source policy
|
|
1066
|
+
source_policy = _get_policy(cursor, "source:folders")
|
|
1067
|
+
if source_policy is not None:
|
|
1068
|
+
policies.append((source_policy, "source:folders"))
|
|
1069
|
+
|
|
1070
|
+
# MOST-RESTRICTIVE-WINS among matching policies only:
|
|
1071
|
+
# hidden > opaque > visible
|
|
1072
|
+
for value, source in policies:
|
|
1073
|
+
if value == "hidden":
|
|
1074
|
+
return ("hidden", source)
|
|
1075
|
+
|
|
1076
|
+
for value, source in policies:
|
|
1077
|
+
if value == "opaque":
|
|
1078
|
+
return ("opaque", source)
|
|
1079
|
+
|
|
1080
|
+
for value, source in policies:
|
|
1081
|
+
if value == "visible":
|
|
1082
|
+
return ("visible", source)
|
|
1083
|
+
|
|
1084
|
+
# No policies matched → use global policy or baseline
|
|
1085
|
+
return _get_global_baseline(cursor)
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def _resolve_project_visibility_with_source(cursor, project_id: int) -> Tuple[VisibilityState, str]:
|
|
1089
|
+
"""Resolve project visibility with source tracking.
|
|
1090
|
+
|
|
1091
|
+
Chain: project:{id} -> client:{id} -> source:projects
|
|
1092
|
+
"""
|
|
1093
|
+
cursor.execute(
|
|
1094
|
+
"""
|
|
1095
|
+
SELECT client_id FROM projects WHERE id = ?
|
|
1096
|
+
""",
|
|
1097
|
+
(project_id,),
|
|
1098
|
+
)
|
|
1099
|
+
row = cursor.fetchone()
|
|
1100
|
+
if not row:
|
|
1101
|
+
return (BASELINE_VISIBILITY, "not_found")
|
|
1102
|
+
|
|
1103
|
+
# Collect matching policies only (not baseline)
|
|
1104
|
+
policies: List[Tuple[Optional[VisibilityState], str]] = []
|
|
1105
|
+
|
|
1106
|
+
# 1. Project-level policy (project:{id})
|
|
1107
|
+
project_policy = _get_policy(cursor, f"project:{project_id}")
|
|
1108
|
+
if project_policy is not None:
|
|
1109
|
+
policies.append((project_policy, f"project:{project_id}"))
|
|
1110
|
+
|
|
1111
|
+
# 2. Client-level policy via full resolution (skip baseline)
|
|
1112
|
+
client_id = row["client_id"]
|
|
1113
|
+
if client_id:
|
|
1114
|
+
conn = cursor.connection
|
|
1115
|
+
result = _resolve_parent_with_source(conn, "client", client_id)
|
|
1116
|
+
if result:
|
|
1117
|
+
state, src = result
|
|
1118
|
+
policies.append((state, f"client:{client_id} (via {src})"))
|
|
1119
|
+
|
|
1120
|
+
# 3. Source policy for projects
|
|
1121
|
+
source_policy = _get_policy(cursor, "source:projects")
|
|
1122
|
+
if source_policy is not None:
|
|
1123
|
+
policies.append((source_policy, "source:projects"))
|
|
1124
|
+
|
|
1125
|
+
# MOST-RESTRICTIVE-WINS among matching policies only:
|
|
1126
|
+
# hidden > opaque > visible
|
|
1127
|
+
for value, source in policies:
|
|
1128
|
+
if value == "hidden":
|
|
1129
|
+
return ("hidden", source)
|
|
1130
|
+
|
|
1131
|
+
for value, source in policies:
|
|
1132
|
+
if value == "opaque":
|
|
1133
|
+
return ("opaque", source)
|
|
1134
|
+
|
|
1135
|
+
for value, source in policies:
|
|
1136
|
+
if value == "visible":
|
|
1137
|
+
return ("visible", source)
|
|
1138
|
+
|
|
1139
|
+
# No policies matched → use global policy or baseline
|
|
1140
|
+
return _get_global_baseline(cursor)
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def _resolve_client_visibility_with_source(cursor, client_id: int) -> Tuple[VisibilityState, str]:
|
|
1144
|
+
"""Resolve client visibility with source tracking.
|
|
1145
|
+
|
|
1146
|
+
Chain: client:{id} -> source:clients
|
|
1147
|
+
"""
|
|
1148
|
+
cursor.execute("SELECT id FROM clients WHERE id = ?", (client_id,))
|
|
1149
|
+
row = cursor.fetchone()
|
|
1150
|
+
if not row:
|
|
1151
|
+
return (BASELINE_VISIBILITY, "not_found")
|
|
1152
|
+
|
|
1153
|
+
# Collect matching policies only (not baseline)
|
|
1154
|
+
policies: List[Tuple[Optional[VisibilityState], str]] = []
|
|
1155
|
+
|
|
1156
|
+
# 1. Client-level policy (client:{id})
|
|
1157
|
+
client_policy = _get_policy(cursor, f"client:{client_id}")
|
|
1158
|
+
if client_policy is not None:
|
|
1159
|
+
policies.append((client_policy, f"client:{client_id}"))
|
|
1160
|
+
|
|
1161
|
+
# 2. Source policy for clients
|
|
1162
|
+
source_policy = _get_policy(cursor, "source:clients")
|
|
1163
|
+
if source_policy is not None:
|
|
1164
|
+
policies.append((source_policy, "source:clients"))
|
|
1165
|
+
|
|
1166
|
+
# MOST-RESTRICTIVE-WINS among matching policies only:
|
|
1167
|
+
# hidden > opaque > visible
|
|
1168
|
+
for value, source in policies:
|
|
1169
|
+
if value == "hidden":
|
|
1170
|
+
return ("hidden", source)
|
|
1171
|
+
|
|
1172
|
+
for value, source in policies:
|
|
1173
|
+
if value == "opaque":
|
|
1174
|
+
return ("opaque", source)
|
|
1175
|
+
|
|
1176
|
+
for value, source in policies:
|
|
1177
|
+
if value == "visible":
|
|
1178
|
+
return ("visible", source)
|
|
1179
|
+
|
|
1180
|
+
# No policies matched → use global policy or baseline
|
|
1181
|
+
return _get_global_baseline(cursor)
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _get_browser_visibility(cursor, browser_id: int) -> VisibilityState:
|
|
1185
|
+
"""Resolve visibility for a browser history item using policies.
|
|
1186
|
+
|
|
1187
|
+
Browser history only has source-level policies (no item/folder/project/client hierarchy).
|
|
1188
|
+
"""
|
|
1189
|
+
resolved, _ = _resolve_browser_visibility_with_source(cursor, browser_id)
|
|
1190
|
+
return resolved
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def _resolve_browser_visibility_with_source(cursor, browser_id: int) -> Tuple[VisibilityState, str]:
|
|
1194
|
+
"""Resolve browser history visibility with source tracking.
|
|
1195
|
+
|
|
1196
|
+
Browser history has no hierarchy - only source-level policy applies.
|
|
1197
|
+
Chain: source:browser → baseline
|
|
1198
|
+
"""
|
|
1199
|
+
# Verify item exists
|
|
1200
|
+
cursor.execute("SELECT id FROM visits WHERE id = ?", (browser_id,))
|
|
1201
|
+
row = cursor.fetchone()
|
|
1202
|
+
if not row:
|
|
1203
|
+
return (BASELINE_VISIBILITY, "not_found")
|
|
1204
|
+
|
|
1205
|
+
# Only source policy applies
|
|
1206
|
+
source_policy = _get_policy(cursor, "source:browser")
|
|
1207
|
+
if source_policy is not None:
|
|
1208
|
+
return (source_policy, "source:browser")
|
|
1209
|
+
|
|
1210
|
+
# No policies matched → use global policy or baseline
|
|
1211
|
+
return _get_global_baseline(cursor)
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _batch_resolve_browser_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
|
|
1215
|
+
"""Batch resolve visibility for browser history items.
|
|
1216
|
+
|
|
1217
|
+
Since browser history only uses source-level policy, we can resolve once
|
|
1218
|
+
and apply to all items.
|
|
1219
|
+
"""
|
|
1220
|
+
cursor.execute("SELECT scope, setting FROM visibility_policies WHERE scope IN ('source:browser', 'global')")
|
|
1221
|
+
rows = {row["scope"]: row["setting"] for row in cursor.fetchall()}
|
|
1222
|
+
|
|
1223
|
+
if "source:browser" in rows:
|
|
1224
|
+
source_visibility = _resolve(rows["source:browser"])
|
|
1225
|
+
source = "source:browser"
|
|
1226
|
+
else:
|
|
1227
|
+
source_visibility = None
|
|
1228
|
+
|
|
1229
|
+
# Global policy fallback
|
|
1230
|
+
if "global" in rows:
|
|
1231
|
+
global_baseline = (_resolve(rows["global"]), "global")
|
|
1232
|
+
else:
|
|
1233
|
+
global_baseline = (BASELINE_VISIBILITY, "baseline")
|
|
1234
|
+
|
|
1235
|
+
# Verify which items exist (chunked)
|
|
1236
|
+
if item_ids:
|
|
1237
|
+
existing_rows = _chunked_query(
|
|
1238
|
+
cursor,
|
|
1239
|
+
"SELECT id FROM visits WHERE id IN ({placeholders})",
|
|
1240
|
+
item_ids,
|
|
1241
|
+
)
|
|
1242
|
+
existing_ids = {row["id"] for row in existing_rows}
|
|
1243
|
+
else:
|
|
1244
|
+
existing_ids = set()
|
|
1245
|
+
|
|
1246
|
+
results = {}
|
|
1247
|
+
for item_id in item_ids:
|
|
1248
|
+
if item_id not in existing_ids:
|
|
1249
|
+
results[item_id] = (BASELINE_VISIBILITY, "not_found")
|
|
1250
|
+
elif source_visibility is not None:
|
|
1251
|
+
results[item_id] = (source_visibility, source)
|
|
1252
|
+
else:
|
|
1253
|
+
results[item_id] = global_baseline
|
|
1254
|
+
|
|
1255
|
+
return results
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def is_readable(visibility: VisibilityState) -> bool:
|
|
1259
|
+
"""Check if an item with this visibility can be read.
|
|
1260
|
+
|
|
1261
|
+
Only visible items can have their content read.
|
|
1262
|
+
Hidden and opaque items are blocked at the visibility layer.
|
|
1263
|
+
"""
|
|
1264
|
+
return visibility == "visible"
|