Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg
| MCP configs | `.mcp.json` `mcp.json` `mcp_servers.json` `claude_desktop_config.json` — extracts server nodes, package refs, env var requirements |
| Package manifests | `apm.yml` `pyproject.toml` `go.mod` `pom.xml` — one canonical package node per package (by name) plus `depends_on` edges, so a package referenced from many manifests is a single hub |
| Docs | `.md .mdx .qmd .html .txt .rst .yaml .yml` (markdown `[text](./other.md)` links and `[[wikilinks]]` become `references` edges between docs) |
| Notebooks | `.ipynb` (converted to Markdown sidecars; code cells keep the kernel language fence, outputs stripped; no extra install) |
| Office | `.docx .xlsx` (requires `uv tool install graphifyy[office]`) |
| Google Workspace | `.gdoc .gsheet .gslides` (opt-in; requires `gws` auth and `--google-workspace`; Sheets need `uv tool install graphifyy[google]`) |
| PDFs | `.pdf` |
Expand Down
12 changes: 7 additions & 5 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ Video and audio files are transcribed with faster-whisper. To focus the transcri
**Pass 3 — Docs, papers, images (Claude subagents, costs tokens)**
Claude runs in parallel over markdown, PDFs, images, and transcripts. Each subagent reads a batch of files and outputs a JSON fragment: nodes, edges, and any group relationships. The fragments are merged into a single graph.

Before Pass 3, optional converters turn supported pointer/binary formats into
Markdown sidecars under `graphify-out/converted/`. Office files (`.docx`,
`.xlsx`) use the `[office]` extra. Google Workspace shortcuts (`.gdoc`,
`.gsheet`, `.gslides`) are opt-in with `--google-workspace` or
`GRAPHIFY_GOOGLE_WORKSPACE=1` and require an authenticated `gws` CLI.
Before Pass 3, converters turn supported pointer/binary/notebook formats into
Markdown sidecars under `graphify-out/converted/`. Jupyter notebooks (`.ipynb`)
are converted with the stdlib (code cells as fenced blocks using the kernel
language, markdown cells verbatim, outputs stripped) — no extra install.
Office files (`.docx`, `.xlsx`) use the `[office]` extra. Google Workspace
shortcuts (`.gdoc`, `.gsheet`, `.gslides`) are opt-in with `--google-workspace`
or `GRAPHIFY_GOOGLE_WORKSPACE=1` and require an authenticated `gws` CLI.

---

Expand Down
104 changes: 104 additions & 0 deletions graphify/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ class FileType(str, Enum):
PAPER_EXTENSIONS = {'.pdf'}
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
OFFICE_EXTENSIONS = {'.docx', '.xlsx'}
# Notebooks are converted to markdown sidecars before indexing — do NOT add .ipynb
# to CODE_EXTENSIONS or DOC_EXTENSIONS.
NOTEBOOK_EXTENSIONS = {'.ipynb'}
VIDEO_EXTENSIONS = {'.mp4', '.mov', '.webm', '.mkv', '.avi', '.m4v', '.mp3', '.wav', '.m4a', '.ogg'}

CORPUS_WARN_THRESHOLD = 50_000 # words - below this, warn "you may not need a graph"
Expand Down Expand Up @@ -517,6 +520,8 @@ def classify_file(path: Path) -> FileType | None:
return FileType.DOCUMENT
if ext in OFFICE_EXTENSIONS:
return FileType.DOCUMENT
if ext in NOTEBOOK_EXTENSIONS:
return FileType.DOCUMENT
if ext in GOOGLE_WORKSPACE_EXTENSIONS:
return FileType.DOCUMENT
if ext in VIDEO_EXTENSIONS:
Expand Down Expand Up @@ -770,6 +775,94 @@ def convert_office_file(path: Path, out_dir: Path, root: "Path | None" = None) -
return out_path


