#!/usr/bin/env python3 from __future__ import annotations import argparse import json import sys from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent if str(SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(SCRIPT_DIR)) import project_kb # type: ignore NOTE_WIDTH = 320 NOTE_HEIGHT = 220 def write_if_changed(path: Path, content: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) if path.exists() and path.read_text(encoding='utf-8') == content: return path.write_text(content, encoding='utf-8') def file_node(node_id: str, rel_path: str, x: int, y: int) -> dict: return { 'id': node_id, 'type': 'file', 'file': rel_path, 'x': x, 'y': y, 'width': NOTE_WIDTH, 'height': NOTE_HEIGHT, } def text_node(node_id: str, text: str, x: int, y: int, width: int = 280, height: int = 120) -> dict: return { 'id': node_id, 'type': 'text', 'text': text, 'x': x, 'y': y, 'width': width, 'height': height, } def edge(edge_id: str, from_node: str, to_node: str, label: str = '') -> dict: payload = { 'id': edge_id, 'fromNode': from_node, 'toNode': to_node, 'fromSide': 'right', 'toSide': 'left', } if label: payload['label'] = label return payload def build_base_content(folder: str, title: str, order: list[str], extra_filters: list[str] | None = None) -> str: filter_expr = [f'file.inFolder("{folder}")'] if extra_filters: filter_expr.extend(extra_filters) if len(filter_expr) == 1: filters_block = f"filters: '{filter_expr[0]}'" else: inner = '\n'.join(f" - '{expr}'" for expr in filter_expr) filters_block = f"filters:\n and:\n{inner}" order_block = '\n'.join(f' - {item}' for item in order) return f'''# Generated by Claude Scholar project_views.py {filters_block} properties: title: displayName: "Title" status: displayName: "Status" updated: displayName: "Updated" year: displayName: "Year" venue: displayName: "Venue" zotero_key: displayName: "Zotero Key" linked_experiment: displayName: "Experiment" decision: displayName: "Decision" views: - type: table name: "{title}" order: {order_block} ''' def generate_bases(project_root: Path) -> list[str]: views_root = project_root / 'Views' files = { 'papers.base': build_base_content( 'Papers', 'Papers', ['file.name', 'title', 'year', 'venue', 'status', 'updated', 'zotero_key'] ), 'experiments.base': build_base_content( 'Experiments', 'Experiments', ['file.name', 'title', 'status', 'updated'] ), 'results.base': build_base_content( 'Results', 'Results', ['file.name', 'title', 'linked_experiment', 'decision', 'updated'] ), 'tasks.base': build_base_content( 'Daily', 'Tasks', ['file.name', 'title', 'status', 'updated'], extra_filters=['file.ext == "md"'] ), } written = [] for name, content in files.items(): path = views_root / name write_if_changed(path, content) written.append(str(path)) return written def existing_rel(project_root: Path, rel: str) -> str | None: path = project_root / rel if path.exists(): return rel return None def generate_project_canvas(project_root: Path) -> str: maps_root = project_root / 'Maps' nodes = [] edges = [] center = text_node('n0', 'Project map\nMain canonical notes', 40, 220) nodes.append(center) candidate_notes = [ '00-Hub.md', '01-Plan.md', 'Knowledge/Project-Overview.md', 'Knowledge/Research-Questions.md', 'Knowledge/Literature-Overview.md', 'Writing/literature-review.md', ] x = 420 y = 40 idx = 1 for rel in candidate_notes: if existing_rel(project_root, rel): node_id = f'n{idx}' nodes.append(file_node(node_id, rel, x, y)) edges.append(edge(f'e{idx}', 'n0', node_id)) y += 260 idx += 1 content = json.dumps({'nodes': nodes, 'edges': edges}, ensure_ascii=False, indent=2) + '\n' path = maps_root / 'project.canvas' write_if_changed(path, content) return str(path) def note_title(path: Path) -> str: return path.stem def result_links_experiment(result_path: Path, experiment_path: Path) -> bool: text = result_path.read_text(encoding='utf-8', errors='ignore') stem = experiment_path.stem return stem in text or f'[[Experiments/{stem}' in text def generate_experiments_canvas(project_root: Path) -> str: maps_root = project_root / 'Maps' exp_paths = sorted((project_root / 'Experiments').glob('*.md')) if (project_root / 'Experiments').exists() else [] res_paths = sorted((project_root / 'Results').glob('*.md')) if (project_root / 'Results').exists() else [] nodes = [text_node('n0', 'Experiments → Results', 40, 220)] edges = [] node_counter = 1 exp_ids: dict[Path, str] = {} res_ids: dict[Path, str] = {} for i, exp_path in enumerate(exp_paths): node_id = f'n{node_counter}' node_counter += 1 exp_ids[exp_path] = node_id rel = str(exp_path.relative_to(project_root)).replace('\\', '/') nodes.append(file_node(node_id, rel, 420, 40 + i * 260)) edges.append(edge(f'e{len(edges)+1}', 'n0', node_id, 'tests')) for i, res_path in enumerate(res_paths): node_id = f'n{node_counter}' node_counter += 1 res_ids[res_path] = node_id rel = str(res_path.relative_to(project_root)).replace('\\', '/') nodes.append(file_node(node_id, rel, 860, 40 + i * 260)) for exp_path, exp_id in exp_ids.items(): linked = False for res_path, res_id in res_ids.items(): if result_links_experiment(res_path, exp_path): edges.append(edge(f'e{len(edges)+1}', exp_id, res_id, 'evidence')) linked = True if not linked: # keep minimal visibility even if explicit links are absent pass content = json.dumps({'nodes': nodes, 'edges': edges}, ensure_ascii=False, indent=2) + '\n' path = maps_root / 'experiments.canvas' write_if_changed(path, content) return str(path) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description='Generate optional Obsidian Bases and canvas helpers for a bound project.') parser.add_argument('--cwd', default='.') parser.add_argument('--include-canvases', action='store_true', help='Also generate Maps/project.canvas and Maps/experiments.canvas') parser.add_argument('--project-id', default='') return parser.parse_args() def main() -> None: args = parse_args() repo_root = project_kb.find_repo_root(Path(args.cwd).resolve()) binding = project_kb.resolve_binding(repo_root, args.project_id or None) outputs = {'views': generate_bases(binding.project_root), 'canvases': []} if args.include_canvases: outputs['canvases'] = [ generate_project_canvas(binding.project_root), generate_experiments_canvas(binding.project_root), ] print(json.dumps(outputs, ensure_ascii=False, indent=2)) if __name__ == '__main__': main()