268 lines
6.4 KiB
Go
268 lines
6.4 KiB
Go
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
|
|
}
|