""" Configuration management for Obsidian KB Skill. """ import os import yaml from pathlib import Path from typing import Optional, Dict, Any # Global config instance _config_instance: Optional['Config'] = None def get_config(config_path: Optional[str] = None) -> 'Config': """Get global config instance.""" global _config_instance if _config_instance is None or config_path: _config_instance = Config(config_path) return _config_instance class Config: """Configuration manager.""" DEFAULT_CONFIG = { 'ai': { 'provider': 'openai', 'model': 'gpt-4', 'api_key': None, }, 'storage': { 'inbox_dir': 'inbox', 'topics_dir': 'topics', 'data_dir': 'data', }, 'git': { 'enabled': False, 'vault_path': '~/Documents/Obsidian/KB', 'remote': 'origin', 'branch': 'main', 'auto_push': False, 'commit_message_template': 'Add note: {title}', }, 'obsidian': { 'vault_path': '~/Documents/Obsidian/KB', 'notes_folder': 'Notes', }, 'chunking': { 'enabled': True, 'chunk_duration_seconds': 900, # 15 minutes 'max_chunk_duration_seconds': 1800, # 30 minutes 'long_video_threshold': 1800, # 30 minutes }, 'output': { 'organize_by': 'author', # author | date | topic 'filename_format': '{date}_{title}.md', 'auto_tags': True, 'default_tags': ['video', 'bilibili'], }, 'sources': { 'bilibili': { 'enabled': True, 'cookie': None, 'extraction': { 'headless': True, 'timeout': 30, }, }, 'youtube': { 'enabled': False, }, 'zhihu': { 'enabled': False, }, }, } def __init__(self, config_path: Optional[str] = None, load_env: bool = True): """ Initialize configuration. Args: config_path: Path to config file. If None, uses default locations. load_env: Whether to load environment variable overrides. """ self.config_path = config_path or self._find_config_file() self._config = self._load_config(load_env) def _find_config_file(self) -> str: """Find configuration file in standard locations.""" search_paths = [ Path.cwd() / 'config.yaml', Path.cwd() / 'config.yml', Path.home() / '.obsidian-kb' / 'config.yaml', Path.home() / '.config' / 'obsidian-kb' / 'config.yaml', ] for path in search_paths: if path.exists(): return str(path) # Return default path return str(Path.cwd() / 'config.yaml') def _load_config(self, load_env: bool = True) -> Dict[str, Any]: """Load configuration from file or use defaults.""" config = self.DEFAULT_CONFIG.copy() if Path(self.config_path).exists(): with open(self.config_path, 'r', encoding='utf-8') as f: user_config = yaml.safe_load(f) if user_config: self._deep_update(config, user_config) # Override with environment variables if load_env: self._apply_env_overrides(config) return config def _deep_update(self, base: Dict, update: Dict) -> None: """Deep update dictionary.""" for key, value in update.items(): if key in base and isinstance(base[key], dict) and isinstance(value, dict): self._deep_update(base[key], value) else: base[key] = value def _apply_env_overrides(self, config: Dict[str, Any]) -> None: """Apply environment variable overrides.""" # AI settings if os.getenv('OPENAI_API_KEY'): config['ai']['api_key'] = os.getenv('OPENAI_API_KEY') if os.getenv('AI_MODEL'): config['ai']['model'] = os.getenv('AI_MODEL') # Git settings if os.getenv('OBSIDIAN_VAULT_PATH'): config['git']['vault_path'] = os.getenv('OBSIDIAN_VAULT_PATH') # Bilibili cookie if os.getenv('BILIBILI_COOKIE'): config['sources']['bilibili']['cookie'] = os.getenv('BILIBILI_COOKIE') def get(self, key: str, default: Any = None) -> Any: """ Get configuration value using dot notation. Args: key: Key in dot notation (e.g., 'ai.model') default: Default value if key not found Returns: Configuration value """ keys = key.split('.') value = self._config for k in keys: if isinstance(value, dict) and k in value: value = value[k] else: return default return value def set(self, key: str, value: Any) -> None: """ Set configuration value using dot notation. Args: key: Key in dot notation value: Value to set """ keys = key.split('.') config = self._config for k in keys[:-1]: if k not in config: config[k] = {} config = config[k] config[keys[-1]] = value def save(self) -> None: """Save configuration to file.""" Path(self.config_path).parent.mkdir(parents=True, exist_ok=True) with open(self.config_path, 'w', encoding='utf-8') as f: yaml.dump(self._config, f, default_flow_style=False, allow_unicode=True) @property def obsidian_path(self) -> str: """Get obsidian path (alias for vault_path).""" return str(self.vault_path) @property def vault_path(self) -> Path: """Get Obsidian vault path.""" path = self.get('obsidian.vault_path') or self.get('git.vault_path', '~/Documents/Obsidian/KB') return Path(path).expanduser() @property def notes_folder(self) -> str: """Get notes folder.""" return self.get('obsidian.notes_folder', 'Notes') @property def ai_api_key(self) -> Optional[str]: """Get AI API key.""" return self.get('ai.api_key') @property def ai_model(self) -> str: """Get AI model.""" return self.get('ai.model', 'gpt-4') @property def chunking_enabled(self) -> bool: """Check if chunking is enabled.""" return self.get('chunking.enabled', True) @property def chunk_duration(self) -> int: """Get chunk duration in seconds.""" return self.get('chunking.chunk_duration_seconds', 900) @property def long_video_threshold(self) -> int: """Get long video threshold in seconds.""" return self.get('chunking.long_video_threshold', 1800) @property def quality_threshold(self) -> float: """Get quality threshold.""" return self.get('quality_threshold', 0.7) @property def obsidian(self) -> 'Config': """Get obsidian config section (returns self for compatibility).""" return self @property def git(self) -> 'Config': """Get git config section (returns self for compatibility).""" return self @property def enabled(self) -> bool: """Check if git is enabled.""" return self.get('git.enabled', False) @property def auto_push(self) -> bool: """Check if auto push is enabled.""" return self.get('git.auto_push', False) @property def commit_message_template(self) -> str: """Get commit message template.""" return self.get('git.commit_message_template', 'Add note: {title}') @property def kimi_api_key(self) -> str: """Get Kimi API key (alias for ai.api_key).""" return self.get('ai.api_key') or '' # Output configuration @property def organize_by(self) -> str: """Get output organize_by strategy.""" return self.get('output.organize_by', 'author') @property def filename_format(self) -> str: """Get output filename format.""" return self.get('output.filename_format', '{date}_{title}.md') @property def auto_tags(self) -> bool: """Get auto_tags setting.""" return self.get('output.auto_tags', True) @property def default_tags(self) -> list: """Get default tags list.""" return self.get('output.default_tags', ['video', 'bilibili']) # Bilibili extraction configuration @property def bilibili_headless(self) -> bool: """Get bilibili extraction headless mode.""" return self.get('sources.bilibili.extraction.headless', True) @property def bilibili_timeout(self) -> int: """Get bilibili extraction timeout in seconds.""" return self.get('sources.bilibili.extraction.timeout', 30) # Zhihu DrissionPage configuration @property def zhihu_enabled(self) -> bool: """知乎是否启用""" return self.get('sources.zhihu.enabled', False) @property def zhihu_use_drissionpage(self) -> bool: """知乎是否使用 DrissionPage""" return self.get('sources.zhihu.use_drissionpage', True) @property def zhihu_cookie_file(self) -> Optional[str]: """知乎 Cookie 文件路径""" return self.get('sources.zhihu.cookie_file') @property def zhihu_cookie_str(self) -> Optional[str]: """知乎 Cookie 字符串""" return self.get('sources.zhihu.cookie_str') @property def zhihu_timeout(self) -> int: """知乎提取超时时间(秒)""" return self.get('sources.zhihu.timeout', 30) @property def zhihu_headless(self) -> bool: """知乎是否使用无头浏览器""" return self.get('sources.zhihu.headless', True) def ensure_dirs(self) -> None: """Ensure required directories exist.""" self.vault_path.mkdir(parents=True, exist_ok=True) (self.vault_path / self.get('storage.inbox_dir', 'Inbox')).mkdir(exist_ok=True) (self.vault_path / self.get('storage.topics_dir', 'Topics')).mkdir(exist_ok=True) def validate(self) -> tuple[bool, list[str]]: """Validate configuration.""" errors = [] if not self.ai_api_key: errors.append("AI API key not configured") return len(errors) == 0, errors # Subscriptions support def get_subscriptions(self) -> list: """Get subscriptions list.""" return self.get('subscriptions', []) def add_subscription(self, subscription: Dict[str, Any]) -> None: """Add a new subscription.""" subs = self.get('subscriptions', []) subs.append(subscription) self.set('subscriptions', subs) self.save()