diff options
Diffstat (limited to '.agents/skills/obsidian-project-memory/scripts/project_views.py')
| -rwxr-xr-x | .agents/skills/obsidian-project-memory/scripts/project_views.py | 243 |
1 files changed, 243 insertions, 0 deletions
diff --git a/.agents/skills/obsidian-project-memory/scripts/project_views.py b/.agents/skills/obsidian-project-memory/scripts/project_views.py new file mode 100755 index 0000000..e388e73 --- /dev/null +++ b/.agents/skills/obsidian-project-memory/scripts/project_views.py @@ -0,0 +1,243 @@ +#!/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() |
