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:
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