def _notebook_sidecar_path(path: Path, out_dir: Path, root: "Path | None" = None) -> Path:
"""Stable sidecar path for a converted notebook.

Uses the same scheme convert_office_file() applies to Office sources: hash
the scan-root-RELATIVE, NFC-normalized path. An absolute key would salt the
name with the checkout location, so one tracked notebook in two clones emits
two byte-identical sidecars when graphify-out/ is committed (#2059); NFC
guards macOS NFD path drift (#1226). Sources outside the scan root keep the
absolute form.
"""
import hashlib
import unicodedata
if root is None:
# Default layout: out_dir is <root>/<graphify-out>/converted.
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 ipynb_to_markdown(path: Path) -> str:
"""Convert a Jupyter notebook to markdown, stripping outputs.

Uses the notebook's kernel language from metadata for fenced code blocks,
falling back to ``code`` when the metadata is absent.
"""
if not _file_within_size_cap(path):
return ""
try:
nb = json.loads(path.read_text(encoding="utf-8", errors="ignore"))
# Resolve the kernel language from notebook metadata so fenced code
# blocks use the correct language identifier (e.g. ```python) rather
# than the generic ```code fallback.
meta = nb.get("metadata", {})
lang = (
meta.get("language_info", {}).get("name")
or meta.get("kernelspec", {}).get("language")
or "code"
)
lines = []
for cell in nb.get("cells", []):
ct = cell.get("cell_type")
raw_src = cell.get("source", [])
src = raw_src if isinstance(raw_src, str) else "".join(raw_src)
if not src.strip():
continue
if ct == "markdown":
lines.append(src)
elif ct == "code":
lines.append(f"```{lang}\n{src}\n```")
return "\n\n".join(lines)
except Exception:
return ""


def convert_notebook_file(path: Path, out_dir: Path, root: "Path | None" = None) -> Path | None:
Comment thread
KunojiLym marked this conversation as resolved.
"""Convert a .ipynb to a markdown sidecar in out_dir.

Naming matches the Office sidecars (see _notebook_sidecar_path). The
rewrite check does not: re-running a notebook rewrites the .ipynb with
fresh outputs/execution counts while cell sources stay the same, so the
Office mtime gate would churn the sidecar. Comparing extracted markdown
keeps its mtime untouched through a re-run, and detect_incremental then
leaves an unchanged notebook alone.
"""
if path.suffix.lower() not in NOTEBOOK_EXTENSIONS:
return None

text = ipynb_to_markdown(path)
if not text.strip():
return None

out_dir.mkdir(parents=True, exist_ok=True)
out_path = _notebook_sidecar_path(path, out_dir, root=root)
payload = f"<!-- converted from {path.name} -->\n\n{text}"
try:
with open(_os_path(out_path), encoding="utf-8") as f:
if f.read() == payload:
return out_path
except OSError:
pass
out_path.write_text(payload, encoding="utf-8")
return out_path


def count_words(path: Path) -> int:
Comment thread
KunojiLym marked this conversation as resolved.
try:
ext = path.suffix.lower()
Expand Down Expand Up @@ -1443,6 +1536,17 @@ def _on_walk_error(err: OSError) -> None:
# Conversion failed (library not installed) - skip with note
skipped_sensitive.append(str(p) + " [office conversion failed - pip install graphifyy[office]]")
continue
# Notebooks: same sidecar treatment as Office files
if p.suffix.lower() in NOTEBOOK_EXTENSIONS:
md_path = convert_notebook_file(p, converted_dir, root=root)
if md_path:
if _is_ignored(md_path, root, ignore_patterns, _cache=ignore_cache):
continue
files[ftype].append(str(md_path))
total_words += _wc(md_path)
else:
skipped_sensitive.append(str(p) + " [notebook conversion failed]")
continue
files[ftype].append(str(p))
if ftype != FileType.VIDEO:
total_words += _wc(p)
Expand Down
Loading