package logseq
import (
"bufio"
"fmt"
"io"
"io/fs"
"iter"
"log/slog"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
)
var (
propertyRegexp = regexp.MustCompile(`(\w[\w_-]*\w):: (.+)$`)
refRegexp = regexp.MustCompile(`\[\[(@?[\w_-]*\w)\]\]`)
)
const (
PageLevel = "page"
BlockLevel = "block"
)
type RegexGraph struct {
Path string
}
func NewRegexGraph(path string) RegexGraph {
return RegexGraph{
Path: path,
}
}
func (g RegexGraph) WalkPages() iter.Seq[Page] {
return func(yield func(p Page) bool) {
_ = filepath.Walk(g.Path, func(path string, fs fs.FileInfo, err error) error {
if err != nil {
slog.Error("failed to access path", "path", path, "with", err)
return nil
}
if !strings.HasSuffix(fs.Name(), ".md") {
return nil
}
page, err := NewPage(path)
if err != nil {
slog.Error("failed to create new page", "with", err)
return nil
}
if !yield(page) {
return fmt.Errorf("pages walk iteration stopped")
}
return nil
})
}
}
type Page struct {
Path string
Info PageInfo
}
func NewPage(path string) (Page, error) {
file, err := os.Open(path)
if err != nil {
return Page{}, fmt.Errorf("failed to open page with %w", err)
}
defer file.Close()
info, err := FindPageInfo(file)
if err != nil {
return Page{}, fmt.Errorf("failed to read page properties with %w", err)
}
return Page{
Path: path,
Info: info,
}, nil
}
func (p Page) Title() string {
fileName := filepath.Base(p.Path)
return strings.TrimSuffix(fileName, filepath.Ext(fileName))
}
func (p Page) Read() (string, error) {
file, err := os.Open(p.Path)
if err != nil {
return "", fmt.Errorf("failed to open page '%s' file with %w", p.Title(), err)
}
content, err := io.ReadAll(file)
if err != nil {
return "", fmt.Errorf("failed to read page '%s' file with %w", p.Title(), err)
}
return string(content), nil
}
type PageInfo struct {
Props []Property
Refs []string
}
type Property struct {
Name string
Values []string
Level string
}
func (p PageInfo) AllTags() ([]string, bool) {
for _, p := range p.Props {
if p.Name != "tags" {
continue
}
return p.Values, true
}
return nil, false
}
func (p PageInfo) PageLevelTags() ([]string, bool) {
for _, p := range p.Props {
if p.Name != "tags" || p.Level != PageLevel {
continue
}
return p.Values, true
}
return nil, false
}
func (p PageInfo) Get(name string) (values []string, ok bool) {
for _, p := range p.Props {
if p.Name != name {
continue
}
return p.Values, true
}
return values, false
}
func (p PageInfo) PageLevelGet(name string) (values []string, ok bool) {
for _, p := range p.Props {
if p.Name != name || p.Level != PageLevel {
continue
}
return p.Values, true
}
return values, false
}
func FindPageInfo(r io.Reader) (PageInfo, error) {
var props PageInfo
var propertyLevel string
pageStart := true
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
pageStart = false
continue
}
matches := refRegexp.FindAllStringSubmatchIndex(line, -1)
for _, match := range matches {
ref := line[match[2]:match[3]]
if slices.Contains(props.Refs, ref) {
continue
}
props.Refs = append(props.Refs, ref)
}
match := propertyRegexp.FindStringSubmatchIndex(line)
if match == nil {
continue
}
propertyName := line[match[2]:match[3]]
propertyValues := strings.Split(line[match[4]:match[5]], ",")
for idx, value := range propertyValues {
propertyValues[idx] = strings.Trim(value, " ")
}
if pageStart {
propertyLevel = PageLevel
} else {
propertyLevel = BlockLevel
}
props.Props = append(props.Props, Property{
Name: propertyName,
Values: propertyValues,
Level: propertyLevel,
})
}
return props, nil
}
func ExtractReference(ref string) string {
return strings.TrimPrefix(strings.TrimSuffix(ref, "]]"), "[[")
}
func (g RegexGraph) GetAllTitles() []string {
var titles []string
for page := range g.WalkPages() {
titles = append(titles, page.Title())
}
return titles
}
func (g RegexGraph) GetPageByTitle(title string) (Page, bool) {
for page := range g.WalkPages() {
if strings.EqualFold(page.Title(), title) {
return page, true
}
}
return Page{}, false
}
func (g RegexGraph) GetRelatedPages(title string, maxDepth int) []Page {
startPage, found := g.GetPageByTitle(title)
if !found {
return nil
}
visited := make(map[string]bool)
var result []Page
type queueItem struct {
page Page
depth int
}
queue := []queueItem{{startPage, 0}}
visited[strings.ToLower(startPage.Title())] = true
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
if current.depth >= maxDepth {
continue
}
for _, ref := range current.page.Info.Refs {
refLower := strings.ToLower(ref)
if visited[refLower] {
continue
}
visited[refLower] = true
refPage, found := g.GetPageByTitle(ref)
if !found {
continue
}
result = append(result, refPage)
queue = append(queue, queueItem{refPage, current.depth + 1})
}
}
return result
}