feat: Implement foundation layer with domain entities and repository interfaces
- Add complete domain layer: Note, Vault, WikiLink, Tag, Frontmatter, Graph entities - Implement repository interfaces for data access abstraction - Create comprehensive configuration system with YAML and env support - Add CLI entry point with signal handling and graceful shutdown - Fix mermaid diagram syntax in design.md (array notation) - Add CLAUDE.md for development guidance 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/obsidian-mcp.iml" filepath="$PROJECT_DIR$/.idea/obsidian-mcp.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
9
.idea/obsidian-mcp.iml
generated
Normal file
9
.idea/obsidian-mcp.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="Go" enabled="true" />
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
134
CLAUDE.md
Normal file
134
CLAUDE.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Building
|
||||||
|
```bash
|
||||||
|
go build -o obsidian-mcp ./cmd/obsidian-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
go test -cover ./...
|
||||||
|
|
||||||
|
# Run tests for specific package
|
||||||
|
go test ./internal/domain/
|
||||||
|
|
||||||
|
# Run specific test
|
||||||
|
go test -run TestNoteName ./internal/domain/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running
|
||||||
|
```bash
|
||||||
|
# Basic run with vault path
|
||||||
|
./obsidian-mcp --vault /path/to/vault
|
||||||
|
|
||||||
|
# With config file
|
||||||
|
./obsidian-mcp --config config.yaml
|
||||||
|
|
||||||
|
# With environment variables
|
||||||
|
VAULT_PATH=/path/to/vault LOG_LEVEL=debug ./obsidian-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
```bash
|
||||||
|
# Download dependencies
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
# Update dependencies
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
# Add new dependency
|
||||||
|
go get github.com/package/name
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
This is an Obsidian MCP (Model Context Protocol) Server implementing **Clean Architecture** with strict dependency inversion. The codebase follows Domain-Driven Design principles.
|
||||||
|
|
||||||
|
### Layer Dependencies (Dependency Rule)
|
||||||
|
All dependencies point inward toward the domain:
|
||||||
|
- **Domain Layer** (innermost): Pure business logic, no external dependencies
|
||||||
|
- **Repository Layer**: Interfaces defining data access contracts
|
||||||
|
- **Use Case Layer**: Business orchestration, depends only on domain + repository interfaces
|
||||||
|
- **Infrastructure Layer**: Concrete implementations (filesystem, git, parsers)
|
||||||
|
- **Adapter Layer**: MCP protocol handlers, DTOs, external interface adapters
|
||||||
|
|
||||||
|
### Key Domain Entities
|
||||||
|
|
||||||
|
**Note**: Central entity representing an Obsidian note with:
|
||||||
|
- Path validation (must end in .md, no path traversal)
|
||||||
|
- Content with automatic extraction of WikiLinks and Tags
|
||||||
|
- Frontmatter (YAML metadata)
|
||||||
|
- Timestamps (created/modified)
|
||||||
|
|
||||||
|
**Vault**: Aggregate root managing collections of notes with:
|
||||||
|
- Graph of note relationships
|
||||||
|
- Tag indexing with counts
|
||||||
|
- Statistics calculation
|
||||||
|
|
||||||
|
**WikiLink**: Value object for `[[Internal Links]]` with support for:
|
||||||
|
- Aliases: `[[target|display text]]`
|
||||||
|
- Sections: `[[note#section]]`
|
||||||
|
|
||||||
|
**Tag**: Value object for `#tags` with:
|
||||||
|
- Nested tag support: `#project/ai/research`
|
||||||
|
- Validation (no spaces, proper format)
|
||||||
|
|
||||||
|
**Graph**: Thread-safe adjacency list for note relationships
|
||||||
|
|
||||||
|
### Repository Pattern
|
||||||
|
All data access goes through interfaces in `internal/repository/`:
|
||||||
|
- **NoteRepository**: CRUD operations for notes
|
||||||
|
- **GitRepository**: Version control operations
|
||||||
|
- **VaultRepository**: Vault-level operations and statistics
|
||||||
|
- **SearchIndex**: Full-text search capabilities
|
||||||
|
- **GraphIndex**: Backlink/outlink indexing
|
||||||
|
- **TagIndex**: Tag-based search and operations
|
||||||
|
|
||||||
|
### Configuration System
|
||||||
|
Uses `internal/config/config.go` with:
|
||||||
|
- YAML file configuration
|
||||||
|
- Environment variable overrides
|
||||||
|
- Validation with sensible defaults
|
||||||
|
- Support for vault path, git settings, caching, logging, performance tuning
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
Domain errors defined in `internal/domain/errors.go`:
|
||||||
|
- `ErrNoteNotFound`, `ErrNoteAlreadyExists`
|
||||||
|
- `ErrInvalidPath`, `ErrInvalidTag`
|
||||||
|
- `ErrCircularLink`
|
||||||
|
|
||||||
|
### Key Architectural Patterns
|
||||||
|
|
||||||
|
1. **Dependency Inversion**: Use cases depend on repository interfaces, not implementations
|
||||||
|
2. **Entity Encapsulation**: Domain entities manage their own business rules
|
||||||
|
3. **Value Objects**: Immutable types like WikiLink, Tag, Frontmatter
|
||||||
|
4. **Repository Pattern**: Clean separation between business logic and data access
|
||||||
|
5. **Configuration as Code**: Comprehensive config system with validation
|
||||||
|
|
||||||
|
### Implementation Status
|
||||||
|
Currently implemented:
|
||||||
|
- ✅ Complete domain layer with all entities
|
||||||
|
- ✅ All repository interfaces
|
||||||
|
- ✅ Configuration system
|
||||||
|
- ✅ CLI entry point
|
||||||
|
|
||||||
|
Still needed (per design.md roadmap):
|
||||||
|
- Infrastructure implementations (filesystem, git, markdown parsing)
|
||||||
|
- Use case implementations
|
||||||
|
- MCP adapter layer with 45+ tools
|
||||||
|
- Index implementations (search, graph, tag)
|
||||||
|
|
||||||
|
### MCP Integration
|
||||||
|
When implementing MCP tools, they should:
|
||||||
|
- Live in `internal/adapter/mcp/tools/`
|
||||||
|
- Use DTOs from `internal/adapter/dto/`
|
||||||
|
- Call use cases, never repositories directly
|
||||||
|
- Handle MCP protocol specifics while keeping business logic in use cases
|
||||||
208
README.md
Normal file
208
README.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# Obsidian MCP Server
|
||||||
|
|
||||||
|
A lightweight, high-performance Model Context Protocol (MCP) server for Obsidian vaults written in Go.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Full Obsidian Support**: Read, create, update, and delete notes with frontmatter, wikilinks, and tags
|
||||||
|
- **Git Integration**: Automatic version control with commit, pull, and push operations
|
||||||
|
- **Fast Search**: Full-text search, tag-based search, and frontmatter filtering
|
||||||
|
- **Graph Navigation**: Backlinks, outlinks, and graph traversal
|
||||||
|
- **45+ MCP Tools**: Comprehensive API for note management and vault operations
|
||||||
|
- **High Performance**: < 100ms read operations, supports vaults with 10,000+ notes
|
||||||
|
- **Clean Architecture**: Domain-driven design with clear separation of concerns
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Binary Release
|
||||||
|
```bash
|
||||||
|
# Download the latest release for your platform
|
||||||
|
curl -L https://github.com/user/obsidian-mcp-server/releases/latest/download/obsidian-mcp-linux -o obsidian-mcp
|
||||||
|
chmod +x obsidian-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Go Install
|
||||||
|
```bash
|
||||||
|
go install github.com/user/obsidian-mcp-server/cmd/obsidian-mcp@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
```bash
|
||||||
|
docker run -v /path/to/vault:/vault ghcr.io/user/obsidian-mcp-server:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. **Configure MCP Client** (Claude Desktop example):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"obsidian": {
|
||||||
|
"command": "obsidian-mcp",
|
||||||
|
"args": ["--vault", "/path/to/your/vault"],
|
||||||
|
"env": {
|
||||||
|
"LOG_LEVEL": "info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Start the server**:
|
||||||
|
```bash
|
||||||
|
obsidian-mcp --vault /path/to/your/vault
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Use with Claude**: The server will be available through the MCP protocol with 45+ tools for note management.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
```bash
|
||||||
|
export VAULT_PATH=/path/to/vault # Required: Path to Obsidian vault
|
||||||
|
export LOG_LEVEL=info # Optional: debug, info, warn, error
|
||||||
|
export GIT_ENABLED=true # Optional: Enable git operations
|
||||||
|
export GIT_AUTO_PUSH=false # Optional: Auto-push changes
|
||||||
|
export CACHE_ENABLED=true # Optional: Enable caching
|
||||||
|
```
|
||||||
|
|
||||||
|
### Config File (config.yaml)
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
name: obsidian-mcp
|
||||||
|
transport: stdio
|
||||||
|
|
||||||
|
vault:
|
||||||
|
path: /path/to/vault
|
||||||
|
exclude_paths:
|
||||||
|
- .obsidian/
|
||||||
|
- .git/
|
||||||
|
- .trash/
|
||||||
|
|
||||||
|
git:
|
||||||
|
enabled: true
|
||||||
|
auto_pull: true
|
||||||
|
auto_push: false
|
||||||
|
auto_commit: true
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: info
|
||||||
|
format: text
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP Tools
|
||||||
|
|
||||||
|
The server provides 45+ MCP tools organized into categories:
|
||||||
|
|
||||||
|
### File Operations (5)
|
||||||
|
- `read_note` - Read note with metadata
|
||||||
|
- `create_note` - Create new note
|
||||||
|
- `update_note` - Update existing note
|
||||||
|
- `delete_note` - Delete note
|
||||||
|
- `rename_note` - Rename note and update links
|
||||||
|
|
||||||
|
### Search Operations (3)
|
||||||
|
- `search_content` - Full-text search
|
||||||
|
- `search_by_tags` - Search by tags
|
||||||
|
- `search_by_frontmatter` - Search by frontmatter
|
||||||
|
|
||||||
|
### Link Operations (5)
|
||||||
|
- `get_backlinks` - Get notes linking to this note
|
||||||
|
- `get_outlinks` - Get notes this note links to
|
||||||
|
- `get_graph` - Get graph neighborhood
|
||||||
|
- `find_broken_links` - Find broken wikilinks
|
||||||
|
- `update_links` - Update links after rename
|
||||||
|
|
||||||
|
### Tag Operations (4)
|
||||||
|
- `get_all_tags` - List all tags with counts
|
||||||
|
- `add_tags` - Add tags to notes
|
||||||
|
- `remove_tags` - Remove tags from notes
|
||||||
|
- `rename_tag` - Rename tag across vault
|
||||||
|
|
||||||
|
### Git Operations (5)
|
||||||
|
- `git_status` - Get git status
|
||||||
|
- `git_commit` - Commit changes
|
||||||
|
- `git_pull` - Pull from remote
|
||||||
|
- `git_push` - Push to remote
|
||||||
|
- `git_history` - Get commit history
|
||||||
|
|
||||||
|
### Vault Operations (5+)
|
||||||
|
- `get_vault_stats` - Get vault statistics
|
||||||
|
- `list_notes` - List all notes
|
||||||
|
- `get_vault_structure` - Get directory structure
|
||||||
|
- `find_orphaned_notes` - Find notes without links
|
||||||
|
- And more...
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
```
|
||||||
|
obsidian-mcp-server/
|
||||||
|
├── cmd/obsidian-mcp/ # Entry point
|
||||||
|
├── internal/
|
||||||
|
│ ├── domain/ # Domain entities
|
||||||
|
│ ├── repository/ # Repository interfaces
|
||||||
|
│ ├── usecase/ # Business logic
|
||||||
|
│ ├── infrastructure/ # External dependencies
|
||||||
|
│ ├── adapter/mcp/ # MCP protocol layer
|
||||||
|
│ └── config/ # Configuration
|
||||||
|
├── pkg/ # Public packages
|
||||||
|
└── test/ # Tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building
|
||||||
|
```bash
|
||||||
|
go build -o obsidian-mcp ./cmd/obsidian-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contributing
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create feature branch
|
||||||
|
3. Add tests for new functionality
|
||||||
|
4. Ensure all tests pass
|
||||||
|
5. Submit pull request
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
### Target Metrics
|
||||||
|
- Read operations: < 100ms
|
||||||
|
- Create/Update: < 200ms
|
||||||
|
- Search (5000 notes): < 500ms
|
||||||
|
- Memory usage: ~200MB for 5000 notes
|
||||||
|
|
||||||
|
### Scalability
|
||||||
|
- Recommended: Up to 10,000 notes
|
||||||
|
- Maximum: 50,000 notes (performance may degrade)
|
||||||
|
- Vault size: Up to 1GB
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- **Issues**: [GitHub Issues](https://github.com/user/obsidian-mcp-server/issues)
|
||||||
|
- **Documentation**: [Wiki](https://github.com/user/obsidian-mcp-server/wiki)
|
||||||
|
- **Discussions**: [GitHub Discussions](https://github.com/user/obsidian-mcp-server/discussions)
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### v1.1.0
|
||||||
|
- [ ] Semantic search with embeddings
|
||||||
|
- [ ] Real-time file watching
|
||||||
|
- [ ] Web UI for monitoring
|
||||||
|
|
||||||
|
### v1.2.0
|
||||||
|
- [ ] Custom parsers
|
||||||
|
- [ ] Advanced git operations
|
||||||
|
- [ ] Performance improvements
|
||||||
|
|
||||||
|
### v2.0.0
|
||||||
|
- [ ] Multi-vault support
|
||||||
|
- [ ] Collaboration features
|
||||||
|
- [ ] Cloud sync integration
|
||||||
115
cmd/obsidian-mcp/main.go
Normal file
115
cmd/obsidian-mcp/main.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/user/obsidian-mcp-server/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
version = "1.0.0"
|
||||||
|
commit = "dev"
|
||||||
|
date = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
configPath = flag.String("config", "", "Path to config file")
|
||||||
|
vaultPath = flag.String("vault", "", "Path to Obsidian vault")
|
||||||
|
showVersion = flag.Bool("version", false, "Show version information")
|
||||||
|
showHelp = flag.Bool("help", false, "Show help")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *showVersion {
|
||||||
|
fmt.Printf("Obsidian MCP Server\n")
|
||||||
|
fmt.Printf("Version: %s\n", version)
|
||||||
|
fmt.Printf("Commit: %s\n", commit)
|
||||||
|
fmt.Printf("Built: %s\n", date)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *showHelp {
|
||||||
|
fmt.Printf("Obsidian MCP Server - A Model Context Protocol server for Obsidian vaults\n\n")
|
||||||
|
fmt.Printf("Usage:\n")
|
||||||
|
fmt.Printf(" obsidian-mcp [flags]\n\n")
|
||||||
|
fmt.Printf("Flags:\n")
|
||||||
|
flag.PrintDefaults()
|
||||||
|
fmt.Printf("\nEnvironment Variables:\n")
|
||||||
|
fmt.Printf(" VAULT_PATH - Path to Obsidian vault\n")
|
||||||
|
fmt.Printf(" CONFIG_PATH - Path to config file\n")
|
||||||
|
fmt.Printf(" LOG_LEVEL - Logging level (debug, info, warn, error)\n")
|
||||||
|
fmt.Printf(" GIT_ENABLED - Enable git operations (true/false)\n")
|
||||||
|
fmt.Printf(" GIT_AUTO_PUSH - Auto-push changes (true/false)\n")
|
||||||
|
fmt.Printf(" CACHE_ENABLED - Enable caching (true/false)\n")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadConfig(*configPath, *vaultPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Setup signal handling
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-sigChan
|
||||||
|
log.Println("Received shutdown signal")
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("Starting Obsidian MCP Server v%s", version)
|
||||||
|
log.Printf("Vault path: %s", cfg.Vault.Path)
|
||||||
|
log.Printf("Transport: %s", cfg.Server.Transport)
|
||||||
|
|
||||||
|
// TODO: Initialize server, repositories, and use cases
|
||||||
|
// server := NewMCPServer(cfg)
|
||||||
|
// if err := server.Start(ctx); err != nil {
|
||||||
|
// log.Fatalf("Server failed: %v", err)
|
||||||
|
// }
|
||||||
|
|
||||||
|
log.Println("Server started successfully")
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
log.Println("Shutting down...")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig(configPath, vaultPath string) (*config.Config, error) {
|
||||||
|
if configPath == "" {
|
||||||
|
configPath = os.Getenv("CONFIG_PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
envCfg, err := config.LoadConfigFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if vaultPath != "" {
|
||||||
|
cfg.Vault.Path = vaultPath
|
||||||
|
} else if envCfg.Vault.Path != "" {
|
||||||
|
cfg.Vault.Path = envCfg.Vault.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Logging.Level = envCfg.Logging.Level
|
||||||
|
cfg.Git.Enabled = envCfg.Git.Enabled
|
||||||
|
cfg.Git.AutoPush = envCfg.Git.AutoPush
|
||||||
|
cfg.Cache.Enabled = envCfg.Cache.Enabled
|
||||||
|
|
||||||
|
return cfg, cfg.Validate()
|
||||||
|
}
|
||||||
14
design.md
14
design.md
@@ -294,20 +294,20 @@ classDiagram
|
|||||||
-string path
|
-string path
|
||||||
-string content
|
-string content
|
||||||
-Frontmatter frontmatter
|
-Frontmatter frontmatter
|
||||||
-[]WikiLink outlinks
|
-WikiLink[] outlinks
|
||||||
-[]Tag tags
|
-Tag[] tags
|
||||||
-time createdAt
|
-time createdAt
|
||||||
-time modifiedAt
|
-time modifiedAt
|
||||||
+NewNote(path, content) Note
|
+NewNote(path, content) Note
|
||||||
+AddTag(tag) error
|
+AddTag(tag) error
|
||||||
+RemoveTag(tag) error
|
+RemoveTag(tag) error
|
||||||
+UpdateContent(content) error
|
+UpdateContent(content) error
|
||||||
+ExtractLinks() []WikiLink
|
+ExtractLinks() WikiLink[]
|
||||||
}
|
}
|
||||||
|
|
||||||
class Vault {
|
class Vault {
|
||||||
-string rootPath
|
-string rootPath
|
||||||
-[]Note notes
|
-Note[] notes
|
||||||
-Graph graph
|
-Graph graph
|
||||||
-map tags
|
-map tags
|
||||||
+NewVault(path) Vault
|
+NewVault(path) Vault
|
||||||
@@ -317,7 +317,7 @@ classDiagram
|
|||||||
|
|
||||||
class Frontmatter {
|
class Frontmatter {
|
||||||
-map data
|
-map data
|
||||||
+Get(key) interface{}
|
+Get(key) any
|
||||||
+Set(key, value) error
|
+Set(key, value) error
|
||||||
+Merge(other) Frontmatter
|
+Merge(other) Frontmatter
|
||||||
}
|
}
|
||||||
@@ -340,8 +340,8 @@ classDiagram
|
|||||||
class Graph {
|
class Graph {
|
||||||
-map adjacencyList
|
-map adjacencyList
|
||||||
+AddEdge(from, to)
|
+AddEdge(from, to)
|
||||||
+GetBacklinks(path) []string
|
+GetBacklinks(path) string[]
|
||||||
+GetOutlinks(path) []string
|
+GetOutlinks(path) string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
Vault "1" *-- "0..*" Note
|
Vault "1" *-- "0..*" Note
|
||||||
|
|||||||
32
go.mod
Normal file
32
go.mod
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
module github.com/user/obsidian-mcp-server
|
||||||
|
|
||||||
|
go 1.23.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-git/go-git/v5 v5.11.0
|
||||||
|
github.com/modelcontextprotocol/go-sdk v0.1.0
|
||||||
|
github.com/yuin/goldmark v1.6.0
|
||||||
|
go.abhg.dev/goldmark/frontmatter v0.1.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect
|
||||||
|
github.com/acomagu/bufpipe v1.0.4 // indirect
|
||||||
|
github.com/cloudflare/circl v1.3.3 // indirect
|
||||||
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||||
|
github.com/go-git/go-billy/v5 v5.5.0 // indirect
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
|
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||||
|
github.com/pjbgf/sha1cd v0.3.0 // indirect
|
||||||
|
github.com/sergi/go-diff v1.1.0 // indirect
|
||||||
|
github.com/skeema/knownhosts v1.2.1 // indirect
|
||||||
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
|
golang.org/x/crypto v0.16.0 // indirect
|
||||||
|
golang.org/x/net v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.15.0 // indirect
|
||||||
|
golang.org/x/text v0.14.0 // indirect
|
||||||
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
|
)
|
||||||
89
go.sum
Normal file
89
go.sum
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||||
|
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
|
||||||
|
github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
|
||||||
|
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
|
||||||
|
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||||
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||||
|
github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
|
||||||
|
github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY=
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||||
|
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v0.1.0/go.mod h1:DcXfbr7yl7e35oMpzHfKw2nUYRjhIGS2uou/6tdsTB0=
|
||||||
|
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||||
|
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
|
github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.abhg.dev/goldmark/frontmatter v0.1.0/go.mod h1:XqrEkZuM57djk7zrlRUB02x8I5J0px76YjkOzhB4YlU=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||||
|
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||||
|
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||||
|
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
241
internal/config/config.go
Normal file
241
internal/config/config.go
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig `yaml:"server"`
|
||||||
|
Vault VaultConfig `yaml:"vault"`
|
||||||
|
Git GitConfig `yaml:"git"`
|
||||||
|
Index IndexConfig `yaml:"index"`
|
||||||
|
Search SearchConfig `yaml:"search"`
|
||||||
|
Cache CacheConfig `yaml:"cache"`
|
||||||
|
Logging LoggingConfig `yaml:"logging"`
|
||||||
|
Performance PerformanceConfig `yaml:"performance"`
|
||||||
|
Security SecurityConfig `yaml:"security"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
Transport string `yaml:"transport"` // "stdio" or "sse"
|
||||||
|
}
|
||||||
|
|
||||||
|
type VaultConfig struct {
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
ExcludePaths []string `yaml:"exclude_paths"`
|
||||||
|
WatchChanges bool `yaml:"watch_changes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GitConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
AutoPull bool `yaml:"auto_pull"`
|
||||||
|
AutoPush bool `yaml:"auto_push"`
|
||||||
|
AutoCommit bool `yaml:"auto_commit"`
|
||||||
|
CommitPrefix string `yaml:"commit_prefix"`
|
||||||
|
Remote string `yaml:"remote"`
|
||||||
|
Branch string `yaml:"branch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IndexConfig struct {
|
||||||
|
BuildOnStartup bool `yaml:"build_on_startup"`
|
||||||
|
RebuildInterval time.Duration `yaml:"rebuild_interval"`
|
||||||
|
MaxNotesInMemory int `yaml:"max_notes_in_memory"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchConfig struct {
|
||||||
|
MaxResults int `yaml:"max_results"`
|
||||||
|
ContextLines int `yaml:"context_lines"`
|
||||||
|
CaseSensitive bool `yaml:"case_sensitive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CacheConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
MaxSize int `yaml:"max_size"`
|
||||||
|
TTL time.Duration `yaml:"ttl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoggingConfig struct {
|
||||||
|
Level string `yaml:"level"` // debug, info, warn, error
|
||||||
|
Format string `yaml:"format"` // text or json
|
||||||
|
File string `yaml:"file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PerformanceConfig struct {
|
||||||
|
MaxConcurrentOperations int `yaml:"max_concurrent_operations"`
|
||||||
|
ReadTimeout time.Duration `yaml:"read_timeout"`
|
||||||
|
WriteTimeout time.Duration `yaml:"write_timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RateLimitConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
RequestsPerSecond int `yaml:"requests_per_second"`
|
||||||
|
Burst int `yaml:"burst"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SecurityConfig struct {
|
||||||
|
RateLimit RateLimitConfig `yaml:"rate_limit"`
|
||||||
|
MaxFileSize string `yaml:"max_file_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDefaultConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
Server: ServerConfig{
|
||||||
|
Name: "obsidian-mcp",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Transport: "stdio",
|
||||||
|
},
|
||||||
|
Vault: VaultConfig{
|
||||||
|
Path: "",
|
||||||
|
ExcludePaths: []string{
|
||||||
|
".obsidian/",
|
||||||
|
".git/",
|
||||||
|
".trash/",
|
||||||
|
},
|
||||||
|
WatchChanges: true,
|
||||||
|
},
|
||||||
|
Git: GitConfig{
|
||||||
|
Enabled: true,
|
||||||
|
AutoPull: true,
|
||||||
|
AutoPush: false,
|
||||||
|
AutoCommit: true,
|
||||||
|
CommitPrefix: "[MCP]",
|
||||||
|
Remote: "origin",
|
||||||
|
Branch: "main",
|
||||||
|
},
|
||||||
|
Index: IndexConfig{
|
||||||
|
BuildOnStartup: true,
|
||||||
|
RebuildInterval: 5 * time.Minute,
|
||||||
|
MaxNotesInMemory: 10000,
|
||||||
|
},
|
||||||
|
Search: SearchConfig{
|
||||||
|
MaxResults: 50,
|
||||||
|
ContextLines: 2,
|
||||||
|
CaseSensitive: false,
|
||||||
|
},
|
||||||
|
Cache: CacheConfig{
|
||||||
|
Enabled: true,
|
||||||
|
MaxSize: 1000,
|
||||||
|
TTL: 5 * time.Minute,
|
||||||
|
},
|
||||||
|
Logging: LoggingConfig{
|
||||||
|
Level: "info",
|
||||||
|
Format: "text",
|
||||||
|
File: "",
|
||||||
|
},
|
||||||
|
Performance: PerformanceConfig{
|
||||||
|
MaxConcurrentOperations: 10,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
Security: SecurityConfig{
|
||||||
|
RateLimit: RateLimitConfig{
|
||||||
|
Enabled: true,
|
||||||
|
RequestsPerSecond: 10,
|
||||||
|
Burst: 20,
|
||||||
|
},
|
||||||
|
MaxFileSize: "10MB",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig(path string) (*Config, error) {
|
||||||
|
config := NewDefaultConfig()
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := yaml.Unmarshal(data, config); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfigFromEnv() (*Config, error) {
|
||||||
|
config := NewDefaultConfig()
|
||||||
|
|
||||||
|
if vaultPath := os.Getenv("VAULT_PATH"); vaultPath != "" {
|
||||||
|
config.Vault.Path = vaultPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if logLevel := os.Getenv("LOG_LEVEL"); logLevel != "" {
|
||||||
|
config.Logging.Level = logLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
if gitEnabled := os.Getenv("GIT_ENABLED"); gitEnabled == "false" {
|
||||||
|
config.Git.Enabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if gitAutoPush := os.Getenv("GIT_AUTO_PUSH"); gitAutoPush == "true" {
|
||||||
|
config.Git.AutoPush = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if cacheEnabled := os.Getenv("CACHE_ENABLED"); cacheEnabled == "false" {
|
||||||
|
config.Cache.Enabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, config.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Validate() error {
|
||||||
|
if c.Vault.Path == "" {
|
||||||
|
return fmt.Errorf("vault path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Server.Transport != "stdio" && c.Server.Transport != "sse" {
|
||||||
|
return fmt.Errorf("invalid transport: %s", c.Server.Transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Search.MaxResults <= 0 {
|
||||||
|
return fmt.Errorf("max results must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Cache.MaxSize <= 0 {
|
||||||
|
return fmt.Errorf("cache max size must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
validLogLevels := map[string]bool{
|
||||||
|
"debug": true,
|
||||||
|
"info": true,
|
||||||
|
"warn": true,
|
||||||
|
"error": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validLogLevels[c.Logging.Level] {
|
||||||
|
return fmt.Errorf("invalid log level: %s", c.Logging.Level)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) SaveToFile(path string) error {
|
||||||
|
data, err := yaml.Marshal(c)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
12
internal/domain/errors.go
Normal file
12
internal/domain/errors.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNoteNotFound = errors.New("note not found")
|
||||||
|
ErrNoteAlreadyExists = errors.New("note already exists")
|
||||||
|
ErrInvalidPath = errors.New("invalid path")
|
||||||
|
ErrEmptyContent = errors.New("content cannot be empty")
|
||||||
|
ErrInvalidTag = errors.New("invalid tag format")
|
||||||
|
ErrCircularLink = errors.New("circular link detected")
|
||||||
|
)
|
||||||
110
internal/domain/frontmatter.go
Normal file
110
internal/domain/frontmatter.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Frontmatter struct {
|
||||||
|
data map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFrontmatter() *Frontmatter {
|
||||||
|
return &Frontmatter{
|
||||||
|
data: make(map[string]interface{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFrontmatterFromMap(data map[string]interface{}) *Frontmatter {
|
||||||
|
fm := NewFrontmatter()
|
||||||
|
for k, v := range data {
|
||||||
|
fm.data[k] = v
|
||||||
|
}
|
||||||
|
return fm
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Frontmatter) Get(key string) interface{} {
|
||||||
|
return f.data[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Frontmatter) Set(key string, value interface{}) error {
|
||||||
|
if key == "" {
|
||||||
|
return fmt.Errorf("frontmatter key cannot be empty")
|
||||||
|
}
|
||||||
|
f.data[key] = value
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Frontmatter) Has(key string) bool {
|
||||||
|
_, exists := f.data[key]
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Frontmatter) Delete(key string) {
|
||||||
|
delete(f.data, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Frontmatter) Keys() []string {
|
||||||
|
keys := make([]string, 0, len(f.data))
|
||||||
|
for k := range f.data {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Frontmatter) Data() map[string]interface{} {
|
||||||
|
result := make(map[string]interface{})
|
||||||
|
for k, v := range f.data {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Frontmatter) IsEmpty() bool {
|
||||||
|
return len(f.data) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Frontmatter) Merge(other *Frontmatter) *Frontmatter {
|
||||||
|
merged := NewFrontmatter()
|
||||||
|
|
||||||
|
for k, v := range f.data {
|
||||||
|
merged.data[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range other.data {
|
||||||
|
merged.data[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Frontmatter) Clone() *Frontmatter {
|
||||||
|
cloned := NewFrontmatter()
|
||||||
|
for k, v := range f.data {
|
||||||
|
cloned.data[k] = deepCopy(v)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func deepCopy(src interface{}) interface{} {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := src.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
dst := make(map[string]interface{})
|
||||||
|
for key, val := range v {
|
||||||
|
dst[key] = deepCopy(val)
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
case []interface{}:
|
||||||
|
dst := make([]interface{}, len(v))
|
||||||
|
for i, val := range v {
|
||||||
|
dst[i] = deepCopy(val)
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
default:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
117
internal/domain/graph.go
Normal file
117
internal/domain/graph.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
type Graph struct {
|
||||||
|
adjacencyList map[string]map[string]bool
|
||||||
|
mutex sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGraph() *Graph {
|
||||||
|
return &Graph{
|
||||||
|
adjacencyList: make(map[string]map[string]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Graph) AddEdge(from, to string) {
|
||||||
|
g.mutex.Lock()
|
||||||
|
defer g.mutex.Unlock()
|
||||||
|
|
||||||
|
if g.adjacencyList[from] == nil {
|
||||||
|
g.adjacencyList[from] = make(map[string]bool)
|
||||||
|
}
|
||||||
|
g.adjacencyList[from][to] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Graph) RemoveEdge(from, to string) {
|
||||||
|
g.mutex.Lock()
|
||||||
|
defer g.mutex.Unlock()
|
||||||
|
|
||||||
|
if g.adjacencyList[from] != nil {
|
||||||
|
delete(g.adjacencyList[from], to)
|
||||||
|
if len(g.adjacencyList[from]) == 0 {
|
||||||
|
delete(g.adjacencyList, from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Graph) GetOutlinks(path string) []string {
|
||||||
|
g.mutex.RLock()
|
||||||
|
defer g.mutex.RUnlock()
|
||||||
|
|
||||||
|
var outlinks []string
|
||||||
|
if targets, exists := g.adjacencyList[path]; exists {
|
||||||
|
for target := range targets {
|
||||||
|
outlinks = append(outlinks, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outlinks
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Graph) GetBacklinks(path string) []string {
|
||||||
|
g.mutex.RLock()
|
||||||
|
defer g.mutex.RUnlock()
|
||||||
|
|
||||||
|
var backlinks []string
|
||||||
|
for source, targets := range g.adjacencyList {
|
||||||
|
if targets[path] {
|
||||||
|
backlinks = append(backlinks, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return backlinks
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Graph) HasEdge(from, to string) bool {
|
||||||
|
g.mutex.RLock()
|
||||||
|
defer g.mutex.RUnlock()
|
||||||
|
|
||||||
|
if targets, exists := g.adjacencyList[from]; exists {
|
||||||
|
return targets[to]
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Graph) RemoveNode(path string) {
|
||||||
|
g.mutex.Lock()
|
||||||
|
defer g.mutex.Unlock()
|
||||||
|
|
||||||
|
delete(g.adjacencyList, path)
|
||||||
|
|
||||||
|
for source, targets := range g.adjacencyList {
|
||||||
|
delete(targets, path)
|
||||||
|
if len(targets) == 0 {
|
||||||
|
delete(g.adjacencyList, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Graph) GetAllNodes() []string {
|
||||||
|
g.mutex.RLock()
|
||||||
|
defer g.mutex.RUnlock()
|
||||||
|
|
||||||
|
nodeSet := make(map[string]bool)
|
||||||
|
|
||||||
|
for source := range g.adjacencyList {
|
||||||
|
nodeSet[source] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, targets := range g.adjacencyList {
|
||||||
|
for target := range targets {
|
||||||
|
nodeSet[target] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodes []string
|
||||||
|
for node := range nodeSet {
|
||||||
|
nodes = append(nodes, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Graph) Clear() {
|
||||||
|
g.mutex.Lock()
|
||||||
|
defer g.mutex.Unlock()
|
||||||
|
|
||||||
|
g.adjacencyList = make(map[string]map[string]bool)
|
||||||
|
}
|
||||||
190
internal/domain/note.go
Normal file
190
internal/domain/note.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Note struct {
|
||||||
|
path string
|
||||||
|
content string
|
||||||
|
frontmatter *Frontmatter
|
||||||
|
outlinks []*WikiLink
|
||||||
|
tags []*Tag
|
||||||
|
createdAt time.Time
|
||||||
|
modifiedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNote(path, content string) (*Note, error) {
|
||||||
|
if err := validatePath(path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
note := &Note{
|
||||||
|
path: path,
|
||||||
|
content: content,
|
||||||
|
frontmatter: NewFrontmatter(),
|
||||||
|
outlinks: make([]*WikiLink, 0),
|
||||||
|
tags: make([]*Tag, 0),
|
||||||
|
createdAt: time.Now(),
|
||||||
|
modifiedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := note.extractLinks(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := note.extractTags(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return note, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Note) Path() string {
|
||||||
|
return n.path
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Note) Content() string {
|
||||||
|
return n.content
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Note) Frontmatter() *Frontmatter {
|
||||||
|
return n.frontmatter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Note) Outlinks() []*WikiLink {
|
||||||
|
return n.outlinks
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Note) Tags() []*Tag {
|
||||||
|
return n.tags
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Note) CreatedAt() time.Time {
|
||||||
|
return n.createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Note) ModifiedAt() time.Time {
|
||||||
|
return n.modifiedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Note) UpdateContent(content string) error {
|
||||||
|
n.content = content
|
||||||
|
n.modifiedAt = time.Now()
|
||||||
|
|
||||||
|
n.outlinks = make([]*WikiLink, 0)
|
||||||
|
n.tags = make([]*Tag, 0)
|
||||||
|
|
||||||
|
if err := n.extractLinks(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return n.extractTags()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Note) SetFrontmatter(fm *Frontmatter) {
|
||||||
|
n.frontmatter = fm
|
||||||
|
n.modifiedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Note) AddTag(tag *Tag) error {
|
||||||
|
for _, existingTag := range n.tags {
|
||||||
|
if existingTag.Name() == tag.Name() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
n.tags = append(n.tags, tag)
|
||||||
|
n.modifiedAt = time.Now()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Note) RemoveTag(tagName string) error {
|
||||||
|
for i, tag := range n.tags {
|
||||||
|
if tag.Name() == tagName {
|
||||||
|
n.tags = append(n.tags[:i], n.tags[i+1:]...)
|
||||||
|
n.modifiedAt = time.Now()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Note) HasTag(tagName string) bool {
|
||||||
|
for _, tag := range n.tags {
|
||||||
|
if tag.Name() == tagName {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Note) ExtractLinks() []*WikiLink {
|
||||||
|
return n.outlinks
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Note) WordCount() int {
|
||||||
|
words := strings.Fields(n.content)
|
||||||
|
return len(words)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Note) CharacterCount() int {
|
||||||
|
return len(n.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Note) extractLinks() error {
|
||||||
|
linkRegex := regexp.MustCompile(`\[\[([^\]]+)\]\]`)
|
||||||
|
matches := linkRegex.FindAllStringSubmatch(n.content, -1)
|
||||||
|
|
||||||
|
for _, match := range matches {
|
||||||
|
if len(match) >= 2 {
|
||||||
|
link, err := ParseWikiLink(match[1])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n.outlinks = append(n.outlinks, link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Note) extractTags() error {
|
||||||
|
tagRegex := regexp.MustCompile(`#([a-zA-Z0-9_/]+)`)
|
||||||
|
matches := tagRegex.FindAllStringSubmatch(n.content, -1)
|
||||||
|
|
||||||
|
for _, match := range matches {
|
||||||
|
if len(match) >= 2 {
|
||||||
|
tag, err := NewTag(match[1])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n.AddTag(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePath(path string) error {
|
||||||
|
if path == "" {
|
||||||
|
return ErrInvalidPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(path, ".md") {
|
||||||
|
return ErrInvalidPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(path, "..") {
|
||||||
|
return ErrInvalidPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filepath.IsAbs(path) && strings.HasPrefix(path, "/") {
|
||||||
|
return ErrInvalidPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
79
internal/domain/tag.go
Normal file
79
internal/domain/tag.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tag struct {
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTag(name string) (*Tag, error) {
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("tag name cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(name, "#") {
|
||||||
|
name = "#" + name
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidTagName(name) {
|
||||||
|
return nil, ErrInvalidTag
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Tag{name: name}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tag) Name() string {
|
||||||
|
return t.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tag) Parent() *Tag {
|
||||||
|
if !t.IsNested() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSlash := strings.LastIndex(t.name, "/")
|
||||||
|
if lastSlash == -1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parentName := t.name[:lastSlash]
|
||||||
|
parent, _ := NewTag(parentName)
|
||||||
|
return parent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tag) IsNested() bool {
|
||||||
|
return strings.Contains(t.name, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tag) String() string {
|
||||||
|
return t.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidTagName(name string) bool {
|
||||||
|
if !strings.HasPrefix(name, "#") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(name) <= 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
tagContent := name[1:]
|
||||||
|
|
||||||
|
if strings.Contains(tagContent, " ") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(tagContent, "/") || strings.HasSuffix(tagContent, "/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(tagContent, "//") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
201
internal/domain/vault.go
Normal file
201
internal/domain/vault.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VaultStats struct {
|
||||||
|
TotalNotes int
|
||||||
|
TotalWords int
|
||||||
|
TotalLinks int
|
||||||
|
TotalTags int
|
||||||
|
AvgNoteLength int
|
||||||
|
OrphanedNotes int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Vault struct {
|
||||||
|
rootPath string
|
||||||
|
notes map[string]*Note
|
||||||
|
graph *Graph
|
||||||
|
tags map[string]int
|
||||||
|
mutex sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVault(rootPath string) *Vault {
|
||||||
|
return &Vault{
|
||||||
|
rootPath: rootPath,
|
||||||
|
notes: make(map[string]*Note),
|
||||||
|
graph: NewGraph(),
|
||||||
|
tags: make(map[string]int),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) RootPath() string {
|
||||||
|
return v.rootPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) AddNote(note *Note) error {
|
||||||
|
v.mutex.Lock()
|
||||||
|
defer v.mutex.Unlock()
|
||||||
|
|
||||||
|
if _, exists := v.notes[note.Path()]; exists {
|
||||||
|
return ErrNoteAlreadyExists
|
||||||
|
}
|
||||||
|
|
||||||
|
v.notes[note.Path()] = note
|
||||||
|
|
||||||
|
for _, link := range note.ExtractLinks() {
|
||||||
|
v.graph.AddEdge(note.Path(), link.Target())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range note.Tags() {
|
||||||
|
v.tags[tag.Name()]++
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) RemoveNote(path string) error {
|
||||||
|
v.mutex.Lock()
|
||||||
|
defer v.mutex.Unlock()
|
||||||
|
|
||||||
|
note, exists := v.notes[path]
|
||||||
|
if !exists {
|
||||||
|
return ErrNoteNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range note.Tags() {
|
||||||
|
v.tags[tag.Name()]--
|
||||||
|
if v.tags[tag.Name()] <= 0 {
|
||||||
|
delete(v.tags, tag.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
v.graph.RemoveNode(path)
|
||||||
|
delete(v.notes, path)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) GetNote(path string) (*Note, error) {
|
||||||
|
v.mutex.RLock()
|
||||||
|
defer v.mutex.RUnlock()
|
||||||
|
|
||||||
|
note, exists := v.notes[path]
|
||||||
|
if !exists {
|
||||||
|
return nil, ErrNoteNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return note, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) UpdateNote(note *Note) error {
|
||||||
|
v.mutex.Lock()
|
||||||
|
defer v.mutex.Unlock()
|
||||||
|
|
||||||
|
existingNote, exists := v.notes[note.Path()]
|
||||||
|
if !exists {
|
||||||
|
return ErrNoteNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range existingNote.Tags() {
|
||||||
|
v.tags[tag.Name()]--
|
||||||
|
if v.tags[tag.Name()] <= 0 {
|
||||||
|
delete(v.tags, tag.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
v.graph.RemoveNode(note.Path())
|
||||||
|
|
||||||
|
v.notes[note.Path()] = note
|
||||||
|
|
||||||
|
for _, link := range note.ExtractLinks() {
|
||||||
|
v.graph.AddEdge(note.Path(), link.Target())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range note.Tags() {
|
||||||
|
v.tags[tag.Name()]++
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) ListNotes() []*Note {
|
||||||
|
v.mutex.RLock()
|
||||||
|
defer v.mutex.RUnlock()
|
||||||
|
|
||||||
|
notes := make([]*Note, 0, len(v.notes))
|
||||||
|
for _, note := range v.notes {
|
||||||
|
notes = append(notes, note)
|
||||||
|
}
|
||||||
|
|
||||||
|
return notes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) GetStats() VaultStats {
|
||||||
|
v.mutex.RLock()
|
||||||
|
defer v.mutex.RUnlock()
|
||||||
|
|
||||||
|
totalWords := 0
|
||||||
|
totalLinks := 0
|
||||||
|
|
||||||
|
for _, note := range v.notes {
|
||||||
|
totalWords += note.WordCount()
|
||||||
|
totalLinks += len(note.ExtractLinks())
|
||||||
|
}
|
||||||
|
|
||||||
|
avgLength := 0
|
||||||
|
if len(v.notes) > 0 {
|
||||||
|
avgLength = totalWords / len(v.notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
orphaned := v.countOrphanedNotes()
|
||||||
|
|
||||||
|
return VaultStats{
|
||||||
|
TotalNotes: len(v.notes),
|
||||||
|
TotalWords: totalWords,
|
||||||
|
TotalLinks: totalLinks,
|
||||||
|
TotalTags: len(v.tags),
|
||||||
|
AvgNoteLength: avgLength,
|
||||||
|
OrphanedNotes: orphaned,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) GetBacklinks(path string) []string {
|
||||||
|
return v.graph.GetBacklinks(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) GetOutlinks(path string) []string {
|
||||||
|
return v.graph.GetOutlinks(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) GetAllTags() map[string]int {
|
||||||
|
v.mutex.RLock()
|
||||||
|
defer v.mutex.RUnlock()
|
||||||
|
|
||||||
|
tags := make(map[string]int)
|
||||||
|
for tag, count := range v.tags {
|
||||||
|
tags[tag] = count
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) countOrphanedNotes() int {
|
||||||
|
orphaned := 0
|
||||||
|
for path := range v.notes {
|
||||||
|
if len(v.graph.GetBacklinks(path)) == 0 && len(v.graph.GetOutlinks(path)) == 0 {
|
||||||
|
orphaned++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return orphaned
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) Clear() {
|
||||||
|
v.mutex.Lock()
|
||||||
|
defer v.mutex.Unlock()
|
||||||
|
|
||||||
|
v.notes = make(map[string]*Note)
|
||||||
|
v.graph.Clear()
|
||||||
|
v.tags = make(map[string]int)
|
||||||
|
}
|
||||||
78
internal/domain/wikilink.go
Normal file
78
internal/domain/wikilink.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WikiLink struct {
|
||||||
|
target string
|
||||||
|
alias string
|
||||||
|
section string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWikiLink(target string, alias string, section string) (*WikiLink, error) {
|
||||||
|
if target == "" {
|
||||||
|
return nil, fmt.Errorf("wikilink target cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &WikiLink{
|
||||||
|
target: target,
|
||||||
|
alias: alias,
|
||||||
|
section: section,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WikiLink) Target() string {
|
||||||
|
return w.target
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WikiLink) Alias() string {
|
||||||
|
return w.alias
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WikiLink) Section() string {
|
||||||
|
return w.section
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WikiLink) IsValid() bool {
|
||||||
|
return w.target != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WikiLink) String() string {
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
if w.alias != "" {
|
||||||
|
return fmt.Sprintf("[[%s|%s]]", w.target, w.alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.section != "" {
|
||||||
|
return fmt.Sprintf("[[%s#%s]]", w.target, w.section)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("[[%s]]", w.target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseWikiLink(linkText string) (*WikiLink, error) {
|
||||||
|
linkText = strings.Trim(linkText, "[]")
|
||||||
|
|
||||||
|
if linkText == "" {
|
||||||
|
return nil, fmt.Errorf("empty wikilink")
|
||||||
|
}
|
||||||
|
|
||||||
|
var target, alias, section string
|
||||||
|
|
||||||
|
if strings.Contains(linkText, "|") {
|
||||||
|
parts := strings.SplitN(linkText, "|", 2)
|
||||||
|
target = strings.TrimSpace(parts[0])
|
||||||
|
alias = strings.TrimSpace(parts[1])
|
||||||
|
} else if strings.Contains(linkText, "#") {
|
||||||
|
parts := strings.SplitN(linkText, "#", 2)
|
||||||
|
target = strings.TrimSpace(parts[0])
|
||||||
|
section = strings.TrimSpace(parts[1])
|
||||||
|
} else {
|
||||||
|
target = strings.TrimSpace(linkText)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewWikiLink(target, alias, section)
|
||||||
|
}
|
||||||
33
internal/repository/git_repository.go
Normal file
33
internal/repository/git_repository.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GitStatus struct {
|
||||||
|
Branch string
|
||||||
|
Modified []string
|
||||||
|
Untracked []string
|
||||||
|
Staged []string
|
||||||
|
Ahead int
|
||||||
|
Behind int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Commit struct {
|
||||||
|
Hash string
|
||||||
|
Author string
|
||||||
|
Date time.Time
|
||||||
|
Message string
|
||||||
|
Files []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GitRepository interface {
|
||||||
|
Status(ctx context.Context) (*GitStatus, error)
|
||||||
|
Pull(ctx context.Context) error
|
||||||
|
Push(ctx context.Context) error
|
||||||
|
Commit(ctx context.Context, message string, files []string) error
|
||||||
|
Log(ctx context.Context, path string, limit int) ([]*Commit, error)
|
||||||
|
IsEnabled() bool
|
||||||
|
Clone(ctx context.Context, url string, path string) error
|
||||||
|
}
|
||||||
40
internal/repository/graph_index.go
Normal file
40
internal/repository/graph_index.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/user/obsidian-mcp-server/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GraphNode struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GraphEdge struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GraphData struct {
|
||||||
|
Nodes []*GraphNode `json:"nodes"`
|
||||||
|
Edges []*GraphEdge `json:"edges"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BacklinkInfo struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
Context string `json:"context"`
|
||||||
|
Line int `json:"line"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GraphIndex interface {
|
||||||
|
AddNote(ctx context.Context, note *domain.Note) error
|
||||||
|
RemoveNote(ctx context.Context, path string) error
|
||||||
|
GetBacklinks(ctx context.Context, path string) ([]*BacklinkInfo, error)
|
||||||
|
GetOutlinks(ctx context.Context, path string) ([]string, error)
|
||||||
|
GetConnected(ctx context.Context, path string, depth int) (*GraphData, error)
|
||||||
|
FindBrokenLinks(ctx context.Context, notePath string) ([]string, error)
|
||||||
|
UpdateLinks(ctx context.Context, oldPath, newPath string) error
|
||||||
|
Rebuild(ctx context.Context, notes []*domain.Note) error
|
||||||
|
Clear() error
|
||||||
|
}
|
||||||
18
internal/repository/note_repository.go
Normal file
18
internal/repository/note_repository.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/user/obsidian-mcp-server/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NoteRepository interface {
|
||||||
|
Get(ctx context.Context, path string) (*domain.Note, error)
|
||||||
|
Create(ctx context.Context, note *domain.Note) error
|
||||||
|
Update(ctx context.Context, note *domain.Note) error
|
||||||
|
Delete(ctx context.Context, path string) error
|
||||||
|
List(ctx context.Context, pattern string, recursive bool) ([]*domain.Note, error)
|
||||||
|
Exists(ctx context.Context, path string) bool
|
||||||
|
FindByTag(ctx context.Context, tag string) ([]*domain.Note, error)
|
||||||
|
FindByContent(ctx context.Context, query string) ([]*domain.Note, error)
|
||||||
|
}
|
||||||
36
internal/repository/search_index.go
Normal file
36
internal/repository/search_index.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/user/obsidian-mcp-server/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SearchResult struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Matches []*SearchMatch `json:"matches"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchMatch struct {
|
||||||
|
Line int `json:"line"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
ContextBefore string `json:"context_before"`
|
||||||
|
ContextAfter string `json:"context_after"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchQuery struct {
|
||||||
|
Query string
|
||||||
|
MaxResults int
|
||||||
|
CaseSensitive bool
|
||||||
|
UseRegex bool
|
||||||
|
ContextLines int
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchIndex interface {
|
||||||
|
Index(ctx context.Context, note *domain.Note) error
|
||||||
|
Remove(ctx context.Context, path string) error
|
||||||
|
Search(ctx context.Context, query *SearchQuery) ([]*SearchResult, error)
|
||||||
|
Rebuild(ctx context.Context, notes []*domain.Note) error
|
||||||
|
Clear() error
|
||||||
|
}
|
||||||
30
internal/repository/tag_index.go
Normal file
30
internal/repository/tag_index.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/user/obsidian-mcp-server/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TagInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Nested bool `json:"nested"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TagFilter struct {
|
||||||
|
Tags []string
|
||||||
|
Mode string // "all" or "any"
|
||||||
|
IncludeNested bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type TagIndex interface {
|
||||||
|
AddNote(ctx context.Context, note *domain.Note) error
|
||||||
|
RemoveNote(ctx context.Context, path string) error
|
||||||
|
GetAllTags(ctx context.Context) ([]*TagInfo, error)
|
||||||
|
GetNotesWithTag(ctx context.Context, tag string) ([]string, error)
|
||||||
|
GetNotesWithTags(ctx context.Context, filter *TagFilter) ([]string, error)
|
||||||
|
RenameTag(ctx context.Context, oldTag, newTag string) error
|
||||||
|
Rebuild(ctx context.Context, notes []*domain.Note) error
|
||||||
|
Clear() error
|
||||||
|
}
|
||||||
39
internal/repository/vault_repository.go
Normal file
39
internal/repository/vault_repository.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/user/obsidian-mcp-server/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VaultStructure struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"` // "file" or "directory"
|
||||||
|
Children []*VaultStructure `json:"children,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MostLinkedNote struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VaultStats struct {
|
||||||
|
TotalNotes int `json:"total_notes"`
|
||||||
|
TotalWords int `json:"total_words"`
|
||||||
|
TotalLinks int `json:"total_links"`
|
||||||
|
TotalTags int `json:"total_tags"`
|
||||||
|
AvgNoteLength int `json:"avg_note_length"`
|
||||||
|
MostLinked []*MostLinkedNote `json:"most_linked"`
|
||||||
|
OrphanedNotes int `json:"orphaned_notes"`
|
||||||
|
LastIndexed string `json:"last_indexed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VaultRepository interface {
|
||||||
|
GetStats(ctx context.Context) (*VaultStats, error)
|
||||||
|
GetStructure(ctx context.Context, maxDepth int) (*VaultStructure, error)
|
||||||
|
FindOrphaned(ctx context.Context, excludePaths []string) ([]string, error)
|
||||||
|
GetMostLinked(ctx context.Context, limit int) ([]*MostLinkedNote, error)
|
||||||
|
Initialize(ctx context.Context, vault *domain.Vault) error
|
||||||
|
Rebuild(ctx context.Context) error
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user