wip

no empty description

+325
+48
README.md
··· 1 + # hugo-digital-garden 2 + 3 + This is a preprocessor for Hugo to create a digital garden from an Obsidian Vault. The idea is, that this tool can be used to generate your Hugo ./content directory from an Obsidian Vault, containing only notes that are not explicitly marked as private. 4 + 5 + What it will do 6 + 7 + - filter out private notes 8 + - create a content structure, mimicking the Obsidian Vault structure 9 + - handle attachments like images, videos, audio files, etc. 10 + - handle varying frontmatter 11 + - add missing frontmatter 12 + - merge hugo and existing frontmatter 13 + - generate "last modified at" information to every note (optopnal) 14 + 15 + Assumptions 16 + 17 + - all note editing will take place in Obsidian 18 + - no manual editing of Hugos `./content` directory 19 + - general editing flow is 20 + - edit note in Obsidian 21 + - use hugo-digital-garden to generate content directory 22 + - `hugo serve` ... 23 + 24 + A private note is 25 + 26 + - marked within frontmatter as `private: true` 27 + - located in a folder named `private` - somewhere in the directory path 28 + 29 + The structure of your `./content` directory will mimic the structure of your Obsidian Vault, with the following exceptions: 30 + 31 + - Private notes are not included in the generated content directory. 32 + - The `private` folder is not included in the generated content directory. 33 + 34 + Additional notes that are excluded 35 + 36 + - configured directories like 37 + - Clippings 38 + - Templates 39 + - bases or files that contain bases (simply as it is unclear how to render them) 40 + 41 + 42 + Questions: 43 + 44 + - How to handle varying frontmatter? 45 + - How to handle backlinks? 46 + - Do we need something that does graph visualization? 47 + - could be done via a json file and a hugo theme extension reading this file 48 + - should we support Hugo archetypes?
+194
file.go
··· 1 1 package main 2 + 2 3 import ( 3 4 "fmt" 5 + "io" 6 + "log" 7 + "os" 4 8 "path/filepath" 9 + "regexp" 5 10 "strings" 11 + 6 12 "github.com/google/uuid" 13 + "github.com/yuin/goldmark" 14 + "github.com/yuin/goldmark/ast" 15 + "github.com/yuin/goldmark/text" 16 + "go.abhg.dev/goldmark/frontmatter" 17 + "go.abhg.dev/goldmark/wikilink" 7 18 ) 19 + 8 20 type File struct { 9 21 Id string 10 22 Name string ··· 21 33 22 34 Links []string 23 35 } 36 + 24 37 func InitNewFile(name string, obsidianPath string, hugoPath string) *File { 25 38 id := uuid.New().String() 26 39 relativePath, err := GetRelativePath(obsidianPath) ··· 50 63 func HugoPath(contentDir, relativePath string) string { 51 64 return filepath.Join(contentDir, relativePath) 52 65 } 66 + 67 + func (f *File) GetHugoPath() string { 68 + return f.HugoPath 69 + } 70 + 71 + func (f *File) GetObsidianPath() string { 72 + return f.ObsidianPath 73 + } 74 + 75 + func (f *File) ReadObsidianFile() error { 76 + // Open the file with the extracted name 77 + file, err := os.Open(f.ObsidianPath) 78 + if err != nil { 79 + return fmt.Errorf("failed to open file %s: %w", f.ObsidianPath, err) 80 + } 81 + defer file.Close() 82 + 83 + // Read the file content 84 + f.RawContent, err = io.ReadAll(file) 85 + if err != nil { 86 + return fmt.Errorf("failed to read file %s: %w", f.ObsidianPath, err) 87 + } 88 + 89 + // Parse the Markdown content 90 + f.ObsidianMarkdown = parseMarkdown(f.RawContent) 91 + f.HugoMarkdown = parseMarkdown(f.RawContent) 92 + ensureBasicFrontmatter(f) 93 + 94 + return nil 95 + } 96 + 53 97 func getFileType(path string) string { 54 98 if strings.HasSuffix(path, "/") { 55 99 return "Directory" ··· 90 134 return "Unknown" 91 135 } 92 136 } 137 + 138 + func parseMarkdown(src []byte) *ast.Document { 139 + md := goldmark.New( 140 + goldmark.WithExtensions( 141 + &frontmatter.Extender{ 142 + Mode: frontmatter.SetMetadata, 143 + }, 144 + &wikilink.Extender{ 145 + // Resolver: ResolveWikilink, 146 + }), 147 + ) 148 + 149 + root := md.Parser().Parse(text.NewReader(src)) 150 + doc := root.OwnerDocument() 151 + 152 + return doc 153 + } 154 + 155 + type myresolver struct{} 156 + 157 + var _html = []byte(".html") 158 + var _hash = []byte{'#'} 159 + 160 + func (myresolver) ResolveWikilink(n *wikilink.Node) ([]byte, error) { 161 + dest := make([]byte, len(n.Target)+len(_html)+len(_hash)+len(n.Fragment)) 162 + var i int 163 + if len(n.Target) > 0 { 164 + i += copy(dest, n.Target) 165 + if filepath.Ext(string(n.Target)) == "" { 166 + i += copy(dest[i:], _html) 167 + } 168 + } 169 + if len(n.Fragment) > 0 { 170 + i += copy(dest[i:], _hash) 171 + i += copy(dest[i:], n.Fragment) 172 + } 173 + return dest[:i], nil 174 + } 175 + 176 + func ensureBasicFrontmatter(f *File) { 177 + meta := f.HugoMarkdown.Meta() 178 + 179 + // Minimum set of frontmatter fields: 180 + // --- 181 + // title: Example Title 182 + // draft: false 183 + // tags: 184 + // - example-tag 185 + // --- 186 + 187 + // if title set title to filename 188 + if meta["title"] == nil { 189 + meta["title"] = filepath.Base(f.Name) 190 + } 191 + 192 + // if a file has a private flag, set treat it accordingly 193 + if meta["private"] != nil { 194 + f.Private = meta["private"].(bool) 195 + } 196 + 197 + if meta["draft"] == nil { 198 + if f.Private { 199 + meta["draft"] = true 200 + } else { 201 + meta["draft"] = false 202 + } 203 + } 204 + 205 + // if tags are empty, set at least one tag "note" 206 + if meta["tags"] == nil { 207 + meta["tags"] = []string{"note"} 208 + } 209 + 210 + f.HugoMarkdown.SetMeta(meta) 211 + } 212 + 213 + func GenerateHugoDirectory(files []*File) error { 214 + 215 + for _, f := range files { 216 + if f.Private { 217 + continue 218 + } 219 + if err := WriteHugoFile(f); err != nil { 220 + return err 221 + } 222 + } 223 + 224 + return nil 225 + } 226 + 227 + // write a function that creates a new file based on a given input path 228 + func WriteHugoFile(f *File) error { 229 + 230 + // Create the file with the extracted name 231 + file, err := os.Create(f.Name) 232 + if err != nil { 233 + return fmt.Errorf("failed to create file %s: %w", f.Name, err) 234 + } 235 + defer file.Close() 236 + 237 + return nil 238 + } 239 + 240 + func (f *File) containsLink() bool { 241 + // Match all patterns like [[.*?]], including those preceded by "!" 242 + re := regexp.MustCompile(`\[\[.*?\]\]`) 243 + 244 + matches := re.FindAllIndex(f.RawContent, -1) 245 + for _, match := range matches { 246 + // Check if the match is not preceded by "!" 247 + if match[0] == 0 || f.RawContent[match[0]-1] != '!' { 248 + fmt.Println(string(f.RawContent)) 249 + return true 250 + } 251 + } 252 + 253 + return false 254 + } 255 + 256 + // extractLinks extracts all valid links (e.g., [[some text]]) from the content. 257 + func extractLinks(content []byte) []string { 258 + re := regexp.MustCompile(`\[\[(.*?)\]\]`) 259 + codeBlockRe := regexp.MustCompile("(?s)```.*?```") // Matches Markdown code blocks 260 + 261 + // Remove all code blocks from the content 262 + cleanedContent := codeBlockRe.ReplaceAll(content, []byte{}) 263 + 264 + matches := re.FindAllSubmatchIndex(cleanedContent, -1) 265 + 266 + uniqueLinks := make(map[string]struct{}) 267 + for _, match := range matches { 268 + if len(match) >= 4 { 269 + start := match[0] 270 + 271 + // Check if the match is not preceded by "!" 272 + if start == 0 || cleanedContent[start-1] != '!' { 273 + link := string(cleanedContent[match[2]:match[3]]) 274 + uniqueLinks[link] = struct{}{} 275 + } 276 + } 277 + } 278 + 279 + // Convert the map keys to a slice 280 + var links []string 281 + for link := range uniqueLinks { 282 + links = append(links, link) 283 + } 284 + 285 + return links 286 + }
+83
filelist.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "path/filepath" 6 + "strings" 7 + ) 8 + 9 + type ObsidianVaultFileList []*File 10 + 11 + func InitObsidianVaultFileList(paths []string) ObsidianVaultFileList { 12 + return PathListToFileList(paths) 13 + } 14 + 15 + func PathListToFileList(paths []string) ObsidianVaultFileList { 16 + var fileList ObsidianVaultFileList 17 + for _, path := range paths { 18 + fileList = append(fileList, InitNewFile(path, path, o.HugoContentDirectory)) 19 + } 20 + return fileList 21 + } 22 + 23 + // Returns amount of files in the list 24 + func (fl *ObsidianVaultFileList) Len() int { 25 + return len(*fl) 26 + } 27 + 28 + func (fl *ObsidianVaultFileList) GetIdByObsidianPath(path string) string { 29 + for _, file := range *fl { 30 + if file.ObsidianPath == path { 31 + return file.Id 32 + } 33 + } 34 + return "" 35 + } 36 + 37 + func (fl *ObsidianVaultFileList) GetIdByObsidianFileName(name string) string { 38 + for _, file := range *fl { 39 + if file.Name == name { 40 + return file.Id 41 + } 42 + } 43 + return "" 44 + } 45 + 46 + type ObsidianVaultGraph struct { 47 + Nodes ObsidianVaultFileList 48 + Edges map[string][]string 49 + } 50 + 51 + // MarshalObsidianVaultGraph marshals the ObsidianVaultGraph to JSON 52 + func (graph ObsidianVaultGraph) MarshalObsidianVaultGraph() ([]byte, error) { 53 + return json.Marshal(graph) 54 + } 55 + 56 + func InitObsidianVaultGraph() ObsidianVaultGraph { 57 + return ObsidianVaultGraph{ 58 + Nodes: ObsidianVaultFileList{}, 59 + Edges: make(map[string][]string), 60 + } 61 + } 62 + 63 + // Filter out files that are not Markdown files 64 + func (fl *ObsidianVaultFileList) FilterMarkdown() *ObsidianVaultFileList { 65 + var filteredFiles ObsidianVaultFileList 66 + for _, file := range *fl { 67 + if filepath.Ext(file.Type) == "Markdown" { 68 + filteredFiles = append(filteredFiles, file) 69 + } 70 + } 71 + return &filteredFiles 72 + } 73 + 74 + // Filter out files that are not Markdown files 75 + func (fl *ObsidianVaultFileList) FilterObsidian() *ObsidianVaultFileList { 76 + var filteredFiles ObsidianVaultFileList 77 + for _, file := range filteredFiles { 78 + if !strings.HasPrefix(file.ObsidianPath, ".obsidian") { 79 + filteredFiles = append(filteredFiles, file) 80 + } 81 + } 82 + return &filteredFiles 83 + }