chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Daily Notes Connectivity Agent
|
||||
Analyzes daily notes and creates meaningful connections between them and other vault content.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import yaml
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
import json
|
||||
|
||||
class DailyNotesConnector:
|
||||
def __init__(self, vault_path):
|
||||
self.vault_path = Path(vault_path)
|
||||
self.connections_made = 0
|
||||
self.notes_processed = 0
|
||||
self.patterns = {
|
||||
'project': r'(?:project|AI IDEAS|idea|experiment|build|develop)',
|
||||
'meeting': r'(?:meeting|call|discussion|client|consultation)',
|
||||
'technical': r'(?:MCP|LangChain|GraphRAG|AI|ML|model|agent|tool)',
|
||||
'client': r'(?:client|consulting|business|CamRohn)',
|
||||
'personal': r'(?:family|personal|reflection|stoic|goal)',
|
||||
'research': r'(?:research|paper|study|article|documentation)',
|
||||
'community': r'(?:Austin|LangChain|meetup|community|conference)'
|
||||
}
|
||||
self.connection_map = defaultdict(list)
|
||||
|
||||
def find_daily_notes(self):
|
||||
"""Find all daily notes across the vault."""
|
||||
daily_notes = []
|
||||
|
||||
# Search patterns for daily notes
|
||||
patterns = [
|
||||
self.vault_path / "Daily Notes" / "*.md",
|
||||
self.vault_path / "REMOTE_VAULT01" / "Daily Notes" / "*.md",
|
||||
self.vault_path / "Daily Email" / "*.md",
|
||||
self.vault_path / "_PERSONAL_" / "JOURNAL" / "**" / "*.md"
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
for file_path in self.vault_path.glob(str(pattern).split(str(self.vault_path) + "/")[1]):
|
||||
# Check if filename matches date pattern
|
||||
if re.match(r'\d{4}-\d{2}-\d{2}', file_path.stem):
|
||||
daily_notes.append(file_path)
|
||||
|
||||
return sorted(daily_notes)
|
||||
|
||||
def extract_frontmatter(self, file_path):
|
||||
"""Extract frontmatter from a markdown file."""
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
if content.startswith('---'):
|
||||
try:
|
||||
end_index = content.index('---', 3)
|
||||
frontmatter_text = content[3:end_index].strip()
|
||||
return yaml.safe_load(frontmatter_text), content[end_index+3:]
|
||||
except:
|
||||
return {}, content
|
||||
return {}, content
|
||||
|
||||
def update_frontmatter(self, file_path, frontmatter, body):
|
||||
"""Update the frontmatter of a file."""
|
||||
yaml_content = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True)
|
||||
new_content = f"---\n{yaml_content}---\n{body}"
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
|
||||
def analyze_content(self, content):
|
||||
"""Analyze content to identify topics and themes."""
|
||||
content_lower = content.lower()
|
||||
topics = defaultdict(int)
|
||||
|
||||
for topic, pattern in self.patterns.items():
|
||||
matches = re.findall(pattern, content_lower)
|
||||
topics[topic] = len(matches)
|
||||
|
||||
# Extract specific mentions
|
||||
mentions = {
|
||||
'projects': re.findall(r'\[\[([^]]+)\]\]', content),
|
||||
'headers': re.findall(r'^#+\s+(.+)$', content, re.MULTILINE),
|
||||
'urls': re.findall(r'https?://[^\s\]]+', content),
|
||||
'tags': re.findall(r'#(\w+)', content)
|
||||
}
|
||||
|
||||
return topics, mentions
|
||||
|
||||
def find_related_content(self, topics, mentions, current_file):
|
||||
"""Find related content based on topics and mentions."""
|
||||
related = []
|
||||
|
||||
# Map topics to vault directories
|
||||
topic_dirs = {
|
||||
'project': ['AI IDEAS', 'AI Development'],
|
||||
'meeting': ['CamRohn LLC/Client Work', 'Austin LangChain'],
|
||||
'technical': ['AI Development', 'Model Context Protocol (MCP)'],
|
||||
'client': ['CamRohn LLC', 'Second Opinion DDS'],
|
||||
'research': ['AI Articles and Research', 'Clippings'],
|
||||
'community': ['Austin LangChain', 'AI Conferences and Competitions']
|
||||
}
|
||||
|
||||
# Find files based on dominant topics
|
||||
for topic, count in sorted(topics.items(), key=lambda x: x[1], reverse=True):
|
||||
if count > 0 and topic in topic_dirs:
|
||||
for dir_name in topic_dirs[topic]:
|
||||
dir_path = self.vault_path / dir_name
|
||||
if dir_path.exists():
|
||||
# Add MOC if exists
|
||||
moc_path = dir_path / f"MOC - {dir_name.split('/')[-1]}.md"
|
||||
if moc_path.exists():
|
||||
related.append((moc_path, f"{topic} reference"))
|
||||
|
||||
# Add specific mentioned files
|
||||
for mention in mentions['projects']:
|
||||
if dir_name in mention:
|
||||
file_path = self.vault_path / f"{mention}.md"
|
||||
if file_path.exists() and file_path != current_file:
|
||||
related.append((file_path, "direct mention"))
|
||||
|
||||
return related[:10] # Limit to top 10 connections
|
||||
|
||||
def find_temporal_connections(self, file_path, all_notes):
|
||||
"""Find temporal connections (previous/next days, weekly summaries)."""
|
||||
temporal = []
|
||||
|
||||
# Extract date from filename
|
||||
date_match = re.match(r'(\d{4})-(\d{2})-(\d{2})', file_path.stem)
|
||||
if not date_match:
|
||||
return temporal
|
||||
|
||||
current_date = datetime(int(date_match.group(1)),
|
||||
int(date_match.group(2)),
|
||||
int(date_match.group(3)))
|
||||
|
||||
# Find previous and next days
|
||||
for days_offset in [-1, 1]:
|
||||
target_date = current_date + timedelta(days=days_offset)
|
||||
target_str = target_date.strftime('%Y-%m-%d')
|
||||
|
||||
for note in all_notes:
|
||||
if target_str in note.stem:
|
||||
temporal.append((note, f"{'Previous' if days_offset < 0 else 'Next'} day"))
|
||||
break
|
||||
|
||||
# Find weekly connections (same week)
|
||||
week_start = current_date - timedelta(days=current_date.weekday())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
|
||||
for note in all_notes:
|
||||
date_match = re.match(r'(\d{4})-(\d{2})-(\d{2})', note.stem)
|
||||
if date_match:
|
||||
note_date = datetime(int(date_match.group(1)),
|
||||
int(date_match.group(2)),
|
||||
int(date_match.group(3)))
|
||||
if week_start <= note_date <= week_end and note != file_path:
|
||||
temporal.append((note, "Same week"))
|
||||
|
||||
return temporal
|
||||
|
||||
def process_daily_note(self, file_path, all_notes):
|
||||
"""Process a single daily note and add connections."""
|
||||
print(f"Processing: {file_path.relative_to(self.vault_path)}")
|
||||
|
||||
frontmatter, body = self.extract_frontmatter(file_path)
|
||||
topics, mentions = self.analyze_content(body)
|
||||
|
||||
# Find related content
|
||||
content_related = self.find_related_content(topics, mentions, file_path)
|
||||
temporal_related = self.find_temporal_connections(file_path, all_notes)
|
||||
|
||||
# Build related list
|
||||
new_related = []
|
||||
|
||||
# Add temporal connections first
|
||||
for related_file, relation_type in temporal_related:
|
||||
if "Previous" in relation_type or "Next" in relation_type:
|
||||
relative_path = related_file.relative_to(self.vault_path)
|
||||
link = f"[[{relative_path.with_suffix('').as_posix()}]]"
|
||||
if relation_type == "Previous day":
|
||||
new_related.insert(0, f"{link} # {relation_type}")
|
||||
else:
|
||||
new_related.append(f"{link} # {relation_type}")
|
||||
|
||||
# Add content-based connections
|
||||
for related_file, relation_type in content_related:
|
||||
relative_path = related_file.relative_to(self.vault_path)
|
||||
link = f"[[{relative_path.with_suffix('').as_posix()}]]"
|
||||
comment = f" # {relation_type.title()}"
|
||||
new_related.append(f"{link}{comment}")
|
||||
|
||||
# Update frontmatter if we found new connections
|
||||
if new_related:
|
||||
existing_related = frontmatter.get('related', [])
|
||||
if isinstance(existing_related, list):
|
||||
# Merge and deduplicate - convert lists to strings for deduplication
|
||||
combined = existing_related + new_related
|
||||
seen = set()
|
||||
all_related = []
|
||||
for item in combined:
|
||||
if item not in seen:
|
||||
seen.add(item)
|
||||
all_related.append(item)
|
||||
else:
|
||||
all_related = new_related
|
||||
|
||||
frontmatter['related'] = all_related
|
||||
self.update_frontmatter(file_path, frontmatter, body)
|
||||
self.connections_made += len(new_related)
|
||||
|
||||
self.notes_processed += 1
|
||||
|
||||
# Track patterns for reporting
|
||||
for topic, count in topics.items():
|
||||
if count > 0:
|
||||
self.connection_map[topic].append(file_path.stem)
|
||||
|
||||
def generate_report(self):
|
||||
"""Generate a report of connections made."""
|
||||
report = f"""# Daily Notes Connectivity Report
|
||||
|
||||
Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}
|
||||
|
||||
## Summary
|
||||
- Total daily notes processed: {self.notes_processed}
|
||||
- Total connections created: {self.connections_made}
|
||||
- Average connections per note: {self.connections_made / max(self.notes_processed, 1):.1f}
|
||||
|
||||
## Connection Patterns Discovered
|
||||
|
||||
"""
|
||||
|
||||
for topic, dates in self.connection_map.items():
|
||||
if dates:
|
||||
report += f"### {topic.title()} Topics\n"
|
||||
report += f"Found in {len(dates)} daily notes:\n"
|
||||
# Show recent examples
|
||||
for date in sorted(dates)[-5:]:
|
||||
report += f"- [[{date}]]\n"
|
||||
report += "\n"
|
||||
|
||||
report += """## Themes Across Time Periods
|
||||
|
||||
### Recent Trends (Last 30 days)
|
||||
"""
|
||||
|
||||
# Analyze recent trends
|
||||
recent_date = datetime.now() - timedelta(days=30)
|
||||
recent_topics = defaultdict(int)
|
||||
|
||||
for topic, dates in self.connection_map.items():
|
||||
for date_str in dates:
|
||||
try:
|
||||
date_match = re.match(r'(\d{4})-(\d{2})-(\d{2})', date_str)
|
||||
if date_match:
|
||||
note_date = datetime(int(date_match.group(1)),
|
||||
int(date_match.group(2)),
|
||||
int(date_match.group(3)))
|
||||
if note_date >= recent_date:
|
||||
recent_topics[topic] += 1
|
||||
except:
|
||||
pass
|
||||
|
||||
for topic, count in sorted(recent_topics.items(), key=lambda x: x[1], reverse=True):
|
||||
report += f"- **{topic.title()}**: {count} occurrences\n"
|
||||
|
||||
report += "\n## Recommendations\n\n"
|
||||
report += "1. Consider creating weekly/monthly summary notes to consolidate themes\n"
|
||||
report += "2. Review orphaned daily notes that lack connections\n"
|
||||
report += "3. Add more content to empty daily notes for better connectivity\n"
|
||||
|
||||
return report
|
||||
|
||||
def run(self):
|
||||
"""Main execution method."""
|
||||
print("Daily Notes Connectivity Agent Starting...")
|
||||
print(f"Vault path: {self.vault_path}")
|
||||
|
||||
# Find all daily notes
|
||||
daily_notes = self.find_daily_notes()
|
||||
print(f"Found {len(daily_notes)} daily notes")
|
||||
|
||||
# Process each note
|
||||
for note in daily_notes:
|
||||
try:
|
||||
self.process_daily_note(note, daily_notes)
|
||||
except Exception as e:
|
||||
print(f"Error processing {note}: {e}")
|
||||
|
||||
# Generate and save report
|
||||
report = self.generate_report()
|
||||
report_path = self.vault_path / "System_Files" / "Daily_Notes_Connectivity_Report.md"
|
||||
|
||||
with open(report_path, 'w', encoding='utf-8') as f:
|
||||
f.write(report)
|
||||
|
||||
print(f"\nComplete! Report saved to: {report_path}")
|
||||
print(f"Processed {self.notes_processed} notes, created {self.connections_made} connections")
|
||||
|
||||
if __name__ == "__main__":
|
||||
vault_path = "/Users/cam/VAULT01"
|
||||
connector = DailyNotesConnector(vault_path)
|
||||
connector.run()
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Enhanced Tag Standardizer for hierarchical tag structure.
|
||||
Consolidates similar tags and applies hierarchical organization.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from collections import defaultdict, Counter
|
||||
|
||||
class EnhancedTagStandardizer:
|
||||
def __init__(self, vault_path):
|
||||
self.vault_path = Path(vault_path)
|
||||
self.files_updated = 0
|
||||
|
||||
# Enhanced mappings for better consolidation
|
||||
self.enhanced_mappings = {
|
||||
# AI hierarchy consolidation
|
||||
'ai-development': 'ai/development',
|
||||
'ai-ideas': 'idea', # Ideas are tracked with 'idea' tag
|
||||
'ai-tools': 'ai/tools',
|
||||
'ai-consulting': 'consulting',
|
||||
'ai-courses': 'learning/course',
|
||||
'ai-conferences-and-competitions': 'learning/conference',
|
||||
'ai-articles-and-research': 'ai/research',
|
||||
'ai-agent': 'ai/agents',
|
||||
'ai-community': 'community',
|
||||
'ai-integration': 'ai/development',
|
||||
'ai-services': 'ai/tools',
|
||||
|
||||
# Business hierarchy
|
||||
'business-strategy': 'business/strategy',
|
||||
'business-development': 'business/development',
|
||||
'business-intelligence': 'business/analytics',
|
||||
'business-model': 'business/strategy',
|
||||
'business-automation': 'automation',
|
||||
'business-systems': 'business/systems',
|
||||
'business-case': 'business/strategy',
|
||||
'business-context': 'business',
|
||||
'business-research': 'business/research',
|
||||
'business-mapping': 'business/strategy',
|
||||
'business-report': 'business/analytics',
|
||||
'business-operations': 'business/operations',
|
||||
'business-plan': 'business/strategy',
|
||||
'business-transformation': 'business/strategy',
|
||||
'business-solutions': 'business/solutions',
|
||||
'business-assets': 'business/assets',
|
||||
|
||||
# Client work standardization
|
||||
'client-work': 'client',
|
||||
'client-materials': 'client',
|
||||
'client-analytics': 'client',
|
||||
'client-communication': 'client',
|
||||
|
||||
# Learning hierarchy
|
||||
'tutorial/course': 'learning/course',
|
||||
'learning-paths': 'learning',
|
||||
'courses': 'learning/course',
|
||||
'tutorials': 'tutorial',
|
||||
'guides': 'tutorial',
|
||||
'training': 'learning',
|
||||
'certifications': 'learning/certification',
|
||||
|
||||
# Technical tags
|
||||
'development-tools': 'ai/tools',
|
||||
'tools': 'ai/tools',
|
||||
'apis': 'api',
|
||||
'api-integration': 'api',
|
||||
'api-testing': 'testing',
|
||||
|
||||
# Content organization
|
||||
'daily-notes': 'daily',
|
||||
'daily-email': 'email',
|
||||
'email-summary': 'email',
|
||||
'email-processing': 'email',
|
||||
'email-marketing': 'marketing',
|
||||
|
||||
# Web development
|
||||
'web-development': 'development',
|
||||
'web-presence': 'business/web-presence',
|
||||
'_web-presence-development': 'business/web-presence',
|
||||
|
||||
# Personal tags
|
||||
'_personal_': 'personal',
|
||||
'personal-development': 'personal/development',
|
||||
|
||||
# Project management
|
||||
'project-management': 'project',
|
||||
'project-timeline': 'project',
|
||||
|
||||
# Marketing & content
|
||||
'content-strategy': 'marketing/content',
|
||||
'content-marketing': 'marketing/content',
|
||||
'content-calendar': 'marketing/calendar',
|
||||
'content-distribution': 'marketing/distribution',
|
||||
'marketing-strategy': 'marketing/strategy',
|
||||
|
||||
# Technical infrastructure
|
||||
'it-infrastructure': 'infrastructure',
|
||||
'server-management': 'infrastructure',
|
||||
'server-setup': 'infrastructure',
|
||||
|
||||
# Data and analytics
|
||||
'data-processing': 'data',
|
||||
'data-sources': 'data',
|
||||
'data-security': 'security',
|
||||
|
||||
# Remove underscores from tags
|
||||
'_tutorials_': 'tutorial',
|
||||
'_business-formation_': 'business/formation',
|
||||
|
||||
# Standardize compound words
|
||||
'meeting-notes': 'meeting',
|
||||
'meetings': 'meeting',
|
||||
|
||||
# Standardize plural forms
|
||||
'agents': 'ai/agents',
|
||||
'templates': 'template',
|
||||
'projects': 'project',
|
||||
'tasks': 'action/todo',
|
||||
'sources': 'source',
|
||||
'systems': 'system',
|
||||
'solutions': 'solution',
|
||||
'recommendations': 'recommendation',
|
||||
'transcripts': 'transcript',
|
||||
'discussions': 'discussion',
|
||||
'platforms': 'platform',
|
||||
'frameworks': 'framework',
|
||||
'pipelines': 'pipeline',
|
||||
'servers': 'server',
|
||||
'summaries': 'summary',
|
||||
'conferences': 'conference',
|
||||
'opportunities': 'opportunity',
|
||||
'datasets': 'dataset',
|
||||
|
||||
# Consolidate similar concepts
|
||||
'thought-leadership': 'authority-building',
|
||||
'technical-authority': 'authority-building',
|
||||
'brainstorming': 'idea',
|
||||
'strategic-planning': 'strategy',
|
||||
'strategic-decision-making': 'strategy',
|
||||
'strategic-overview': 'strategy',
|
||||
'strategic-connections': 'strategy',
|
||||
|
||||
# Standardize vendor/tool names
|
||||
'anthropic_blog': 'anthropic',
|
||||
'anthropic_github': 'anthropic',
|
||||
'github_topic_-_mcp': 'mcp',
|
||||
'github_topic_-_mcp_server': 'mcp',
|
||||
'mcp_reddit': 'mcp',
|
||||
'mcp_documentation': 'mcp',
|
||||
'mcp_github_discussions': 'mcp',
|
||||
'mcp-server': 'mcp',
|
||||
'npm_-_mcp_packages': 'mcp',
|
||||
'dev.to_mcp_tag': 'mcp',
|
||||
'medium_-_mcp_topics': 'mcp',
|
||||
'pulsemcp_blog': 'pulsemcp',
|
||||
|
||||
# Clean up system tags
|
||||
'system_files': 'system',
|
||||
'remote_vault01': 'remote-sync',
|
||||
|
||||
# Consolidate database tags
|
||||
'graph-db': 'database',
|
||||
'graph-databases': 'database',
|
||||
'vector-databases': 'database',
|
||||
'database-queries': 'database',
|
||||
'database-updates': 'database',
|
||||
|
||||
# Consolidate AI concepts
|
||||
'ai/rag': 'ai/embeddings',
|
||||
'agentic-rag': 'ai/embeddings',
|
||||
'knowledge-graph': 'graphrag',
|
||||
'knowledge-network': 'graphrag',
|
||||
'knowledge-management': 'knowledge-base',
|
||||
|
||||
# Family tags
|
||||
'family-projects': 'family/projects',
|
||||
'family/index': 'family',
|
||||
|
||||
# Visual content
|
||||
'visual-assets': 'visual-assets',
|
||||
'visual-organization': 'visual-assets',
|
||||
'visual-learning': 'visual-assets',
|
||||
'visual-search': 'visual-assets',
|
||||
'image-gallery': 'gallery',
|
||||
'image-generation': 'ai/tools',
|
||||
'screenshots': 'visual-assets',
|
||||
'screenshots-and-references': 'visual-assets',
|
||||
'infographics': 'visual-assets',
|
||||
'charts': 'visual-assets',
|
||||
'images': 'visual-assets',
|
||||
'snagit-captures': 'visual-assets',
|
||||
|
||||
# Analytics and metrics
|
||||
'analytics': 'analytics',
|
||||
'client-analytics': 'analytics',
|
||||
'revenue-analytics': 'analytics',
|
||||
'performance-metrics': 'analytics',
|
||||
'financial-analysis': 'finance',
|
||||
'roi-analysis': 'roi',
|
||||
'roi-calculator': 'roi',
|
||||
|
||||
# Simplify compound concepts
|
||||
'complexity-analysis': 'analysis',
|
||||
'connection-analysis': 'analysis',
|
||||
'network-analysis': 'analysis',
|
||||
'schema-analysis': 'analysis',
|
||||
'competitive-intelligence': 'analysis',
|
||||
'competitor-tracking': 'analysis',
|
||||
|
||||
# Standardize workflow tags
|
||||
'workflow': 'workflows',
|
||||
|
||||
# Clean up misc tags
|
||||
'--ollama-deep-research': 'ollama',
|
||||
'second-opinion-dds': 'dental',
|
||||
'austin-langchain': 'community',
|
||||
'the-build': 'build',
|
||||
'mcpcentral.io': 'mcpcentral',
|
||||
}
|
||||
|
||||
def extract_frontmatter(self, content):
|
||||
"""Extract YAML frontmatter from content."""
|
||||
if not content.strip().startswith('---'):
|
||||
return None, content
|
||||
|
||||
lines = content.split('\n')
|
||||
if len(lines) < 3:
|
||||
return None, content
|
||||
|
||||
end_idx = None
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == '---':
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if end_idx is None:
|
||||
return None, content
|
||||
|
||||
try:
|
||||
frontmatter_text = '\n'.join(lines[1:end_idx])
|
||||
frontmatter = yaml.safe_load(frontmatter_text)
|
||||
remaining_content = '\n'.join(lines[end_idx + 1:])
|
||||
return frontmatter, remaining_content
|
||||
except yaml.YAMLError:
|
||||
return None, content
|
||||
|
||||
def enhance_tag(self, tag):
|
||||
"""Apply enhanced tag standardization."""
|
||||
# Remove leading/trailing whitespace
|
||||
tag = tag.strip()
|
||||
|
||||
# Remove hash if present
|
||||
if tag.startswith('#'):
|
||||
tag = tag[1:]
|
||||
|
||||
# Apply enhanced mappings
|
||||
if tag in self.enhanced_mappings:
|
||||
return self.enhanced_mappings[tag]
|
||||
|
||||
# Default: lowercase and replace spaces with hyphens
|
||||
return tag.lower().replace(' ', '-')
|
||||
|
||||
def process_file(self, file_path):
|
||||
"""Process a single file with enhanced tag standardization."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
frontmatter, remaining_content = self.extract_frontmatter(content)
|
||||
|
||||
if frontmatter is None or 'tags' not in frontmatter:
|
||||
return False
|
||||
|
||||
# Get current tags
|
||||
current_tags = frontmatter.get('tags', [])
|
||||
if not current_tags:
|
||||
return False
|
||||
|
||||
# Standardize tags
|
||||
if isinstance(current_tags, list):
|
||||
new_tags = []
|
||||
changed = False
|
||||
|
||||
for tag in current_tags:
|
||||
if isinstance(tag, str):
|
||||
enhanced = self.enhance_tag(tag)
|
||||
if enhanced != tag:
|
||||
changed = True
|
||||
if enhanced and enhanced not in new_tags:
|
||||
new_tags.append(enhanced)
|
||||
|
||||
if changed:
|
||||
# Update frontmatter
|
||||
frontmatter['tags'] = new_tags
|
||||
|
||||
# Reconstruct content
|
||||
new_frontmatter = yaml.dump(frontmatter, default_flow_style=False, sort_keys=False)
|
||||
new_content = f"---\n{new_frontmatter}---\n{remaining_content}"
|
||||
|
||||
# Write back to file
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
|
||||
print(f"Enhanced: {file_path.relative_to(self.vault_path)}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def process_vault(self):
|
||||
"""Process all files in the vault."""
|
||||
skip_dirs = {'.obsidian', '.trash', '.git'}
|
||||
|
||||
for file_path in self.vault_path.rglob('*.md'):
|
||||
if any(skip_dir in file_path.parts for skip_dir in skip_dirs):
|
||||
continue
|
||||
|
||||
if self.process_file(file_path):
|
||||
self.files_updated += 1
|
||||
|
||||
return self.files_updated
|
||||
|
||||
def main():
|
||||
vault_path = '/Users/cam/VAULT01'
|
||||
print(f"Enhanced tag standardization for: {vault_path}")
|
||||
print("This will consolidate similar tags and apply hierarchical organization")
|
||||
print("-" * 60)
|
||||
|
||||
standardizer = EnhancedTagStandardizer(vault_path)
|
||||
updated = standardizer.process_vault()
|
||||
|
||||
print("-" * 60)
|
||||
print(f"Total files updated: {updated}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Find and implement keyword-based connections between files."""
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
# Priority keywords to focus on
|
||||
PRIORITY_KEYWORDS = {
|
||||
'llm', 'langchain', 'langgraph', 'rag', 'embedding', 'vector',
|
||||
'agent', 'automation', 'workflow', 'pipeline', 'mcp', 'api',
|
||||
'anthropic', 'openai', 'google', 'claude', 'gpt', 'model',
|
||||
'context', 'protocol', 'fastapi', 'docker', 'cloudflare',
|
||||
'supabase', 'integration', 'framework', 'retrieval', 'augmented',
|
||||
'generation', 'prompt', 'engineering', 'multimodal', 'function',
|
||||
'calling', 'tool', 'use', 'chain', 'thought', 'reasoning'
|
||||
}
|
||||
|
||||
def extract_keywords_from_file(file_path):
|
||||
"""Extract meaningful keywords from a file."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read().lower()
|
||||
|
||||
# Extract words (alphanumeric plus hyphens)
|
||||
words = re.findall(r'\b[a-z0-9-]+\b', content)
|
||||
|
||||
# Filter for priority keywords and count occurrences
|
||||
keyword_counts = Counter()
|
||||
for word in words:
|
||||
if word in PRIORITY_KEYWORDS:
|
||||
keyword_counts[word] += 1
|
||||
|
||||
return keyword_counts
|
||||
except:
|
||||
return Counter()
|
||||
|
||||
def find_keyword_connections(vault_root):
|
||||
"""Find files that share multiple priority keywords."""
|
||||
search_dirs = [
|
||||
vault_root / "AI Development",
|
||||
vault_root / "AI Articles and Research",
|
||||
vault_root / "AI IDEAS",
|
||||
vault_root / "AI Courses",
|
||||
vault_root / "CamRohn LLC",
|
||||
vault_root / "Clippings"
|
||||
]
|
||||
|
||||
# Collect keyword data for all files
|
||||
file_keywords = {}
|
||||
print("Analyzing files for keywords...")
|
||||
|
||||
for search_dir in search_dirs:
|
||||
if not search_dir.exists():
|
||||
continue
|
||||
|
||||
for root, dirs, files in os.walk(search_dir):
|
||||
for file in files:
|
||||
if file.endswith('.md'):
|
||||
file_path = Path(root) / file
|
||||
keywords = extract_keywords_from_file(file_path)
|
||||
if keywords:
|
||||
file_keywords[file_path] = keywords
|
||||
|
||||
print(f"Analyzed {len(file_keywords)} files")
|
||||
|
||||
# Find connections between files
|
||||
connections = []
|
||||
processed_pairs = set()
|
||||
|
||||
files = list(file_keywords.keys())
|
||||
for i, file1 in enumerate(files):
|
||||
if i % 100 == 0:
|
||||
print(f"Processing file {i+1}/{len(files)}...")
|
||||
|
||||
for j, file2 in enumerate(files[i+1:], start=i+1):
|
||||
# Skip if already processed
|
||||
pair = tuple(sorted([str(file1), str(file2)]))
|
||||
if pair in processed_pairs:
|
||||
continue
|
||||
processed_pairs.add(pair)
|
||||
|
||||
# Find common keywords
|
||||
common_keywords = set(file_keywords[file1].keys()) & set(file_keywords[file2].keys())
|
||||
|
||||
if len(common_keywords) >= 5: # At least 5 common keywords
|
||||
# Calculate relevance score
|
||||
score = sum(
|
||||
min(file_keywords[file1][kw], file_keywords[file2][kw])
|
||||
for kw in common_keywords
|
||||
)
|
||||
|
||||
connections.append({
|
||||
'file1': file1,
|
||||
'file2': file2,
|
||||
'keywords': list(common_keywords),
|
||||
'score': score
|
||||
})
|
||||
|
||||
# Sort by score
|
||||
connections.sort(key=lambda x: x['score'], reverse=True)
|
||||
|
||||
return connections
|
||||
|
||||
def add_link_to_file(file_path, link_to_add, link_text=None):
|
||||
"""Add a link to a file if it doesn't already exist."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Extract just the filename for the link
|
||||
link_filename = Path(link_to_add).stem
|
||||
|
||||
# Check if link already exists
|
||||
if f'[[{link_filename}]]' in content:
|
||||
return False
|
||||
|
||||
# Find or create Related section
|
||||
related_match = re.search(r'^## Related\s*$', content, re.MULTILINE)
|
||||
|
||||
if related_match:
|
||||
# Insert after the Related header
|
||||
insert_pos = related_match.end()
|
||||
# Skip to next line
|
||||
next_line = content.find('\n', insert_pos)
|
||||
if next_line != -1:
|
||||
insert_pos = next_line + 1
|
||||
else:
|
||||
# Add Related section at end
|
||||
if not content.endswith('\n'):
|
||||
content += '\n'
|
||||
content += '\n## Related\n'
|
||||
insert_pos = len(content)
|
||||
|
||||
# Create the link line
|
||||
if link_text:
|
||||
link_line = f"- [[{link_filename}]] - {link_text}\n"
|
||||
else:
|
||||
link_line = f"- [[{link_filename}]]\n"
|
||||
|
||||
# Insert the link
|
||||
new_content = content[:insert_pos] + link_line + content[insert_pos:]
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error updating {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def implement_keyword_connections(connections, limit=50):
|
||||
"""Implement the top keyword-based connections."""
|
||||
connections_made = 0
|
||||
keyword_clusters = defaultdict(list)
|
||||
|
||||
for conn in connections[:limit]:
|
||||
file1 = conn['file1']
|
||||
file2 = conn['file2']
|
||||
keywords = conn['keywords']
|
||||
|
||||
# Create descriptive text
|
||||
keyword_text = f"Shares keywords: {', '.join(sorted(keywords)[:5])}"
|
||||
|
||||
# Add bidirectional links
|
||||
if add_link_to_file(file1, file2, keyword_text):
|
||||
connections_made += 1
|
||||
print(f"Connected {file1.name} ↔ {file2.name}")
|
||||
|
||||
# Track keyword clusters
|
||||
for kw in keywords:
|
||||
keyword_clusters[kw].append((file1.name, file2.name))
|
||||
|
||||
if add_link_to_file(file2, file1, keyword_text):
|
||||
connections_made += 1
|
||||
|
||||
return connections_made, keyword_clusters
|
||||
|
||||
def main():
|
||||
vault_root = Path("/Users/cam/VAULT01")
|
||||
|
||||
print("Finding keyword-based connections...")
|
||||
connections = find_keyword_connections(vault_root)
|
||||
|
||||
print(f"\nFound {len(connections)} potential keyword connections")
|
||||
|
||||
# Show top connections
|
||||
print("\nTop 10 keyword connections by relevance:")
|
||||
for i, conn in enumerate(connections[:10]):
|
||||
print(f"{i+1}. {conn['file1'].name} ↔ {conn['file2'].name}")
|
||||
print(f" Keywords ({len(conn['keywords'])}): {', '.join(sorted(conn['keywords'])[:8])}")
|
||||
print(f" Score: {conn['score']}")
|
||||
|
||||
# Implement connections
|
||||
print("\nImplementing connections...")
|
||||
connections_made, keyword_clusters = implement_keyword_connections(connections)
|
||||
|
||||
print(f"\n=== Keyword Connection Report ===")
|
||||
print(f"Total connections made: {connections_made}")
|
||||
|
||||
print("\nMost connected keywords:")
|
||||
sorted_keywords = sorted(keyword_clusters.items(), key=lambda x: len(x[1]), reverse=True)[:10]
|
||||
for keyword, connections in sorted_keywords:
|
||||
print(f" {keyword}: {len(connections)} connections")
|
||||
|
||||
# Find cross-domain connections
|
||||
print("\nCross-domain connections:")
|
||||
cross_domain = 0
|
||||
for i, (keyword, conn_list) in enumerate(sorted_keywords[:5]):
|
||||
print(f"\n{keyword}-related cross-domain connections:")
|
||||
for file1_name, file2_name in conn_list[:3]: # Show first 3 for each keyword
|
||||
print(f" - {file1_name} ↔ {file2_name}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fix quoted tags in frontmatter by converting single-quoted tags to unquoted format.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
def fix_quoted_tags(vault_path):
|
||||
"""Fix single-quoted tags in all markdown files."""
|
||||
vault_path = Path(vault_path)
|
||||
files_updated = 0
|
||||
|
||||
for file_path in vault_path.rglob('*.md'):
|
||||
if any(skip in file_path.parts for skip in ['.obsidian', '.trash', '.git']):
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check if file has frontmatter
|
||||
if not content.strip().startswith('---'):
|
||||
continue
|
||||
|
||||
# Find frontmatter boundaries
|
||||
lines = content.split('\n')
|
||||
if len(lines) < 3:
|
||||
continue
|
||||
|
||||
end_idx = None
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == '---':
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if end_idx is None:
|
||||
continue
|
||||
|
||||
# Extract frontmatter
|
||||
frontmatter_text = '\n'.join(lines[1:end_idx])
|
||||
|
||||
# Check if tags line has quoted values
|
||||
if re.search(r"tags:\s*\[['\"]", frontmatter_text):
|
||||
# Parse frontmatter
|
||||
try:
|
||||
frontmatter = yaml.safe_load(frontmatter_text)
|
||||
if frontmatter and 'tags' in frontmatter:
|
||||
# Update tags (remove quotes)
|
||||
tags = frontmatter['tags']
|
||||
if isinstance(tags, list):
|
||||
# Tags are already in list format, just ensure they're not quoted
|
||||
frontmatter['tags'] = [str(tag) for tag in tags]
|
||||
|
||||
# Reconstruct content
|
||||
new_frontmatter = yaml.dump(frontmatter, default_flow_style=False, sort_keys=False)
|
||||
remaining_content = '\n'.join(lines[end_idx + 1:])
|
||||
new_content = f"---\n{new_frontmatter}---\n{remaining_content}"
|
||||
|
||||
# Write back
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
|
||||
files_updated += 1
|
||||
print(f"Fixed: {file_path.relative_to(vault_path)}")
|
||||
|
||||
except yaml.YAMLError as e:
|
||||
print(f"YAML error in {file_path}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing {file_path}: {e}")
|
||||
|
||||
return files_updated
|
||||
|
||||
if __name__ == '__main__':
|
||||
vault_path = '/Users/cam/VAULT01'
|
||||
print(f"Fixing quoted tags in: {vault_path}")
|
||||
|
||||
updated = fix_quoted_tags(vault_path)
|
||||
print(f"\nTotal files updated: {updated}")
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Implement entity-based connections from Link Suggestions Report."""
|
||||
|
||||
import re
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Priority entities to focus on
|
||||
PRIORITY_ENTITIES = {
|
||||
'langchain', 'langgraph', 'llm', 'rag', 'embedding', 'vector',
|
||||
'mcp', 'model context protocol', 'api integration', 'function calling',
|
||||
'anthropic', 'openai', 'google', 'claude', 'gpt',
|
||||
'autonomous agent', 'ai agent', 'chain of thought', 'prompt engineering',
|
||||
'retrieval augmented', 'graphrag', 'multimodal', 'tool use'
|
||||
}
|
||||
|
||||
def read_file(file_path):
|
||||
"""Read file content."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except:
|
||||
return None
|
||||
|
||||
def write_file(file_path, content):
|
||||
"""Write content to file."""
|
||||
try:
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def find_file(filename, search_dirs):
|
||||
"""Find a file in the vault."""
|
||||
for dir_path in search_dirs:
|
||||
for root, dirs, files in os.walk(dir_path):
|
||||
for file in files:
|
||||
if file == filename or file == filename + '.md':
|
||||
return os.path.join(root, file)
|
||||
return None
|
||||
|
||||
def add_link_to_file(file_path, link_to_add, link_text=None):
|
||||
"""Add a link to a file if it doesn't already exist."""
|
||||
content = read_file(file_path)
|
||||
if not content:
|
||||
return False
|
||||
|
||||
# Check if link already exists
|
||||
if f'[[{link_to_add}]]' in content:
|
||||
return False
|
||||
|
||||
# Find a good place to add the link
|
||||
# Look for existing "Related:" or "See also:" sections
|
||||
related_patterns = [
|
||||
r'^#+\s*Related.*?$',
|
||||
r'^#+\s*See also.*?$',
|
||||
r'^#+\s*Links.*?$',
|
||||
r'^#+\s*References.*?$'
|
||||
]
|
||||
|
||||
insert_pos = None
|
||||
for pattern in related_patterns:
|
||||
match = re.search(pattern, content, re.MULTILINE | re.IGNORECASE)
|
||||
if match:
|
||||
# Find the end of this section
|
||||
insert_pos = match.end()
|
||||
# Skip to next line
|
||||
next_line = content.find('\n', insert_pos)
|
||||
if next_line != -1:
|
||||
insert_pos = next_line + 1
|
||||
break
|
||||
|
||||
# If no related section found, add at the end of frontmatter
|
||||
if insert_pos is None:
|
||||
# Find end of frontmatter
|
||||
frontmatter_end = content.find('---', 3)
|
||||
if frontmatter_end != -1:
|
||||
insert_pos = content.find('\n', frontmatter_end + 3) + 1
|
||||
else:
|
||||
# No frontmatter, add at beginning
|
||||
insert_pos = 0
|
||||
|
||||
# Create the link line
|
||||
if link_text:
|
||||
link_line = f"- [[{link_to_add}]] - {link_text}\n"
|
||||
else:
|
||||
link_line = f"- [[{link_to_add}]]\n"
|
||||
|
||||
# If we're not in a list section, create one
|
||||
if insert_pos is None or (insert_pos > 0 and content[insert_pos-2:insert_pos] != '\n\n'):
|
||||
# Check if we need to add a Related section
|
||||
needs_section = True
|
||||
for pattern in related_patterns:
|
||||
if re.search(pattern, content, re.MULTILINE | re.IGNORECASE):
|
||||
needs_section = False
|
||||
break
|
||||
|
||||
if needs_section:
|
||||
# Add at end of file with new Related section
|
||||
if not content.endswith('\n'):
|
||||
content += '\n'
|
||||
content += '\n## Related\n'
|
||||
content += link_line
|
||||
else:
|
||||
# Insert in existing section
|
||||
new_content = content[:insert_pos] + link_line + content[insert_pos:]
|
||||
content = new_content
|
||||
else:
|
||||
# Insert at found position
|
||||
new_content = content[:insert_pos] + link_line + content[insert_pos:]
|
||||
content = new_content
|
||||
|
||||
return write_file(file_path, content)
|
||||
|
||||
def implement_connections():
|
||||
"""Implement priority entity connections."""
|
||||
vault_root = Path("/Users/cam/VAULT01")
|
||||
search_dirs = [
|
||||
vault_root / "AI Development",
|
||||
vault_root / "AI Articles and Research",
|
||||
vault_root / "AI IDEAS",
|
||||
vault_root / "AI Courses",
|
||||
vault_root / "CamRohn LLC",
|
||||
vault_root / "Clippings"
|
||||
]
|
||||
|
||||
# Read the report
|
||||
report_path = vault_root / "System_Files" / "Link_Suggestions_Report.md"
|
||||
report_content = read_file(report_path)
|
||||
|
||||
if not report_content:
|
||||
print("Could not read report")
|
||||
return
|
||||
|
||||
# Parse entity connections
|
||||
connections_made = 0
|
||||
connections_by_entity = {}
|
||||
|
||||
# Find entity sections
|
||||
entity_pattern = r'^### (.+)$'
|
||||
connection_pattern = r'- \[\[([^\]]+)\]\] ↔ \[\[([^\]]+)\]\]'
|
||||
|
||||
current_entity = None
|
||||
for line in report_content.split('\n'):
|
||||
# Check for entity header
|
||||
entity_match = re.match(entity_pattern, line)
|
||||
if entity_match:
|
||||
current_entity = entity_match.group(1).strip().lower()
|
||||
continue
|
||||
|
||||
# Check for connection
|
||||
if current_entity and current_entity in [e.lower() for e in PRIORITY_ENTITIES]:
|
||||
conn_match = re.match(connection_pattern, line)
|
||||
if conn_match:
|
||||
file1 = conn_match.group(1).strip()
|
||||
file2 = conn_match.group(2).strip()
|
||||
|
||||
# Skip self-connections
|
||||
if file1 == file2:
|
||||
continue
|
||||
|
||||
# Find actual file paths
|
||||
file1_path = find_file(file1 + '.md', search_dirs)
|
||||
file2_path = find_file(file2 + '.md', search_dirs)
|
||||
|
||||
if file1_path and file2_path:
|
||||
# Add bidirectional links
|
||||
if add_link_to_file(file1_path, file2, f"Related to {current_entity}"):
|
||||
connections_made += 1
|
||||
if current_entity not in connections_by_entity:
|
||||
connections_by_entity[current_entity] = 0
|
||||
connections_by_entity[current_entity] += 1
|
||||
print(f"Added link from {file1} to {file2}")
|
||||
|
||||
if add_link_to_file(file2_path, file1, f"Related to {current_entity}"):
|
||||
connections_made += 1
|
||||
if current_entity not in connections_by_entity:
|
||||
connections_by_entity[current_entity] = 0
|
||||
connections_by_entity[current_entity] += 1
|
||||
print(f"Added link from {file2} to {file1}")
|
||||
|
||||
# Generate report
|
||||
print("\n=== Connection Implementation Report ===")
|
||||
print(f"Total connections made: {connections_made}")
|
||||
print("\nConnections by entity:")
|
||||
for entity, count in sorted(connections_by_entity.items(), key=lambda x: x[1], reverse=True):
|
||||
print(f" {entity}: {count} connections")
|
||||
|
||||
# Find cross-domain connections
|
||||
print("\nCross-domain connections created:")
|
||||
# This would require more complex analysis of file paths
|
||||
|
||||
if __name__ == "__main__":
|
||||
implement_connections()
|
||||
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Link Suggester for Obsidian Vault
|
||||
Identifies potential connections between notes based on content analysis.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from collections import defaultdict, Counter
|
||||
import argparse
|
||||
import json
|
||||
|
||||
class LinkSuggester:
|
||||
def __init__(self, vault_path):
|
||||
self.vault_path = Path(vault_path)
|
||||
self.notes = {}
|
||||
self.entity_mentions = defaultdict(set)
|
||||
self.potential_links = []
|
||||
|
||||
# Common entities to look for
|
||||
self.entities = {
|
||||
'technologies': [
|
||||
'langchain', 'langgraph', 'mcp', 'model context protocol',
|
||||
'graphrag', 'openai', 'anthropic', 'claude', 'gpt', 'llm',
|
||||
'ollama', 'huggingface', 'github', 'python', 'javascript',
|
||||
'cloudflare', 'supabase', 'vector database', 'embedding',
|
||||
'ai agent', 'autonomous agent', 'rag', 'retrieval augmented'
|
||||
],
|
||||
'concepts': [
|
||||
'machine learning', 'deep learning', 'neural network',
|
||||
'transformer', 'attention mechanism', 'fine-tuning',
|
||||
'prompt engineering', 'chain of thought', 'reasoning',
|
||||
'multimodal', 'text generation', 'code generation',
|
||||
'tool use', 'function calling', 'api integration'
|
||||
],
|
||||
'companies': [
|
||||
'google', 'microsoft', 'amazon', 'meta', 'apple',
|
||||
'nvidia', 'intel', 'amd', 'tesla', 'stripe',
|
||||
'y combinator', 'techstars', 'propel', 'dental'
|
||||
],
|
||||
'people': [
|
||||
'andrew ng', 'geoffrey hinton', 'yann lecun', 'ilya sutskever',
|
||||
'sam altman', 'dario amodei', 'demis hassabis', 'jensen huang'
|
||||
]
|
||||
}
|
||||
|
||||
# Flatten entities for easier searching
|
||||
self.all_entities = []
|
||||
for category, entities in self.entities.items():
|
||||
self.all_entities.extend(entities)
|
||||
|
||||
def load_notes(self):
|
||||
"""Load all markdown files and their content."""
|
||||
skip_dirs = {'.obsidian', '.trash', 'System_Files', '.git'}
|
||||
|
||||
for file_path in self.vault_path.rglob('*.md'):
|
||||
if any(skip_dir in file_path.parts for skip_dir in skip_dirs):
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Extract title
|
||||
title_match = re.search(r'^#\s+(.+)$', content, re.MULTILINE)
|
||||
title = title_match.group(1) if title_match else file_path.stem
|
||||
|
||||
# Extract existing links
|
||||
existing_links = set(re.findall(r'\[\[([^\]]+)\]\]', content))
|
||||
|
||||
self.notes[file_path] = {
|
||||
'title': title,
|
||||
'content': content.lower(),
|
||||
'existing_links': existing_links,
|
||||
'word_count': len(content.split())
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error reading {file_path}: {e}")
|
||||
|
||||
def find_entity_mentions(self):
|
||||
"""Find mentions of entities across all notes."""
|
||||
for file_path, note_data in self.notes.items():
|
||||
content = note_data['content']
|
||||
|
||||
for entity in self.all_entities:
|
||||
if entity in content:
|
||||
self.entity_mentions[entity].add(file_path)
|
||||
|
||||
def suggest_links_by_entities(self):
|
||||
"""Suggest links based on common entity mentions."""
|
||||
suggestions = []
|
||||
|
||||
for entity, files in self.entity_mentions.items():
|
||||
if len(files) >= 2: # Entity mentioned in at least 2 files
|
||||
file_list = list(files)
|
||||
|
||||
for i, file1 in enumerate(file_list):
|
||||
for file2 in file_list[i+1:]:
|
||||
# Check if files don't already link to each other
|
||||
note1 = self.notes[file1]
|
||||
note2 = self.notes[file2]
|
||||
|
||||
if (note2['title'] not in note1['existing_links'] and
|
||||
note1['title'] not in note2['existing_links']):
|
||||
|
||||
suggestions.append({
|
||||
'file1': file1,
|
||||
'file2': file2,
|
||||
'title1': note1['title'],
|
||||
'title2': note2['title'],
|
||||
'common_entity': entity,
|
||||
'type': 'entity_mention',
|
||||
'confidence': len(files) / 10 # Simple confidence score
|
||||
})
|
||||
|
||||
return suggestions
|
||||
|
||||
def suggest_links_by_keywords(self):
|
||||
"""Suggest links based on keyword overlap."""
|
||||
suggestions = []
|
||||
|
||||
# Extract keywords from titles and content
|
||||
for file_path, note_data in self.notes.items():
|
||||
if note_data['word_count'] < 100: # Skip very short notes
|
||||
continue
|
||||
|
||||
# Get keywords from title
|
||||
title_words = set(re.findall(r'\b\w{4,}\b', note_data['title'].lower()))
|
||||
|
||||
# Find other notes with similar keywords
|
||||
for other_path, other_data in self.notes.items():
|
||||
if file_path == other_path:
|
||||
continue
|
||||
|
||||
other_title_words = set(re.findall(r'\b\w{4,}\b', other_data['title'].lower()))
|
||||
|
||||
# Check for keyword overlap
|
||||
common_words = title_words.intersection(other_title_words)
|
||||
if len(common_words) >= 2: # At least 2 common significant words
|
||||
|
||||
# Check if files don't already link to each other
|
||||
if (other_data['title'] not in note_data['existing_links'] and
|
||||
note_data['title'] not in other_data['existing_links']):
|
||||
|
||||
suggestions.append({
|
||||
'file1': file_path,
|
||||
'file2': other_path,
|
||||
'title1': note_data['title'],
|
||||
'title2': other_data['title'],
|
||||
'common_words': list(common_words),
|
||||
'type': 'keyword_overlap',
|
||||
'confidence': len(common_words) / 5
|
||||
})
|
||||
|
||||
return suggestions
|
||||
|
||||
def find_orphaned_notes(self):
|
||||
"""Find notes with no incoming or outgoing links."""
|
||||
orphaned = []
|
||||
|
||||
for file_path, note_data in self.notes.items():
|
||||
if len(note_data['existing_links']) == 0:
|
||||
# Check if any other notes link to this one
|
||||
mentioned_in = []
|
||||
for other_path, other_data in self.notes.items():
|
||||
if note_data['title'] in other_data['existing_links']:
|
||||
mentioned_in.append(other_path)
|
||||
|
||||
if not mentioned_in:
|
||||
orphaned.append({
|
||||
'file': file_path,
|
||||
'title': note_data['title'],
|
||||
'word_count': note_data['word_count']
|
||||
})
|
||||
|
||||
return orphaned
|
||||
|
||||
def analyze_vault(self):
|
||||
"""Perform complete analysis of the vault."""
|
||||
print("Loading notes...")
|
||||
self.load_notes()
|
||||
print(f"Loaded {len(self.notes)} notes")
|
||||
|
||||
print("Finding entity mentions...")
|
||||
self.find_entity_mentions()
|
||||
|
||||
print("Generating link suggestions...")
|
||||
entity_suggestions = self.suggest_links_by_entities()
|
||||
keyword_suggestions = self.suggest_links_by_keywords()
|
||||
orphaned_notes = self.find_orphaned_notes()
|
||||
|
||||
return {
|
||||
'entity_suggestions': entity_suggestions,
|
||||
'keyword_suggestions': keyword_suggestions,
|
||||
'orphaned_notes': orphaned_notes,
|
||||
'stats': {
|
||||
'total_notes': len(self.notes),
|
||||
'entity_suggestions': len(entity_suggestions),
|
||||
'keyword_suggestions': len(keyword_suggestions),
|
||||
'orphaned_notes': len(orphaned_notes)
|
||||
}
|
||||
}
|
||||
|
||||
def generate_report(self, results, output_file=None):
|
||||
"""Generate a human-readable report."""
|
||||
report = []
|
||||
|
||||
report.append("# Link Suggestions Report")
|
||||
report.append(f"Generated for vault: {self.vault_path}")
|
||||
report.append(f"Total notes analyzed: {results['stats']['total_notes']}")
|
||||
report.append("")
|
||||
|
||||
# Entity-based suggestions
|
||||
report.append("## Entity-Based Link Suggestions")
|
||||
report.append(f"Found {len(results['entity_suggestions'])} potential connections")
|
||||
report.append("")
|
||||
|
||||
# Group by entity
|
||||
entity_groups = defaultdict(list)
|
||||
for suggestion in results['entity_suggestions']:
|
||||
entity_groups[suggestion['common_entity']].append(suggestion)
|
||||
|
||||
for entity, suggestions in sorted(entity_groups.items()):
|
||||
report.append(f"### {entity.title()}")
|
||||
for suggestion in suggestions[:5]: # Top 5 per entity
|
||||
report.append(f"- [[{suggestion['title1']}]] ↔ [[{suggestion['title2']}]]")
|
||||
report.append("")
|
||||
|
||||
# Keyword-based suggestions
|
||||
report.append("## Keyword-Based Link Suggestions")
|
||||
report.append(f"Found {len(results['keyword_suggestions'])} potential connections")
|
||||
report.append("")
|
||||
|
||||
# Sort by confidence
|
||||
sorted_keywords = sorted(results['keyword_suggestions'],
|
||||
key=lambda x: x['confidence'], reverse=True)
|
||||
|
||||
for suggestion in sorted_keywords[:20]: # Top 20
|
||||
common_words = ', '.join(suggestion['common_words'])
|
||||
report.append(f"- [[{suggestion['title1']}]] ↔ [[{suggestion['title2']}]]")
|
||||
report.append(f" Common words: {common_words}")
|
||||
report.append("")
|
||||
|
||||
# Orphaned notes
|
||||
report.append("## Orphaned Notes (No Links)")
|
||||
report.append(f"Found {len(results['orphaned_notes'])} notes with no connections")
|
||||
report.append("")
|
||||
|
||||
# Sort by word count (longer notes first)
|
||||
sorted_orphaned = sorted(results['orphaned_notes'],
|
||||
key=lambda x: x['word_count'], reverse=True)
|
||||
|
||||
for note in sorted_orphaned[:30]: # Top 30
|
||||
report.append(f"- [[{note['title']}]] ({note['word_count']} words)")
|
||||
|
||||
report_text = '\n'.join(report)
|
||||
|
||||
if output_file:
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.write(report_text)
|
||||
print(f"Report saved to: {output_file}")
|
||||
|
||||
return report_text
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Suggest links for Obsidian vault')
|
||||
parser.add_argument('--vault', default='/Users/cam/VAULT01',
|
||||
help='Path to Obsidian vault')
|
||||
parser.add_argument('--output',
|
||||
default='/Users/cam/VAULT01/System_Files/Link_Suggestions_Report.md',
|
||||
help='Output file for report')
|
||||
parser.add_argument('--json',
|
||||
help='Output JSON file for programmatic use')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
suggester = LinkSuggester(args.vault)
|
||||
results = suggester.analyze_vault()
|
||||
|
||||
# Generate report
|
||||
report = suggester.generate_report(results, args.output)
|
||||
|
||||
# Save JSON if requested
|
||||
if args.json:
|
||||
with open(args.json, 'w') as f:
|
||||
json.dump(results, f, indent=2, default=str)
|
||||
print(f"JSON data saved to: {args.json}")
|
||||
|
||||
# Print summary
|
||||
print("\n" + "="*50)
|
||||
print("LINK SUGGESTIONS SUMMARY")
|
||||
print("="*50)
|
||||
print(f"Total notes: {results['stats']['total_notes']}")
|
||||
print(f"Entity-based suggestions: {results['stats']['entity_suggestions']}")
|
||||
print(f"Keyword-based suggestions: {results['stats']['keyword_suggestions']}")
|
||||
print(f"Orphaned notes: {results['stats']['orphaned_notes']}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Metadata Adder for Obsidian Vault
|
||||
Adds standardized frontmatter to markdown files that lack it.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
|
||||
class MetadataAdder:
|
||||
def __init__(self, vault_path):
|
||||
self.vault_path = Path(vault_path)
|
||||
self.stats = {
|
||||
'processed': 0,
|
||||
'updated': 0,
|
||||
'skipped': 0,
|
||||
'errors': 0
|
||||
}
|
||||
|
||||
def get_file_creation_date(self, file_path):
|
||||
"""Get file creation date from filesystem."""
|
||||
try:
|
||||
stat = os.stat(file_path)
|
||||
# Use birthtime on macOS, ctime on others
|
||||
timestamp = stat.st_birthtime if hasattr(stat, 'st_birthtime') else stat.st_ctime
|
||||
return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d')
|
||||
except:
|
||||
return datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
def determine_file_type(self, file_path):
|
||||
"""Determine the type of note based on path and content."""
|
||||
path_str = str(file_path).lower()
|
||||
|
||||
if 'moc' in path_str or 'map of content' in path_str:
|
||||
return 'map-of-content'
|
||||
elif 'daily notes' in path_str or 'daily note' in path_str:
|
||||
return 'daily'
|
||||
elif 'research' in path_str or 'articles' in path_str:
|
||||
return 'research'
|
||||
elif 'client' in path_str or 'camrohn llc' in path_str:
|
||||
return 'client-work'
|
||||
elif 'tutorial' in path_str or 'course' in path_str:
|
||||
return 'tutorial'
|
||||
elif 'idea' in path_str:
|
||||
return 'idea'
|
||||
elif 'meeting' in path_str:
|
||||
return 'meeting'
|
||||
elif 'email' in path_str:
|
||||
return 'email'
|
||||
else:
|
||||
return 'note'
|
||||
|
||||
def generate_tags_from_path(self, file_path):
|
||||
"""Generate tags based on file path."""
|
||||
tags = []
|
||||
path_parts = file_path.relative_to(self.vault_path).parts[:-1] # Exclude filename
|
||||
|
||||
# Map directory names to tags
|
||||
tag_mapping = {
|
||||
'ai development': 'ai/development',
|
||||
'ai articles': 'ai/research',
|
||||
'ai courses': 'tutorial/course',
|
||||
'ai ideas': 'idea',
|
||||
'camrohn llc': 'client',
|
||||
'daily notes': 'daily',
|
||||
'clippings': 'clippings',
|
||||
'mcp': 'mcp',
|
||||
'langchain': 'langchain',
|
||||
'graphrag': 'graphrag'
|
||||
}
|
||||
|
||||
for part in path_parts:
|
||||
part_lower = part.lower()
|
||||
for key, tag in tag_mapping.items():
|
||||
if key in part_lower:
|
||||
tags.append(tag)
|
||||
break
|
||||
|
||||
# Add date tags for daily notes
|
||||
if 'daily' in tags:
|
||||
created_date = self.get_file_creation_date(file_path)
|
||||
year_month = datetime.strptime(created_date, '%Y-%m-%d').strftime('%Y/%m')
|
||||
tags.append(f'daily/{year_month}')
|
||||
|
||||
return list(set(tags)) # Remove duplicates
|
||||
|
||||
def has_frontmatter(self, content):
|
||||
"""Check if content already has frontmatter."""
|
||||
return content.strip().startswith('---')
|
||||
|
||||
def create_frontmatter(self, file_path, existing_content):
|
||||
"""Create appropriate frontmatter for the file."""
|
||||
created_date = self.get_file_creation_date(file_path)
|
||||
file_type = self.determine_file_type(file_path)
|
||||
tags = self.generate_tags_from_path(file_path)
|
||||
|
||||
# Extract title from first heading or filename
|
||||
title_match = re.search(r'^#\s+(.+)$', existing_content, re.MULTILINE)
|
||||
if title_match:
|
||||
title = title_match.group(1)
|
||||
else:
|
||||
title = file_path.stem.replace('_', ' ').replace('-', ' ')
|
||||
|
||||
frontmatter = f"""---
|
||||
tags: {tags}
|
||||
type: {file_type}
|
||||
created: {created_date}
|
||||
modified: {datetime.now().strftime('%Y-%m-%d')}
|
||||
status: active
|
||||
related: []
|
||||
aliases: []
|
||||
---
|
||||
|
||||
"""
|
||||
|
||||
return frontmatter
|
||||
|
||||
def process_file(self, file_path):
|
||||
"""Process a single markdown file."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
if self.has_frontmatter(content):
|
||||
self.stats['skipped'] += 1
|
||||
return False
|
||||
|
||||
# Create and prepend frontmatter
|
||||
frontmatter = self.create_frontmatter(file_path, content)
|
||||
new_content = frontmatter + content
|
||||
|
||||
# Write back to file
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
|
||||
self.stats['updated'] += 1
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing {file_path}: {e}")
|
||||
self.stats['errors'] += 1
|
||||
return False
|
||||
|
||||
def process_vault(self, dry_run=False):
|
||||
"""Process all markdown files in the vault."""
|
||||
# Directories to skip
|
||||
skip_dirs = {'.obsidian', '.trash', 'System_Files', '.git'}
|
||||
|
||||
for file_path in self.vault_path.rglob('*.md'):
|
||||
# Skip files in excluded directories
|
||||
if any(skip_dir in file_path.parts for skip_dir in skip_dirs):
|
||||
continue
|
||||
|
||||
self.stats['processed'] += 1
|
||||
|
||||
if dry_run:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
if not self.has_frontmatter(content):
|
||||
print(f"Would update: {file_path.relative_to(self.vault_path)}")
|
||||
self.stats['updated'] += 1
|
||||
else:
|
||||
self.stats['skipped'] += 1
|
||||
else:
|
||||
if self.process_file(file_path):
|
||||
print(f"Updated: {file_path.relative_to(self.vault_path)}")
|
||||
|
||||
return self.stats
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Add metadata to Obsidian vault files')
|
||||
parser.add_argument('--vault', default='/Users/cam/VAULT01',
|
||||
help='Path to Obsidian vault')
|
||||
parser.add_argument('--dry-run', action='store_true',
|
||||
help='Show what would be updated without making changes')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
adder = MetadataAdder(args.vault)
|
||||
print(f"Processing vault at: {args.vault}")
|
||||
print("Dry run mode" if args.dry_run else "Making changes")
|
||||
print("-" * 50)
|
||||
|
||||
stats = adder.process_vault(dry_run=args.dry_run)
|
||||
|
||||
print("-" * 50)
|
||||
print(f"Files processed: {stats['processed']}")
|
||||
print(f"Files updated: {stats['updated']}")
|
||||
print(f"Files skipped (already have metadata): {stats['skipped']}")
|
||||
print(f"Errors: {stats['errors']}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MOC (Map of Content) Generator for Obsidian Vault
|
||||
Automatically generates MOCs for directories and topics.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
import argparse
|
||||
|
||||
class MOCGenerator:
|
||||
def __init__(self, vault_path):
|
||||
self.vault_path = Path(vault_path)
|
||||
self.directory_stats = {}
|
||||
self.topic_clusters = defaultdict(list)
|
||||
|
||||
def analyze_directory(self, directory_path):
|
||||
"""Analyze a directory and its contents."""
|
||||
stats = {
|
||||
'total_files': 0,
|
||||
'md_files': 0,
|
||||
'subdirectories': [],
|
||||
'file_types': defaultdict(int),
|
||||
'common_topics': defaultdict(int)
|
||||
}
|
||||
|
||||
try:
|
||||
for item in directory_path.iterdir():
|
||||
if item.is_file():
|
||||
stats['total_files'] += 1
|
||||
if item.suffix == '.md':
|
||||
stats['md_files'] += 1
|
||||
# Extract topics from filename
|
||||
topics = self.extract_topics_from_filename(item.name)
|
||||
for topic in topics:
|
||||
stats['common_topics'][topic] += 1
|
||||
stats['file_types'][item.suffix] += 1
|
||||
elif item.is_dir() and not item.name.startswith('.'):
|
||||
stats['subdirectories'].append(item.name)
|
||||
except PermissionError:
|
||||
print(f"Permission denied: {directory_path}")
|
||||
|
||||
return stats
|
||||
|
||||
def extract_topics_from_filename(self, filename):
|
||||
"""Extract potential topics from filename."""
|
||||
# Remove extension and common prefixes
|
||||
name = filename.replace('.md', '')
|
||||
name = re.sub(r'^\d{4}-\d{2}-\d{2}[_-]', '', name) # Remove date prefix
|
||||
name = re.sub(r'^MOC[_-]', '', name) # Remove MOC prefix
|
||||
|
||||
# Split on common separators and filter
|
||||
words = re.split(r'[_\-\s]+', name)
|
||||
topics = []
|
||||
|
||||
# Filter out common words and keep meaningful terms
|
||||
stop_words = {'and', 'the', 'for', 'with', 'to', 'of', 'in', 'on', 'at', 'by'}
|
||||
|
||||
for word in words:
|
||||
if len(word) > 2 and word.lower() not in stop_words:
|
||||
topics.append(word.lower())
|
||||
|
||||
return topics
|
||||
|
||||
def generate_moc_content(self, directory_path, title, description=""):
|
||||
"""Generate MOC content for a directory."""
|
||||
stats = self.analyze_directory(directory_path)
|
||||
|
||||
# Create frontmatter
|
||||
frontmatter = f"""---
|
||||
tags: [MOC, {directory_path.name.lower().replace(' ', '-')}]
|
||||
type: map-of-content
|
||||
created: {datetime.now().strftime('%Y-%m-%d')}
|
||||
modified: {datetime.now().strftime('%Y-%m-%d')}
|
||||
status: active
|
||||
cssclass: moc
|
||||
aliases: [{title} Hub, {title} Overview]
|
||||
hub_for: [{directory_path.name}]
|
||||
related_mocs: []
|
||||
---
|
||||
|
||||
"""
|
||||
|
||||
# Create content
|
||||
content = f"""# {title} Map of Content
|
||||
|
||||
## Overview
|
||||
{description if description else f"This MOC organizes all content related to {title.lower()}."}
|
||||
|
||||
**Directory**: `{directory_path.name}/`
|
||||
**Total Files**: {stats['md_files']} markdown files
|
||||
**Last Updated**: {datetime.now().strftime('%Y-%m-%d')}
|
||||
|
||||
"""
|
||||
|
||||
# Add subdirectories if any
|
||||
if stats['subdirectories']:
|
||||
content += "## Subdirectories\n\n"
|
||||
for subdir in sorted(stats['subdirectories']):
|
||||
content += f"### {subdir}\n"
|
||||
content += f"- [[MOC - {subdir}|{subdir} Overview]]\n\n"
|
||||
|
||||
# Organize files by topic
|
||||
content += self.organize_files_by_topic(directory_path, stats)
|
||||
|
||||
# Add common topics section
|
||||
if stats['common_topics']:
|
||||
content += "## Key Topics\n\n"
|
||||
sorted_topics = sorted(stats['common_topics'].items(), key=lambda x: x[1], reverse=True)
|
||||
for topic, count in sorted_topics[:10]: # Top 10 topics
|
||||
content += f"- **{topic.title()}** ({count} files)\n"
|
||||
content += "\n"
|
||||
|
||||
# Add templates and resources
|
||||
content += """## Related MOCs
|
||||
- [[MOC - AI Development|AI Development]]
|
||||
- [[MOC - Learning Resources|Learning Resources]]
|
||||
- [[Master Index|🗂️ Master Index]]
|
||||
|
||||
## Status & Progress
|
||||
- [ ] Organize files by topic
|
||||
- [ ] Add cross-references
|
||||
- [ ] Review and update links
|
||||
- [ ] Add examples and tutorials
|
||||
|
||||
## Next Steps
|
||||
- Review all files in this directory
|
||||
- Create sub-MOCs for large topic clusters
|
||||
- Add relevant tags to all files
|
||||
- Connect to related MOCs
|
||||
|
||||
---
|
||||
*This MOC was auto-generated. Please review and customize as needed.*
|
||||
"""
|
||||
|
||||
return frontmatter + content
|
||||
|
||||
def organize_files_by_topic(self, directory_path, stats):
|
||||
"""Organize files by topic clusters."""
|
||||
content = "## Content Organization\n\n"
|
||||
|
||||
# Group files by topic
|
||||
topic_files = defaultdict(list)
|
||||
ungrouped_files = []
|
||||
|
||||
try:
|
||||
for file_path in directory_path.glob('*.md'):
|
||||
if file_path.name.startswith('MOC'):
|
||||
continue # Skip existing MOCs
|
||||
|
||||
topics = self.extract_topics_from_filename(file_path.name)
|
||||
if topics:
|
||||
# Use first topic as primary grouping
|
||||
primary_topic = topics[0]
|
||||
topic_files[primary_topic].append(file_path)
|
||||
else:
|
||||
ungrouped_files.append(file_path)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error organizing files: {e}")
|
||||
return content
|
||||
|
||||
# Sort topics by number of files
|
||||
sorted_topics = sorted(topic_files.items(), key=lambda x: len(x[1]), reverse=True)
|
||||
|
||||
for topic, files in sorted_topics:
|
||||
if len(files) > 1: # Only create sections for topics with multiple files
|
||||
content += f"### {topic.title()}\n"
|
||||
for file_path in sorted(files):
|
||||
title = file_path.stem.replace('_', ' ').replace('-', ' ')
|
||||
content += f"- [[{title}]]\n"
|
||||
content += "\n"
|
||||
|
||||
# Add ungrouped files
|
||||
if ungrouped_files:
|
||||
content += "### Other Files\n"
|
||||
for file_path in sorted(ungrouped_files):
|
||||
title = file_path.stem.replace('_', ' ').replace('-', ' ')
|
||||
content += f"- [[{title}]]\n"
|
||||
content += "\n"
|
||||
|
||||
return content
|
||||
|
||||
def create_moc_file(self, directory_path, title, description="", output_path=None):
|
||||
"""Create a MOC file for a directory."""
|
||||
content = self.generate_moc_content(directory_path, title, description)
|
||||
|
||||
if output_path is None:
|
||||
# Create MOCs in the centralized map-of-content directory
|
||||
moc_dir = self.vault_path / "map-of-content"
|
||||
moc_dir.mkdir(exist_ok=True)
|
||||
output_path = moc_dir / f"MOC - {title}.md"
|
||||
|
||||
# Check if file already exists
|
||||
if output_path.exists():
|
||||
print(f"MOC already exists: {output_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print(f"Created MOC: {output_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error creating MOC: {e}")
|
||||
return False
|
||||
|
||||
def suggest_mocs(self):
|
||||
"""Suggest MOCs for directories that don't have them."""
|
||||
suggestions = []
|
||||
|
||||
# Skip these directories
|
||||
skip_dirs = {'.obsidian', '.trash', 'System_Files', '.git'}
|
||||
|
||||
for directory in self.vault_path.iterdir():
|
||||
if not directory.is_dir() or directory.name in skip_dirs:
|
||||
continue
|
||||
|
||||
stats = self.analyze_directory(directory)
|
||||
|
||||
# Check if directory has enough content to warrant a MOC
|
||||
if stats['md_files'] >= 3:
|
||||
# Check if MOC already exists
|
||||
existing_mocs = list(directory.glob('MOC*.md'))
|
||||
if not existing_mocs:
|
||||
suggestions.append({
|
||||
'directory': directory,
|
||||
'title': directory.name,
|
||||
'file_count': stats['md_files'],
|
||||
'subdirs': len(stats['subdirectories']),
|
||||
'top_topics': sorted(stats['common_topics'].items(),
|
||||
key=lambda x: x[1], reverse=True)[:5]
|
||||
})
|
||||
|
||||
return suggestions
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Generate MOCs for Obsidian vault')
|
||||
parser.add_argument('--vault', default='/Users/cam/VAULT01',
|
||||
help='Path to Obsidian vault')
|
||||
parser.add_argument('--directory',
|
||||
help='Specific directory to create MOC for')
|
||||
parser.add_argument('--title',
|
||||
help='Title for the MOC')
|
||||
parser.add_argument('--description',
|
||||
help='Description for the MOC')
|
||||
parser.add_argument('--suggest', action='store_true',
|
||||
help='Suggest directories that need MOCs')
|
||||
parser.add_argument('--create-all', action='store_true',
|
||||
help='Create MOCs for all suggested directories')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
generator = MOCGenerator(args.vault)
|
||||
|
||||
if args.suggest or args.create_all:
|
||||
suggestions = generator.suggest_mocs()
|
||||
|
||||
if args.suggest:
|
||||
print("MOC Suggestions:")
|
||||
print("="*50)
|
||||
for suggestion in suggestions:
|
||||
print(f"Directory: {suggestion['directory'].name}")
|
||||
print(f" Files: {suggestion['file_count']}")
|
||||
print(f" Subdirs: {suggestion['subdirs']}")
|
||||
if suggestion['top_topics']:
|
||||
topics = [f"{topic} ({count})" for topic, count in suggestion['top_topics']]
|
||||
print(f" Topics: {', '.join(topics)}")
|
||||
print()
|
||||
|
||||
if args.create_all:
|
||||
for suggestion in suggestions:
|
||||
generator.create_moc_file(
|
||||
suggestion['directory'],
|
||||
suggestion['title']
|
||||
)
|
||||
|
||||
elif args.directory:
|
||||
directory_path = Path(args.vault) / args.directory
|
||||
if not directory_path.exists():
|
||||
print(f"Directory not found: {directory_path}")
|
||||
return
|
||||
|
||||
title = args.title or directory_path.name
|
||||
description = args.description or ""
|
||||
|
||||
generator.create_moc_file(directory_path, title, description)
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse keyword connections from Link Suggestions Report."""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def parse_keyword_connections(report_path):
|
||||
"""Parse keyword connections from the report and find meaningful ones."""
|
||||
|
||||
with open(report_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Find the keyword-based section
|
||||
keyword_section_start = content.find("## Keyword-Based Link Suggestions")
|
||||
if keyword_section_start == -1:
|
||||
print("Could not find keyword-based section")
|
||||
return []
|
||||
|
||||
# Find the next section (orphaned notes)
|
||||
orphaned_section_start = content.find("## Orphaned Notes", keyword_section_start)
|
||||
if orphaned_section_start == -1:
|
||||
keyword_section = content[keyword_section_start:]
|
||||
else:
|
||||
keyword_section = content[keyword_section_start:orphaned_section_start]
|
||||
|
||||
# Pattern to match connections
|
||||
pattern = r'- \[\[([^\]]+)\]\] ↔ \[\[([^\]]+)\]\]\s*\n\s*Common words: ([^\n]+)'
|
||||
|
||||
connections = []
|
||||
for match in re.finditer(pattern, keyword_section):
|
||||
file1 = match.group(1).strip()
|
||||
file2 = match.group(2).strip()
|
||||
keywords = match.group(3).strip()
|
||||
|
||||
# Skip self-connections
|
||||
if file1 == file2:
|
||||
continue
|
||||
|
||||
# Count keywords
|
||||
keyword_list = [k.strip() for k in keywords.split(',')]
|
||||
keyword_count = len(keyword_list)
|
||||
|
||||
# Only include connections with 5+ keywords
|
||||
if keyword_count >= 5:
|
||||
connections.append({
|
||||
'file1': file1,
|
||||
'file2': file2,
|
||||
'keywords': keyword_list,
|
||||
'count': keyword_count
|
||||
})
|
||||
|
||||
# Sort by keyword count descending
|
||||
connections.sort(key=lambda x: x['count'], reverse=True)
|
||||
|
||||
return connections
|
||||
|
||||
def main():
|
||||
report_path = Path("/Users/cam/VAULT01/System_Files/Link_Suggestions_Report.md")
|
||||
|
||||
connections = parse_keyword_connections(report_path)
|
||||
|
||||
print(f"Found {len(connections)} meaningful keyword connections (5+ keywords)\n")
|
||||
|
||||
# Group by keyword themes
|
||||
tech_keywords = {'llm', 'langchain', 'langgraph', 'rag', 'embedding', 'vector', 'agent', 'model', 'api', 'mcp'}
|
||||
framework_keywords = {'langchain', 'langgraph', 'fastapi', 'docker', 'cloudflare', 'supabase'}
|
||||
company_keywords = {'openai', 'anthropic', 'google', 'microsoft', 'meta'}
|
||||
concept_keywords = {'automation', 'workflow', 'pipeline', 'integration', 'generation', 'retrieval'}
|
||||
|
||||
tech_connections = []
|
||||
framework_connections = []
|
||||
company_connections = []
|
||||
concept_connections = []
|
||||
|
||||
for conn in connections:
|
||||
keywords_lower = [k.lower() for k in conn['keywords']]
|
||||
|
||||
tech_score = len([k for k in keywords_lower if any(tech in k for tech in tech_keywords)])
|
||||
framework_score = len([k for k in keywords_lower if any(fw in k for fw in framework_keywords)])
|
||||
company_score = len([k for k in keywords_lower if any(comp in k for comp in company_keywords)])
|
||||
concept_score = len([k for k in keywords_lower if any(conc in k for conc in concept_keywords)])
|
||||
|
||||
if tech_score >= 2:
|
||||
tech_connections.append(conn)
|
||||
if framework_score >= 2:
|
||||
framework_connections.append(conn)
|
||||
if company_score >= 1:
|
||||
company_connections.append(conn)
|
||||
if concept_score >= 2:
|
||||
concept_connections.append(conn)
|
||||
|
||||
print("## High-Priority Technical Connections")
|
||||
print(f"Found {len(tech_connections)} connections with technical keywords\n")
|
||||
for i, conn in enumerate(tech_connections[:20]): # Top 20
|
||||
print(f"{i+1}. [[{conn['file1']}]] ↔ [[{conn['file2']}]]")
|
||||
print(f" Keywords ({conn['count']}): {', '.join(conn['keywords'][:10])}")
|
||||
print()
|
||||
|
||||
print("\n## Framework-Related Connections")
|
||||
print(f"Found {len(framework_connections)} connections with framework keywords\n")
|
||||
for i, conn in enumerate(framework_connections[:15]): # Top 15
|
||||
print(f"{i+1}. [[{conn['file1']}]] ↔ [[{conn['file2']}]]")
|
||||
print(f" Keywords ({conn['count']}): {', '.join(conn['keywords'][:10])}")
|
||||
print()
|
||||
|
||||
print("\n## Company/Provider Connections")
|
||||
print(f"Found {len(company_connections)} connections with company keywords\n")
|
||||
for i, conn in enumerate(company_connections[:10]): # Top 10
|
||||
print(f"{i+1}. [[{conn['file1']}]] ↔ [[{conn['file2']}]]")
|
||||
print(f" Keywords ({conn['count']}): {', '.join(conn['keywords'][:10])}")
|
||||
print()
|
||||
|
||||
print("\n## Concept/Workflow Connections")
|
||||
print(f"Found {len(concept_connections)} connections with concept keywords\n")
|
||||
for i, conn in enumerate(concept_connections[:10]): # Top 10
|
||||
print(f"{i+1}. [[{conn['file1']}]] ↔ [[{conn['file2']}]]")
|
||||
print(f" Keywords ({conn['count']}): {', '.join(conn['keywords'][:10])}")
|
||||
print()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tag Standardizer for Obsidian Vault
|
||||
Normalizes and standardizes tags across all notes.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from collections import defaultdict, Counter
|
||||
import argparse
|
||||
|
||||
class TagStandardizer:
|
||||
def __init__(self, vault_path):
|
||||
self.vault_path = Path(vault_path)
|
||||
self.tag_mappings = {}
|
||||
self.tag_stats = Counter()
|
||||
self.files_processed = 0
|
||||
self.files_updated = 0
|
||||
|
||||
# Define standard tag mappings
|
||||
self.standard_mappings = {
|
||||
# Color codes to semantic tags
|
||||
'#F0EEE6': 'clippings',
|
||||
'#e0e0e0': 'reference',
|
||||
'#f0f0f0': 'note',
|
||||
|
||||
# Technology standardization
|
||||
'langchain': 'langchain',
|
||||
'lang-chain': 'langchain',
|
||||
'LangChain': 'langchain',
|
||||
'langgraph': 'langgraph',
|
||||
'lang-graph': 'langgraph',
|
||||
'LangGraph': 'langgraph',
|
||||
'mcp': 'mcp',
|
||||
'MCP': 'mcp',
|
||||
'model-context-protocol': 'mcp',
|
||||
'Model Context Protocol': 'mcp',
|
||||
'graphrag': 'graphrag',
|
||||
'GraphRAG': 'graphrag',
|
||||
'graph-rag': 'graphrag',
|
||||
'openai': 'openai',
|
||||
'OpenAI': 'openai',
|
||||
'anthropic': 'anthropic',
|
||||
'Anthropic': 'anthropic',
|
||||
'claude': 'anthropic',
|
||||
'Claude': 'anthropic',
|
||||
'llm': 'ai/llm',
|
||||
'LLM': 'ai/llm',
|
||||
'ai-agents': 'ai/agents',
|
||||
'AI Agents': 'ai/agents',
|
||||
'embeddings': 'ai/embeddings',
|
||||
'vector-db': 'ai/embeddings',
|
||||
'rag': 'ai/embeddings',
|
||||
'RAG': 'ai/embeddings',
|
||||
|
||||
# Common case standardizations
|
||||
'MOC': 'moc',
|
||||
'API': 'api',
|
||||
'RSS': 'rss',
|
||||
'UI': 'ui',
|
||||
'AI': 'ai',
|
||||
|
||||
# Content type standardization
|
||||
'research': 'research',
|
||||
'Research': 'research',
|
||||
'tutorial': 'tutorial',
|
||||
'Tutorial': 'tutorial',
|
||||
'how-to': 'tutorial',
|
||||
'guide': 'tutorial',
|
||||
'reference': 'reference',
|
||||
'Reference': 'reference',
|
||||
'docs': 'reference',
|
||||
'documentation': 'reference',
|
||||
'idea': 'idea',
|
||||
'Idea': 'idea',
|
||||
'ideas': 'idea',
|
||||
'brainstorm': 'idea',
|
||||
'concept': 'idea',
|
||||
'meeting': 'meeting',
|
||||
'Meeting': 'meeting',
|
||||
'notes': 'meeting',
|
||||
'email': 'email',
|
||||
'Email': 'email',
|
||||
'correspondence': 'email',
|
||||
'daily': 'daily',
|
||||
'Daily': 'daily',
|
||||
'journal': 'daily',
|
||||
'Journal': 'daily',
|
||||
'log': 'daily',
|
||||
|
||||
# Business tags
|
||||
'client': 'client',
|
||||
'Client': 'client',
|
||||
'business': 'business',
|
||||
'Business': 'business',
|
||||
'startup': 'startup',
|
||||
'Startup': 'startup',
|
||||
'freelance': 'freelance',
|
||||
'Freelance': 'freelance',
|
||||
'project': 'project',
|
||||
'Project': 'project',
|
||||
|
||||
# Status tags
|
||||
'active': 'status/active',
|
||||
'Active': 'status/active',
|
||||
'draft': 'status/draft',
|
||||
'Draft': 'status/draft',
|
||||
'completed': 'status/completed',
|
||||
'Completed': 'status/completed',
|
||||
'archived': 'status/archived',
|
||||
'Archived': 'status/archived',
|
||||
'todo': 'action/todo',
|
||||
'TODO': 'action/todo',
|
||||
'follow-up': 'action/follow-up',
|
||||
'Follow-up': 'action/follow-up',
|
||||
|
||||
# Learning tags
|
||||
'course': 'learning/course',
|
||||
'Course': 'learning/course',
|
||||
'certification': 'learning/certification',
|
||||
'Certification': 'learning/certification',
|
||||
'book': 'learning/book',
|
||||
'Book': 'learning/book',
|
||||
'video': 'learning/video',
|
||||
'Video': 'learning/video',
|
||||
'podcast': 'learning/podcast',
|
||||
'Podcast': 'learning/podcast',
|
||||
'conference': 'learning/conference',
|
||||
'Conference': 'learning/conference',
|
||||
'webinar': 'learning/webinar',
|
||||
'Webinar': 'learning/webinar'
|
||||
}
|
||||
|
||||
def extract_frontmatter(self, content):
|
||||
"""Extract YAML frontmatter from content."""
|
||||
if not content.strip().startswith('---'):
|
||||
return None, content
|
||||
|
||||
# Find the end of frontmatter
|
||||
lines = content.split('\n')
|
||||
if len(lines) < 3:
|
||||
return None, content
|
||||
|
||||
end_idx = None
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == '---':
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if end_idx is None:
|
||||
return None, content
|
||||
|
||||
try:
|
||||
frontmatter_text = '\n'.join(lines[1:end_idx])
|
||||
frontmatter = yaml.safe_load(frontmatter_text)
|
||||
remaining_content = '\n'.join(lines[end_idx + 1:])
|
||||
return frontmatter, remaining_content
|
||||
except yaml.YAMLError:
|
||||
return None, content
|
||||
|
||||
def normalize_tag(self, tag):
|
||||
"""Normalize a single tag."""
|
||||
# Remove leading/trailing whitespace
|
||||
tag = tag.strip()
|
||||
|
||||
# Remove hash if present
|
||||
if tag.startswith('#'):
|
||||
tag = tag[1:]
|
||||
|
||||
# Apply standard mappings
|
||||
if tag in self.standard_mappings:
|
||||
return self.standard_mappings[tag]
|
||||
|
||||
# Handle date-based tags
|
||||
if re.match(r'\d{4}/\d{2}', tag):
|
||||
return f'daily/{tag}'
|
||||
|
||||
# Handle file paths as tags
|
||||
if '/' in tag and not tag.startswith(('ai/', 'client/', 'learning/', 'status/', 'action/')):
|
||||
# Convert path-like tags to hierarchical tags
|
||||
parts = tag.split('/')
|
||||
if len(parts) >= 2:
|
||||
category = parts[0].lower()
|
||||
if category in ['ai', 'client', 'learning', 'business', 'project']:
|
||||
return f"{category}/{'/'.join(parts[1:]).lower()}"
|
||||
|
||||
# Default normalization
|
||||
return tag.lower().replace(' ', '-')
|
||||
|
||||
def standardize_tags(self, tags):
|
||||
"""Standardize a list of tags."""
|
||||
if not tags:
|
||||
return []
|
||||
|
||||
# Handle different tag formats
|
||||
if isinstance(tags, str):
|
||||
# Single tag as string
|
||||
return [self.normalize_tag(tags)]
|
||||
|
||||
if isinstance(tags, list):
|
||||
standardized = []
|
||||
for tag in tags:
|
||||
if isinstance(tag, str):
|
||||
normalized = self.normalize_tag(tag)
|
||||
if normalized and normalized not in standardized:
|
||||
standardized.append(normalized)
|
||||
self.tag_stats[normalized] += 1
|
||||
return standardized
|
||||
|
||||
return []
|
||||
|
||||
def process_file(self, file_path, dry_run=False):
|
||||
"""Process a single file and standardize its tags."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
frontmatter, remaining_content = self.extract_frontmatter(content)
|
||||
|
||||
if frontmatter is None:
|
||||
return False # No frontmatter to process
|
||||
|
||||
# Get current tags
|
||||
current_tags = frontmatter.get('tags', [])
|
||||
|
||||
# Standardize tags
|
||||
standardized_tags = self.standardize_tags(current_tags)
|
||||
|
||||
# Check if tags changed
|
||||
if standardized_tags != current_tags:
|
||||
if not dry_run:
|
||||
# Update frontmatter
|
||||
frontmatter['tags'] = standardized_tags
|
||||
|
||||
# Reconstruct content
|
||||
new_frontmatter = yaml.dump(frontmatter, default_flow_style=False, sort_keys=False)
|
||||
new_content = f"---\n{new_frontmatter}---\n{remaining_content}"
|
||||
|
||||
# Write back to file
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
|
||||
return True # File was updated
|
||||
|
||||
return False # No changes needed
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def analyze_existing_tags(self):
|
||||
"""Analyze existing tags in the vault."""
|
||||
tag_files = defaultdict(list)
|
||||
|
||||
for file_path in self.vault_path.rglob('*.md'):
|
||||
if any(skip_dir in file_path.parts for skip_dir in {'.obsidian', '.trash', 'System_Files', '.git'}):
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
frontmatter, _ = self.extract_frontmatter(content)
|
||||
|
||||
if frontmatter and 'tags' in frontmatter:
|
||||
tags = frontmatter['tags']
|
||||
if isinstance(tags, list):
|
||||
for tag in tags:
|
||||
if isinstance(tag, str):
|
||||
tag_files[tag].append(file_path)
|
||||
elif isinstance(tags, str):
|
||||
tag_files[tags].append(file_path)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error analyzing {file_path}: {e}")
|
||||
|
||||
return tag_files
|
||||
|
||||
def generate_tag_report(self):
|
||||
"""Generate a report of current tags and suggested changes."""
|
||||
print("Analyzing existing tags...")
|
||||
tag_files = self.analyze_existing_tags()
|
||||
|
||||
report = []
|
||||
report.append("# Tag Standardization Report")
|
||||
report.append(f"Generated for vault: {self.vault_path}")
|
||||
report.append(f"Total unique tags: {len(tag_files)}")
|
||||
report.append("")
|
||||
|
||||
# Group tags by frequency
|
||||
tag_frequency = [(tag, len(files)) for tag, files in tag_files.items()]
|
||||
tag_frequency.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
report.append("## Current Tags by Frequency")
|
||||
for tag, count in tag_frequency:
|
||||
normalized = self.normalize_tag(tag)
|
||||
if normalized != tag:
|
||||
report.append(f"- `{tag}` ({count} files) → `{normalized}`")
|
||||
else:
|
||||
report.append(f"- `{tag}` ({count} files)")
|
||||
report.append("")
|
||||
|
||||
# Suggest consolidations
|
||||
report.append("## Suggested Consolidations")
|
||||
consolidations = defaultdict(list)
|
||||
|
||||
for tag, files in tag_files.items():
|
||||
normalized = self.normalize_tag(tag)
|
||||
if normalized != tag:
|
||||
consolidations[normalized].append((tag, len(files)))
|
||||
|
||||
for normalized_tag, original_tags in consolidations.items():
|
||||
if len(original_tags) > 1:
|
||||
report.append(f"### {normalized_tag}")
|
||||
total_files = sum(count for _, count in original_tags)
|
||||
report.append(f"Total files: {total_files}")
|
||||
for original, count in original_tags:
|
||||
report.append(f"- `{original}` ({count} files)")
|
||||
report.append("")
|
||||
|
||||
return '\n'.join(report)
|
||||
|
||||
def process_vault(self, dry_run=False):
|
||||
"""Process all files in the vault."""
|
||||
skip_dirs = {'.obsidian', '.trash', 'System_Files', '.git'}
|
||||
|
||||
for file_path in self.vault_path.rglob('*.md'):
|
||||
if any(skip_dir in file_path.parts for skip_dir in skip_dirs):
|
||||
continue
|
||||
|
||||
self.files_processed += 1
|
||||
|
||||
if self.process_file(file_path, dry_run):
|
||||
self.files_updated += 1
|
||||
if not dry_run:
|
||||
print(f"Updated: {file_path.relative_to(self.vault_path)}")
|
||||
|
||||
return {
|
||||
'processed': self.files_processed,
|
||||
'updated': self.files_updated,
|
||||
'tag_stats': dict(self.tag_stats)
|
||||
}
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Standardize tags in Obsidian vault')
|
||||
parser.add_argument('--vault', default='/Users/cam/VAULT01',
|
||||
help='Path to Obsidian vault')
|
||||
parser.add_argument('--dry-run', action='store_true',
|
||||
help='Show what would be changed without making changes')
|
||||
parser.add_argument('--report', action='store_true',
|
||||
help='Generate analysis report of current tags')
|
||||
parser.add_argument('--output',
|
||||
default='/Users/cam/VAULT01/System_Files/Tag_Analysis_Report.md',
|
||||
help='Output file for report')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
standardizer = TagStandardizer(args.vault)
|
||||
|
||||
if args.report:
|
||||
report = standardizer.generate_tag_report()
|
||||
|
||||
with open(args.output, 'w', encoding='utf-8') as f:
|
||||
f.write(report)
|
||||
|
||||
print(f"Tag analysis report saved to: {args.output}")
|
||||
|
||||
else:
|
||||
print(f"Processing vault at: {args.vault}")
|
||||
print("Dry run mode" if args.dry_run else "Making changes")
|
||||
print("-" * 50)
|
||||
|
||||
results = standardizer.process_vault(dry_run=args.dry_run)
|
||||
|
||||
print("-" * 50)
|
||||
print(f"Files processed: {results['processed']}")
|
||||
print(f"Files updated: {results['updated']}")
|
||||
|
||||
if results['tag_stats']:
|
||||
print("\nTop standardized tags:")
|
||||
for tag, count in Counter(results['tag_stats']).most_common(10):
|
||||
print(f" {tag}: {count}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user