footprinter-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- footprinter/__init__.py +8 -0
- footprinter/access.py +444 -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/cli/__init__.py +128 -0
- footprinter/cli/__main__.py +6 -0
- footprinter/cli/_common.py +332 -0
- footprinter/cli/_policy_helpers.py +646 -0
- footprinter/cli/_prompt.py +220 -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 +579 -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 +1836 -0
- footprinter/cli/status.py +729 -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 +610 -0
- footprinter/db/clients.py +307 -0
- footprinter/db/emails.py +279 -0
- footprinter/db/files.py +741 -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 +515 -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 +328 -0
- footprinter/ingest/db/schema.py +1043 -0
- footprinter/ingest/db/security.py +6 -0
- footprinter/ingest/file_indexer.py +261 -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 +125 -0
- footprinter/ingest/pipe_runner.py +217 -0
- footprinter/ingest/processing.py +165 -0
- footprinter/ingest/registry.py +201 -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 +57 -0
- footprinter/mcp/errors.py +102 -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 +15 -0
- footprinter/paths.py +91 -0
- footprinter/permissions.py +1160 -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 +1272 -0
- footprinter_cli-1.0.0.dist-info/LICENSE +21 -0
- footprinter_cli-1.0.0.dist-info/METADATA +229 -0
- footprinter_cli-1.0.0.dist-info/RECORD +134 -0
- footprinter_cli-1.0.0.dist-info/WHEEL +5 -0
- footprinter_cli-1.0.0.dist-info/entry_points.txt +2 -0
- footprinter_cli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""fp vectorize — manage per-record vectorization control flags.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
fp vectorize exclude <entity> <id> [<id>...] Mark records to skip vectorization
|
|
5
|
+
fp vectorize include <entity> <id> [<id>...] Restore records for vectorization
|
|
6
|
+
fp vectorize review [<entity>] Show excluded record counts
|
|
7
|
+
fp vectorize import <path> Apply flags from a JSON file
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sqlite3
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional, Union
|
|
15
|
+
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
from footprinter.cli._common import FORMATTER, console, open_db
|
|
19
|
+
|
|
20
|
+
# Entity types that support the vectorize flag
|
|
21
|
+
ENTITY_TABLES = {"files": "files", "messages": "messages", "chats": "chats"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Core flag operations
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _set_vectorize_flag(
|
|
30
|
+
conn: sqlite3.Connection,
|
|
31
|
+
table: str,
|
|
32
|
+
ids: list[int],
|
|
33
|
+
value: int,
|
|
34
|
+
) -> int:
|
|
35
|
+
"""Set metadata.vectorize on records via json_set().
|
|
36
|
+
|
|
37
|
+
Returns the number of rows updated.
|
|
38
|
+
"""
|
|
39
|
+
if not ids:
|
|
40
|
+
return 0
|
|
41
|
+
placeholders = ",".join("?" for _ in ids)
|
|
42
|
+
cursor = conn.execute(
|
|
43
|
+
f"UPDATE {table} SET metadata = json_set("
|
|
44
|
+
f"COALESCE(metadata, '{{}}'), '$.vectorize', ?) "
|
|
45
|
+
f"WHERE id IN ({placeholders}) AND status != 'removed'",
|
|
46
|
+
[value, *ids],
|
|
47
|
+
)
|
|
48
|
+
conn.commit()
|
|
49
|
+
return cursor.rowcount
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Handlers
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _handle_exclude(
|
|
58
|
+
args: argparse.Namespace,
|
|
59
|
+
*,
|
|
60
|
+
db_path: Optional[Union[str, Path]] = None,
|
|
61
|
+
output: Optional[Console] = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Set metadata.vectorize=0 for given entity/IDs."""
|
|
64
|
+
table = ENTITY_TABLES.get(args.entity)
|
|
65
|
+
if not table:
|
|
66
|
+
(output or console).print(f"[red]Unknown entity:[/red] {args.entity}. Use one of: {', '.join(ENTITY_TABLES)}")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
with open_db(db_path) as conn:
|
|
70
|
+
count = _set_vectorize_flag(conn, table, args.ids, 0)
|
|
71
|
+
(output or console).print(f"Excluded {count} {args.entity} from vectorization.")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _handle_include(
|
|
75
|
+
args: argparse.Namespace,
|
|
76
|
+
*,
|
|
77
|
+
db_path: Optional[Union[str, Path]] = None,
|
|
78
|
+
output: Optional[Console] = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Set metadata.vectorize=1 for given entity/IDs."""
|
|
81
|
+
table = ENTITY_TABLES.get(args.entity)
|
|
82
|
+
if not table:
|
|
83
|
+
(output or console).print(f"[red]Unknown entity:[/red] {args.entity}. Use one of: {', '.join(ENTITY_TABLES)}")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
with open_db(db_path) as conn:
|
|
87
|
+
count = _set_vectorize_flag(conn, table, args.ids, 1)
|
|
88
|
+
(output or console).print(f"Included {count} {args.entity} for vectorization.")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _handle_review(
|
|
92
|
+
args: argparse.Namespace,
|
|
93
|
+
*,
|
|
94
|
+
db_path: Optional[Union[str, Path]] = None,
|
|
95
|
+
output: Optional[Console] = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Show counts of excluded records per entity."""
|
|
98
|
+
out = output or console
|
|
99
|
+
entities = (
|
|
100
|
+
{args.entity: ENTITY_TABLES[args.entity]} if args.entity and args.entity in ENTITY_TABLES else ENTITY_TABLES
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
from rich.table import Table
|
|
104
|
+
|
|
105
|
+
table = Table(title="Vectorization Exclusions")
|
|
106
|
+
table.add_column("Entity", style="bold")
|
|
107
|
+
table.add_column("Excluded", justify="right")
|
|
108
|
+
table.add_column("Total", justify="right")
|
|
109
|
+
|
|
110
|
+
with open_db(db_path) as conn:
|
|
111
|
+
for entity_name, table_name in entities.items():
|
|
112
|
+
excluded = conn.execute(
|
|
113
|
+
f"SELECT COUNT(*) as n FROM {table_name} "
|
|
114
|
+
f"WHERE json_extract(metadata, '$.vectorize') = 0 "
|
|
115
|
+
f"AND status != 'removed'"
|
|
116
|
+
).fetchone()["n"]
|
|
117
|
+
total = conn.execute(f"SELECT COUNT(*) as n FROM {table_name} WHERE status != 'removed'").fetchone()["n"]
|
|
118
|
+
table.add_row(entity_name, str(excluded), str(total))
|
|
119
|
+
|
|
120
|
+
out.print(table)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
VALID_ACTIONS = frozenset({"exclude", "include"})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _handle_import(
|
|
127
|
+
args: argparse.Namespace,
|
|
128
|
+
*,
|
|
129
|
+
db_path: Optional[Union[str, Path]] = None,
|
|
130
|
+
output: Optional[Console] = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Apply vectorize flags from a JSON file."""
|
|
133
|
+
out = output or console
|
|
134
|
+
path = Path(args.path)
|
|
135
|
+
if not path.exists():
|
|
136
|
+
out.print(f"[red]File not found:[/red] {path}")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
data = json.loads(path.read_text())
|
|
140
|
+
|
|
141
|
+
# Support structured format: {"entity": ..., "action": ..., "ids": [...]}
|
|
142
|
+
# and flat list format: [1, 2, 3] (defaults to files + exclude)
|
|
143
|
+
if isinstance(data, list):
|
|
144
|
+
entity = "files"
|
|
145
|
+
action = "exclude"
|
|
146
|
+
ids = [int(i) for i in data]
|
|
147
|
+
elif isinstance(data, dict):
|
|
148
|
+
entity = data.get("entity", "files")
|
|
149
|
+
action = data.get("action", "exclude")
|
|
150
|
+
ids = [int(i) for i in data.get("ids", [])]
|
|
151
|
+
else:
|
|
152
|
+
out.print("[red]Invalid JSON format.[/red] Expected a list or object.")
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
if action not in VALID_ACTIONS:
|
|
156
|
+
out.print(f"[red]Unknown action:[/red] {action}. Use one of: {', '.join(VALID_ACTIONS)}")
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
table = ENTITY_TABLES.get(entity)
|
|
160
|
+
if not table:
|
|
161
|
+
out.print(f"[red]Unknown entity:[/red] {entity}. Use one of: {', '.join(ENTITY_TABLES)}")
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
value = 0 if action == "exclude" else 1
|
|
165
|
+
with open_db(db_path) as conn:
|
|
166
|
+
count = _set_vectorize_flag(conn, table, ids, value)
|
|
167
|
+
verb = "Excluded" if action == "exclude" else "Included"
|
|
168
|
+
out.print(f"{verb} {count} {entity} via import.")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# CLI registration
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def register(subparsers) -> None:
|
|
177
|
+
"""Register the ``fp vectorize`` subcommand."""
|
|
178
|
+
parser = subparsers.add_parser(
|
|
179
|
+
"vectorize",
|
|
180
|
+
help="Manage per-record vectorization control",
|
|
181
|
+
description="Exclude or include individual records from vectorization.",
|
|
182
|
+
formatter_class=FORMATTER,
|
|
183
|
+
)
|
|
184
|
+
sub = parser.add_subparsers(dest="vectorize_action", metavar="ACTION")
|
|
185
|
+
|
|
186
|
+
# exclude
|
|
187
|
+
exc = sub.add_parser("exclude", help="Exclude records from vectorization")
|
|
188
|
+
exc.add_argument("entity", choices=list(ENTITY_TABLES), help="Entity type")
|
|
189
|
+
exc.add_argument("ids", nargs="+", type=int, help="Record IDs to exclude")
|
|
190
|
+
exc.set_defaults(func=lambda args: _handle_exclude(args))
|
|
191
|
+
|
|
192
|
+
# include
|
|
193
|
+
inc = sub.add_parser("include", help="Include records for vectorization")
|
|
194
|
+
inc.add_argument("entity", choices=list(ENTITY_TABLES), help="Entity type")
|
|
195
|
+
inc.add_argument("ids", nargs="+", type=int, help="Record IDs to include")
|
|
196
|
+
inc.set_defaults(func=lambda args: _handle_include(args))
|
|
197
|
+
|
|
198
|
+
# review
|
|
199
|
+
rev = sub.add_parser("review", help="Show excluded record counts")
|
|
200
|
+
rev.add_argument(
|
|
201
|
+
"entity",
|
|
202
|
+
nargs="?",
|
|
203
|
+
default=None,
|
|
204
|
+
choices=list(ENTITY_TABLES),
|
|
205
|
+
help="Filter to a specific entity type",
|
|
206
|
+
)
|
|
207
|
+
rev.set_defaults(func=lambda args: _handle_review(args))
|
|
208
|
+
|
|
209
|
+
# import
|
|
210
|
+
imp = sub.add_parser("import", help="Apply flags from a JSON file")
|
|
211
|
+
imp.add_argument("path", help="Path to JSON file")
|
|
212
|
+
imp.set_defaults(func=lambda args: _handle_import(args))
|
|
213
|
+
|
|
214
|
+
# Default: show help if no action given
|
|
215
|
+
parser.set_defaults(func=lambda args: parser.print_help())
|
footprinter/cli/view.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""fp view — unified entity viewer with singular/plural noun convention.
|
|
2
|
+
|
|
3
|
+
Routes ``fp view client 42`` (single record) and ``fp view clients``
|
|
4
|
+
(paginated collection) through the service layer. Supports ``--json``,
|
|
5
|
+
``--csv``, and ``--verbose`` output flags.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from footprinter.cli._common import (
|
|
14
|
+
FORMATTER,
|
|
15
|
+
add_csv_flag,
|
|
16
|
+
add_json_flag,
|
|
17
|
+
add_verbose_flag,
|
|
18
|
+
console,
|
|
19
|
+
enrich_verbose_access,
|
|
20
|
+
format_size,
|
|
21
|
+
open_db,
|
|
22
|
+
output_csv,
|
|
23
|
+
output_json,
|
|
24
|
+
verbose_access_cells,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Entity dispatch table
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
#: Maps every recognised noun to (service_module, list_key, entity_type, mode).
|
|
32
|
+
ENTITY_MAP: dict[str, tuple[str, str, str, str]] = {
|
|
33
|
+
# singular → single record
|
|
34
|
+
"client": ("client_service", "clients", "client", "single"),
|
|
35
|
+
"project": ("project_service", "projects", "project", "single"),
|
|
36
|
+
"file": ("file_service", "files", "file", "single"),
|
|
37
|
+
"folder": ("folder_service", "folders", "folder", "single"),
|
|
38
|
+
"chat": ("chat_service", "chats", "chat", "single"),
|
|
39
|
+
"email": ("email_service", "emails", "email", "single"),
|
|
40
|
+
"visit": ("visit_service", "visits", "visit", "single"),
|
|
41
|
+
# plural → paginated collection
|
|
42
|
+
"clients": ("client_service", "clients", "client", "collection"),
|
|
43
|
+
"projects": ("project_service", "projects", "project", "collection"),
|
|
44
|
+
"files": ("file_service", "files", "file", "collection"),
|
|
45
|
+
"folders": ("folder_service", "folders", "folder", "collection"),
|
|
46
|
+
"chats": ("chat_service", "chats", "chat", "collection"),
|
|
47
|
+
"emails": ("email_service", "emails", "email", "collection"),
|
|
48
|
+
"visits": ("visit_service", "visits", "visit", "collection"),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Column specs for Rich table rendering
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
#: (header, dict_key, style, justify)
|
|
56
|
+
_Col = tuple[str, str, str | None, str | None]
|
|
57
|
+
|
|
58
|
+
ENTITY_COLUMNS: dict[str, list[_Col]] = {
|
|
59
|
+
"client": [
|
|
60
|
+
("ID", "id", "dim", "right"),
|
|
61
|
+
("Name", "name", "cyan", None),
|
|
62
|
+
("Type", "client_type", None, None),
|
|
63
|
+
("Status", "status", None, None),
|
|
64
|
+
("Projects", "project_count", None, "right"),
|
|
65
|
+
("Files", "file_count", None, "right"),
|
|
66
|
+
],
|
|
67
|
+
"project": [
|
|
68
|
+
("ID", "id", "dim", "right"),
|
|
69
|
+
("Name", "name", "cyan", None),
|
|
70
|
+
("Type", "type", None, None),
|
|
71
|
+
("Client", "client", None, None),
|
|
72
|
+
("Status", "status", None, None),
|
|
73
|
+
("Files", "file_count", None, "right"),
|
|
74
|
+
("Path", "root_path", "dim", None),
|
|
75
|
+
],
|
|
76
|
+
"file": [
|
|
77
|
+
("ID", "id", "dim", "right"),
|
|
78
|
+
("Name", "name", None, None),
|
|
79
|
+
("Source", "source", None, None),
|
|
80
|
+
("Status", "status", None, None),
|
|
81
|
+
("Project", "project_name", None, None),
|
|
82
|
+
],
|
|
83
|
+
"folder": [
|
|
84
|
+
("ID", "id", "dim", "right"),
|
|
85
|
+
("Path", "relative_path", None, None),
|
|
86
|
+
("Files", "direct_files", None, "right"),
|
|
87
|
+
("Size", "total_size_bytes", None, "right"),
|
|
88
|
+
("Project", "project_name", "cyan", None),
|
|
89
|
+
],
|
|
90
|
+
"chat": [
|
|
91
|
+
("ID", "id", "cyan", "right"),
|
|
92
|
+
("Account", "account", "magenta", None),
|
|
93
|
+
("Msgs", "message_count", None, "right"),
|
|
94
|
+
("Title", "title", None, None),
|
|
95
|
+
],
|
|
96
|
+
"email": [
|
|
97
|
+
("ID", "id", "dim", "right"),
|
|
98
|
+
("From", "from_address", None, None),
|
|
99
|
+
("Subject", "subject", None, None),
|
|
100
|
+
("Account", "account", None, None),
|
|
101
|
+
("Date", "received_at", None, None),
|
|
102
|
+
],
|
|
103
|
+
"visit": [
|
|
104
|
+
("ID", "id", "dim", "right"),
|
|
105
|
+
("Title", "title", None, None),
|
|
106
|
+
("URL", "url", None, None),
|
|
107
|
+
("Browser", "browser", None, None),
|
|
108
|
+
("Time", "visit_time", None, None),
|
|
109
|
+
],
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Service resolution
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _get_service(service_name: str):
|
|
118
|
+
"""Lazy-import and return a service module from footprinter.services."""
|
|
119
|
+
import footprinter.services as svc
|
|
120
|
+
|
|
121
|
+
return getattr(svc, service_name)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Handlers
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _handle_single(args) -> None:
|
|
130
|
+
"""Handle singular noun: ``fp view client 42``."""
|
|
131
|
+
from footprinter.services.roles import Role
|
|
132
|
+
|
|
133
|
+
noun = args.noun
|
|
134
|
+
svc_name, _list_key, entity_type, _mode = ENTITY_MAP[noun]
|
|
135
|
+
service = _get_service(svc_name)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
entity_id = int(args.id)
|
|
139
|
+
except ValueError:
|
|
140
|
+
console.print(f"[red]Invalid ID: {args.id!r} — expected an integer.[/red]")
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
|
|
143
|
+
with open_db() as conn:
|
|
144
|
+
record = service.get(conn, entity_id, role=Role.ADMIN)
|
|
145
|
+
|
|
146
|
+
if record is None:
|
|
147
|
+
console.print(f"[red]{entity_type.title()} {args.id} not found.[/red]")
|
|
148
|
+
sys.exit(1)
|
|
149
|
+
|
|
150
|
+
if getattr(args, "json", False):
|
|
151
|
+
output_json(record)
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
# Rich panel — show all key-value pairs
|
|
155
|
+
lines = []
|
|
156
|
+
for key, value in record.items():
|
|
157
|
+
if key.startswith("mcp_") or isinstance(value, (list, dict)):
|
|
158
|
+
continue
|
|
159
|
+
display_val = str(value) if value is not None else "—"
|
|
160
|
+
lines.append(f"[bold]{key}:[/bold] {display_val}")
|
|
161
|
+
|
|
162
|
+
console.print(
|
|
163
|
+
Panel(
|
|
164
|
+
"\n".join(lines),
|
|
165
|
+
title=f"{entity_type.title()} #{record['id']}",
|
|
166
|
+
border_style="cyan",
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _handle_collection(args) -> None:
|
|
172
|
+
"""Handle plural noun: ``fp view clients``."""
|
|
173
|
+
from footprinter.services.roles import Role
|
|
174
|
+
|
|
175
|
+
noun = args.noun
|
|
176
|
+
svc_name, list_key, entity_type, _mode = ENTITY_MAP[noun]
|
|
177
|
+
service = _get_service(svc_name)
|
|
178
|
+
|
|
179
|
+
verbose = getattr(args, "verbose", False)
|
|
180
|
+
limit = getattr(args, "limit", 50)
|
|
181
|
+
page = getattr(args, "page", 1)
|
|
182
|
+
|
|
183
|
+
with open_db() as conn:
|
|
184
|
+
result = service.list_(conn, role=Role.ADMIN, limit=limit, page=page)
|
|
185
|
+
rows = result[list_key]
|
|
186
|
+
if (verbose or getattr(args, "json", False)) and rows:
|
|
187
|
+
enrich_verbose_access(rows, entity_type)
|
|
188
|
+
|
|
189
|
+
if getattr(args, "json", False):
|
|
190
|
+
output_json(result)
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
if getattr(args, "csv", False):
|
|
194
|
+
cols = ENTITY_COLUMNS.get(entity_type)
|
|
195
|
+
col_keys = [c[1] for c in cols] if cols else None
|
|
196
|
+
output_csv(rows, columns=col_keys)
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
if not rows:
|
|
200
|
+
console.print(f"[dim]No {list_key} found.[/dim]")
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
# Build Rich table from column specs
|
|
204
|
+
pag = result["pagination"]
|
|
205
|
+
table = Table(
|
|
206
|
+
title=f"{list_key.title()} (page {pag['page']}/{pag['total_pages']}, {pag['total']} total)",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
cols = ENTITY_COLUMNS.get(entity_type, [])
|
|
210
|
+
for header, _key, style, justify in cols:
|
|
211
|
+
kwargs: dict = {}
|
|
212
|
+
if style:
|
|
213
|
+
kwargs["style"] = style
|
|
214
|
+
if justify:
|
|
215
|
+
kwargs["justify"] = justify
|
|
216
|
+
table.add_column(header, **kwargs)
|
|
217
|
+
if verbose:
|
|
218
|
+
table.add_column("Access")
|
|
219
|
+
table.add_column("Visibility")
|
|
220
|
+
|
|
221
|
+
for row in rows:
|
|
222
|
+
cells = []
|
|
223
|
+
for _header, key, _style, _justify in cols:
|
|
224
|
+
val = row.get(key)
|
|
225
|
+
if key == "total_size_bytes" and isinstance(val, (int, float)):
|
|
226
|
+
cells.append(format_size(int(val)))
|
|
227
|
+
elif key == "file_count" and isinstance(val, int):
|
|
228
|
+
cells.append(f"{val:,}")
|
|
229
|
+
else:
|
|
230
|
+
cells.append(str(val) if val is not None else "")
|
|
231
|
+
if verbose:
|
|
232
|
+
cells.extend(verbose_access_cells(row))
|
|
233
|
+
table.add_row(*cells)
|
|
234
|
+
|
|
235
|
+
console.print(table)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# Registration
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
SINGULAR_NOUNS = ["client", "project", "file", "folder", "chat", "email", "visit"]
|
|
243
|
+
PLURAL_NOUNS = ["clients", "projects", "files", "folders", "chats", "emails", "visits"]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def register(subparsers) -> None:
|
|
247
|
+
"""Register the ``view`` subcommand with noun sub-subparsers."""
|
|
248
|
+
parser = subparsers.add_parser(
|
|
249
|
+
"view",
|
|
250
|
+
help="View entity records",
|
|
251
|
+
description=(
|
|
252
|
+
"View entity records — singular noun for one record, plural for a list.\n\n"
|
|
253
|
+
"Singular: fp view client 42 Single record by ID\n"
|
|
254
|
+
"Plural: fp view clients Paginated collection\n"
|
|
255
|
+
"Export: fp view clients --csv Bulk CSV export"
|
|
256
|
+
),
|
|
257
|
+
epilog=(
|
|
258
|
+
"examples:\n"
|
|
259
|
+
" fp view client 42 View a single client\n"
|
|
260
|
+
" fp view clients List all clients\n"
|
|
261
|
+
" fp view clients --json JSON output\n"
|
|
262
|
+
" fp view clients --csv CSV export\n"
|
|
263
|
+
" fp view files --limit 10 First 10 files\n"
|
|
264
|
+
" fp view projects --verbose Include access columns\n"
|
|
265
|
+
"\n"
|
|
266
|
+
"entity nouns:\n"
|
|
267
|
+
" singular: client, project, file, folder, chat, email, visit\n"
|
|
268
|
+
" plural: clients, projects, files, folders, chats, emails, visits\n"
|
|
269
|
+
"\n"
|
|
270
|
+
"tip: use 'fp view <noun> --help' for details on any noun."
|
|
271
|
+
),
|
|
272
|
+
formatter_class=FORMATTER,
|
|
273
|
+
)
|
|
274
|
+
noun_subs = parser.add_subparsers(
|
|
275
|
+
dest="noun",
|
|
276
|
+
metavar="NOUN",
|
|
277
|
+
title="entity nouns (one required)",
|
|
278
|
+
)
|
|
279
|
+
parser.set_defaults(func=lambda args: parser.print_help())
|
|
280
|
+
|
|
281
|
+
# Singular nouns — require an ID positional arg
|
|
282
|
+
for noun in SINGULAR_NOUNS:
|
|
283
|
+
entity_type = ENTITY_MAP[noun][2]
|
|
284
|
+
p = noun_subs.add_parser(
|
|
285
|
+
noun,
|
|
286
|
+
help=f"View a single {entity_type}",
|
|
287
|
+
description=f"Show details for a single {entity_type} record by ID.",
|
|
288
|
+
formatter_class=FORMATTER,
|
|
289
|
+
)
|
|
290
|
+
p.add_argument("id", help=f"{entity_type.title()} ID")
|
|
291
|
+
add_json_flag(p)
|
|
292
|
+
p.set_defaults(func=_handle_single)
|
|
293
|
+
|
|
294
|
+
# Plural nouns — pagination + format flags
|
|
295
|
+
for noun in PLURAL_NOUNS:
|
|
296
|
+
entity_type = ENTITY_MAP[noun][2]
|
|
297
|
+
p = noun_subs.add_parser(
|
|
298
|
+
noun,
|
|
299
|
+
help=f"List {noun}",
|
|
300
|
+
description=f"List {noun} with pagination.",
|
|
301
|
+
formatter_class=FORMATTER,
|
|
302
|
+
)
|
|
303
|
+
p.add_argument(
|
|
304
|
+
"--limit",
|
|
305
|
+
type=int,
|
|
306
|
+
default=50,
|
|
307
|
+
help="Max rows to return (default: 50)",
|
|
308
|
+
)
|
|
309
|
+
p.add_argument(
|
|
310
|
+
"--page",
|
|
311
|
+
type=int,
|
|
312
|
+
default=1,
|
|
313
|
+
help="Page number (default: 1)",
|
|
314
|
+
)
|
|
315
|
+
add_verbose_flag(p)
|
|
316
|
+
|
|
317
|
+
# --json and --csv are mutually exclusive
|
|
318
|
+
fmt_group = p.add_mutually_exclusive_group()
|
|
319
|
+
add_json_flag(fmt_group)
|
|
320
|
+
add_csv_flag(fmt_group)
|
|
321
|
+
|
|
322
|
+
p.set_defaults(func=_handle_collection)
|