Files
knox/internal/ingest/obsidian.go
david 2c0f8fa257 feat: M1 determinism fixes for gossip
Refs #1

- F1: canonical fingerprints (git identity by remote URL, log by
  basename, obsidian by relative vault path) so identical facts get
  identical ids across machines
- F2: node_id (persisted in settings) + Hybrid Logical Clock in all
  observation "when" columns; removed time.Now() fact-time fallbacks
- F3: stable TF-IDF tie-break sort (score desc, term asc)
- F4: observations carry (node_id, hcl) locator; dedup ordered by hcl
2026-08-29 03:51:02 -07:00

269 lines
6.5 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 + relative vault path (machine-independent):
// the same note on two hosts maps to the same fingerprint.
relPath, _ := filepath.Rel(vault, path)
fp := FingerprintWithMeta(data, map[string]string{
"source": "obsidian",
"path": relPath,
})
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
}