aboutsummaryrefslogtreecommitdiffstats
path: root/.agents/skills/obsidian-project-memory/scripts/project_views.py
diff options
context:
space:
mode:
authorsillylaird <sillyfanboy@gmail.com>2026-09-03 00:33:59 +0000
committersillylaird <sillyfanboy@gmail.com>2026-09-03 00:33:59 +0000
commit898b52edcb47bcb3e9d6106e74ca73e74ea01e70 (patch)
tree85c6ee5ad58b860144551184d4cf86b560c62b91 /.agents/skills/obsidian-project-memory/scripts/project_views.py
downloadwww-main.tar.gz
www-main.zip
import live www.sillylaird.ca webrootHEADmain
Diffstat (limited to '')
-rwxr-xr-x.agents/skills/obsidian-project-memory/scripts/project_views.py243
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()