Files
safishamsi db410d0d09 fix(detect): stop silent data loss from env-dir pruning and absolute-path sidecars (#2058, #2059)
#2058: `_is_noise_dir` treated any directory named `env`/`.env`/`*_env` as a
Python virtualenv and pruned it during the walk — before `.graphifyignore`
negation, with zero trace in any returned bucket. Real source dirs with those
names (common in UVM/ASIC verification trees) were silently lost. The venv
heuristic for those names is now gated on actual markers (`pyvenv.cfg`,
`bin`/`Scripts/activate`, `lib/python*`, `conda-meta/`); `venv`/`.venv`/`*_venv`
stay name-only. Pruned-as-noise dirs are recorded in a new `pruned_noise_dirs`
bucket for traceability, and extract.py's walk call sites pass the parent so
genuine venvs are still marker-checked and pruned.

#2059: Office and Google-Workspace sidecars were named with a hash of the
resolved ABSOLUTE source path, so the same tracked file in two clones/worktrees
produced two differently-named byte-identical sidecars — unbounded duplicates
when graphify-out/ is committed, each ingested as a distinct source doc. The
hash is now over the scan-root-relative (NFC-normalized) path, stable across
checkouts while still disambiguating same-stem files; out-of-root sources fall
back to the old absolute form. Also fixes the same bug in google_workspace's
`_sidecar_path` (which additionally never had the #1226 NFC fix).
2026-07-20 15:28:59 +01:00

238 lines
8.7 KiB
Python

"""Optional Google Workspace shortcut export support.
Google Drive for desktop stores native Docs, Sheets, and Slides as small JSON
shortcut files (.gdoc, .gsheet, .gslides). Those files are pointers, not the
document content. This module exports them to Markdown sidecars via the
googleworkspace CLI (`gws`) so Graphify can extract their actual contents.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import subprocess
import tempfile
import urllib.parse
from pathlib import Path
from typing import Callable, Any
GOOGLE_WORKSPACE_EXTENSIONS = {".gdoc", ".gsheet", ".gslides"}
def google_workspace_enabled(value: str | None = None) -> bool:
"""Return True when Google Workspace shortcut export is enabled."""
raw = value if value is not None else os.environ.get("GRAPHIFY_GOOGLE_WORKSPACE", "")
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _safe_yaml_str(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ").replace("\r", " ")
def _extract_file_id_from_url(url: str) -> str | None:
"""Extract a Drive file ID from common Google Docs/Drive URL shapes."""
if not url:
return None
parsed = urllib.parse.urlparse(url)
query = urllib.parse.parse_qs(parsed.query)
if query.get("id"):
return query["id"][0]
match = re.search(r"/(?:document|spreadsheets|presentation|file)/d/([^/?#]+)", parsed.path)
if match:
return match.group(1)
return None
def _extract_resource_key(url: str, data: dict[str, Any]) -> str | None:
for key in ("resource_key", "resourceKey"):
value = data.get(key)
if value:
return str(value)
if not url:
return None
parsed = urllib.parse.urlparse(url)
query = urllib.parse.parse_qs(parsed.query)
if query.get("resourcekey"):
return query["resourcekey"][0]
return None
def read_google_shortcut(path: Path) -> dict[str, str | None]:
"""Read a .gdoc/.gsheet/.gslides shortcut and return export metadata."""
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
raise RuntimeError(f"could not read Google Workspace shortcut {path}: {exc}") from exc
url = str(data.get("url") or "")
file_id = (
data.get("doc_id")
or data.get("file_id")
or data.get("fileId")
or data.get("id")
or _extract_file_id_from_url(url)
)
if not file_id:
resource_id = str(data.get("resource_id") or "")
if ":" in resource_id:
file_id = resource_id.split(":", 1)[1]
if not file_id:
raise RuntimeError(f"Google Workspace shortcut {path} does not include a Drive file ID")
return {
"file_id": str(file_id),
"url": url or None,
"resource_key": _extract_resource_key(url, data),
"account": str(data.get("email")) if data.get("email") else None,
}
def _run_gws_export(file_id: str, mime_type: str, output: Path, resource_key: str | None = None) -> None:
exe = shutil.which("gws")
if not exe:
raise RuntimeError(
"gws is required for Google Workspace export. Install it from "
"https://github.com/googleworkspace/cli and run `gws auth login -s drive`."
)
params: dict[str, str] = {"fileId": file_id, "mimeType": mime_type}
# Drive resource keys are sent via X-Goog-Drive-Resource-Keys. The current
# gws export command has no custom-header flag, so do not pass resourceKey
# as an unsupported query parameter.
_ = resource_key
output = output.resolve()
output.parent.mkdir(parents=True, exist_ok=True)
timeout = int(os.environ.get("GRAPHIFY_GOOGLE_WORKSPACE_TIMEOUT", "120"))
result = subprocess.run(
[exe, "drive", "files", "export", "--params", json.dumps(params), "-o", output.name],
capture_output=True,
cwd=output.parent,
text=True,
timeout=timeout,
)
if result.returncode != 0:
stderr = (result.stderr or result.stdout or "").strip()
if len(stderr) > 1200:
stderr = stderr[:1200] + "..."
raise RuntimeError(f"gws export failed for {file_id}: {stderr}")
def _sidecar_path(path: Path, out_dir: Path, root: "Path | None" = None) -> Path:
# Hash the scan-root-relative, NFC-normalized path — not the absolute path.
# The absolute form salts the sidecar name with the checkout location, so the
# same shortcut in two clones/worktrees emits differently-named byte-identical
# sidecars, each ingested as a distinct source doc when graphify-out/ is
# committed (#2059; mirrors convert_office_file). NFC guards macOS NFD drift
# (#1226). The relative path still disambiguates same-stem files.
import unicodedata
if root is None:
root = out_dir.parent.parent
try:
key = path.resolve().relative_to(Path(root).resolve()).as_posix()
except (ValueError, OSError):
key = str(path.resolve())
name_hash = hashlib.sha256(unicodedata.normalize("NFC", key).encode()).hexdigest()[:8]
return out_dir / f"{path.stem}_{name_hash}.md"
def _with_frontmatter(path: Path, shortcut: dict[str, str | None], body: str, exported_mime_type: str) -> str:
source_url = shortcut.get("url") or ""
account = shortcut.get("account") or ""
account_line = ""
if account:
account_hash = hashlib.sha256(account.encode()).hexdigest()[:12]
account_line = f'google_account_hash: "{account_hash}"\n'
return (
"---\n"
f'source_file: "{_safe_yaml_str(str(path))}"\n'
'source_type: "google_workspace"\n'
f'google_file_id: "{_safe_yaml_str(shortcut["file_id"] or "")}"\n'
f'google_export_mime_type: "{_safe_yaml_str(exported_mime_type)}"\n'
f'source_url: "{_safe_yaml_str(source_url)}"\n'
f"{account_line}"
"---\n\n"
f"<!-- converted from Google Workspace shortcut: {path.name} -->\n\n"
f"{body.strip()}\n"
)
def convert_google_workspace_file(
path: Path,
out_dir: Path,
*,
xlsx_to_markdown: Callable[[Path], str] | None = None,
root: "Path | None" = None,
) -> Path | None:
"""Export a Google Workspace shortcut to a Markdown sidecar.
Returns the converted Markdown path, or None when conversion is unsupported
or produced no readable content.
"""
ext = path.suffix.lower()
if ext not in GOOGLE_WORKSPACE_EXTENSIONS:
return None
shortcut = read_google_shortcut(path)
out_dir.mkdir(parents=True, exist_ok=True)
out_path = _sidecar_path(path, out_dir, root=root)
if ext == ".gdoc":
with tempfile.NamedTemporaryFile("w+b", suffix=".md", delete=False, dir=out_dir) as tmp:
tmp_path = Path(tmp.name)
try:
_run_gws_export(shortcut["file_id"] or "", "text/markdown", tmp_path, shortcut.get("resource_key"))
body = tmp_path.read_text(encoding="utf-8", errors="replace")
finally:
tmp_path.unlink(missing_ok=True)
if not body.strip():
return None
out_path.write_text(_with_frontmatter(path, shortcut, body, "text/markdown"), encoding="utf-8")
return out_path
if ext == ".gslides":
with tempfile.NamedTemporaryFile("w+b", suffix=".txt", delete=False, dir=out_dir) as tmp:
tmp_path = Path(tmp.name)
try:
_run_gws_export(shortcut["file_id"] or "", "text/plain", tmp_path, shortcut.get("resource_key"))
body = tmp_path.read_text(encoding="utf-8", errors="replace")
finally:
tmp_path.unlink(missing_ok=True)
if not body.strip():
return None
out_path.write_text(_with_frontmatter(path, shortcut, body, "text/plain"), encoding="utf-8")
return out_path
if ext == ".gsheet":
if xlsx_to_markdown is None:
raise RuntimeError("Google Sheets export requires the office extra: pip install graphifyy[office,google]")
with tempfile.NamedTemporaryFile("w+b", suffix=".xlsx", delete=False, dir=out_dir) as tmp:
tmp_path = Path(tmp.name)
try:
_run_gws_export(
shortcut["file_id"] or "",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
tmp_path,
shortcut.get("resource_key"),
)
body = xlsx_to_markdown(tmp_path)
finally:
tmp_path.unlink(missing_ok=True)
if not body.strip():
return None
out_path.write_text(
_with_frontmatter(
path,
shortcut,
body,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
),
encoding="utf-8",
)
return out_path
return None