Initial commit: knox knowledge index
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user