package ingest import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "time" ) const obsidianConfigPath = ".config/obsidian/obsidian.json" type ObsidianIngester struct { Version string Vault string // optional override; empty = auto-detect } func NewObsidianIngester(vault string) *ObsidianIngester { return &ObsidianIngester{Version: "obsidian-note/v1", Vault: vault} } func (o *ObsidianIngester) SourceID() string { return "obsidian" } func (o *ObsidianIngester) IngestAll() ([]*IngestResult, error) { vault := o.Vault if vault == "" { var err error vault, err = detectObsidianVault() if err != nil { return nil, fmt.Errorf("no vault path and auto-detect failed: %w", err) } } if !fileExists(vault) { return nil, fmt.Errorf("vault path %s does not exist", vault) } var results []*IngestResult err := filepath.Walk(vault, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // skip errors per-file } if info.IsDir() { dir := filepath.Base(path) if strings.HasPrefix(dir, ".") || dir == "node_modules" { return filepath.SkipDir } return nil } if !strings.HasSuffix(path, ".md") { return nil } result, err := o.ingestNote(path, vault) if err != nil { return nil } if result != nil { results = append(results, result) } return nil }) return results, err } func (o *ObsidianIngester) ingestNote(path, vault string) (*IngestResult, error) { data, err := os.ReadFile(path) if err != nil || len(data) < 10 { return nil, nil } content := string(data) title := extractObsidianTitle(content, path, vault) tags := extractObsidianTags(content) created, modified := extractObsidianTimestamps(content, path) if created == "" { if fi, err := os.Stat(path); err == nil { created = fi.ModTime().UTC().Format(time.RFC3339) } } if modified == "" && created != "" { modified = created } // Fingerprint by content (so edits create updates) fp := FingerprintWithMeta(data, map[string]string{ "source": "obsidian", "path": path, }) relPath, _ := filepath.Rel(vault, path) summary := extractBodyPreview(content, 200) return &IngestResult{ Fingerprint: fp, SourceID: o.SourceID(), SourcePath: relPath, Project: filepath.Base(vault), ContentType: ".md", Title: title, Summary: summary, CreatedAt: created, LineStart: 0, LineEnd: len(strings.Split(content, "\n")), Confidence: 0.85, IngesterVersion: o.Version, Provenance: map[string]any{ "path": relPath, "tags": tags, "modified_at": modified, "note_title": title, }, }, nil } func DetectObsidianVault() (string, error) { return detectObsidianVault() } func detectObsidianVault() (string, error) { home, _ := os.UserHomeDir() cfgPath := filepath.Join(home, obsidianConfigPath) if !fileExists(cfgPath) { // Fallback: check common vault locations common := []string{ filepath.Join(home, "notes"), filepath.Join(home, "Documents", "notes"), filepath.Join(home, "Obsidian"), filepath.Join(home, "vault"), } for _, p := range common { if fileExists(filepath.Join(p, ".obsidian")) { return p, nil } } return "", fmt.Errorf("no obsidian config found at %s and no common vault detected", cfgPath) } data, err := os.ReadFile(cfgPath) if err != nil { return "", err } var cfg struct { Vaults map[string]struct { Path string `json:"path"` } `json:"vaults"` } if err := json.Unmarshal(data, &cfg); err != nil { return "", fmt.Errorf("parse obsidian config: %w", err) } for _, v := range cfg.Vaults { if v.Path != "" { return v.Path, nil } } return "", fmt.Errorf("no vault path in obsidian config") } func extractObsidianTitle(content, path, vault string) string { // Try frontmatter title first for _, line := range strings.Split(content, "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "title:") { t := strings.TrimSpace(trimmed[6:]) t = strings.Trim(t, "\"'") if t != "" { return t } } if strings.HasPrefix(trimmed, "aliases:") { break } if trimmed == "---" && strings.Count(content[:len(content)/2], "---") >= 2 { break } } // Fallback: first H1 for _, line := range strings.Split(content, "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "# ") { return strings.TrimSpace(trimmed[2:]) } } // Fallback: filename rel, _ := filepath.Rel(vault, path) return strings.TrimSuffix(rel, ".md") } func extractObsidianTags(content string) []string { for _, line := range strings.Split(content, "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "tags:") { raw := strings.TrimSpace(trimmed[5:]) raw = strings.Trim(raw, "\"[] ") var tags []string for _, t := range strings.Split(raw, ",") { t = strings.TrimSpace(t) t = strings.Trim(t, "\" ") if t != "" { tags = append(tags, t) } } return tags } if trimmed == "---" && strings.Count(content[:len(content)/2], "---") >= 2 { break } } return nil } func extractObsidianTimestamps(content, path string) (created, modified string) { // Try frontmatter dates inFrontmatter := false for _, line := range strings.Split(content, "\n") { trimmed := strings.TrimSpace(line) if trimmed == "---" { inFrontmatter = !inFrontmatter continue } if !inFrontmatter { break } if strings.HasPrefix(trimmed, "created:") || strings.HasPrefix(trimmed, "date:") { created = strings.TrimSpace(trimmed[strings.Index(trimmed, ":")+1:]) created = strings.Trim(created, "\"' ") } if strings.HasPrefix(trimmed, "modified:") || strings.HasPrefix(trimmed, "updated:") { modified = strings.TrimSpace(trimmed[strings.Index(trimmed, ":")+1:]) modified = strings.Trim(modified, "\"' ") } } return created, modified } func extractBodyPreview(content string, maxLen int) string { inFrontmatter := false var body strings.Builder for _, line := range strings.Split(content, "\n") { trimmed := strings.TrimSpace(line) if trimmed == "---" { if inFrontmatter { inFrontmatter = false continue } inFrontmatter = true continue } if inFrontmatter { continue } if trimmed != "" && body.Len() < maxLen { if body.Len() > 0 { body.WriteString(" ") } body.WriteString(truncate(trimmed, maxLen-body.Len())) } } result := body.String() if len(result) > maxLen { return result[:maxLen] + "..." } return result }