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:
Andrey Epifancev
2025-10-08 10:22:28 +04:00
parent b655c58ba1
commit da289d4a7e
24 changed files with 1840 additions and 7 deletions

12
internal/domain/errors.go Normal file
View 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")
)

View 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
View 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
View 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
View 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
View 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)
}

View 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)
}