possibly first working draft

This commit is contained in:
2026-02-01 16:55:40 +01:00
parent e27aadc603
commit 92a6f84540
18 changed files with 450 additions and 270 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
_bin
.#*

View File

@ -1,6 +1,6 @@
build: build:
mkdir -p _bin mkdir -p _bin
go build -o _bin/donniemarko go build -o _bin/donniemarko cmd/main.go
install: install:
cp bin/donniemarko ~/.local/bin/ cp bin/donniemarko ~/.local/bin/

View File

@ -1 +0,0 @@
gator@gargantua.368651:1769498985

View File

@ -1,13 +1,16 @@
package main package main
import ( import (
"context"
"flag" "flag"
"log" "log"
"net/http" "net/http"
"kadath.corp/git/adminoo/donniemarko/web" "donniemarko/internal/render"
"donniemarko/internal/scanner"
"kadath.corp/git/adminoo/donniemarko/models" "donniemarko/internal/service"
"donniemarko/internal/storage"
"donniemarko/internal/web"
) )
func main() { func main() {
@ -23,15 +26,34 @@ func main() {
return return
} }
// Initialize the directory manager // Initialize storage
dm := models.NewTree(*rootFolder) noteStorage := storage.NewNoteStorage()
go dm.MonitorFileChange()
tm := web.NewTemplateManager("web/templates") // Initialize scanner
monitor := scanner.NewScanner(*rootFolder)
rh := web.NewRouteHandler(dm, tm) // Initialize notes handler for scanner
rh.SetupRoutes() notesHandler := scanner.NewNotesHandler(noteStorage)
monitor.SetHandler(notesHandler)
// Initialize service
noteService := service.NewNoteService()
noteService.SetStorage(noteStorage)
// Start scanner in background
ctx := context.Background()
go monitor.Monitor(ctx)
// log.Println("WE GET THERE", len(noteStorage.Index))
// Initialize template manager
tm := render.NewTemplateManager("internal/web/templates")
// Initialize web handler
handler := web.NewHandler(noteService, tm)
// Setup routes
handler.SetupRoutes()
log.Printf("Serving on http://%s", *listenAddr) log.Printf("Serving on http://%s", *listenAddr)
http.ListenAndServe(*listenAddr, nil) log.Fatal(http.ListenAndServe(*listenAddr, nil))
} }

View File

@ -25,27 +25,45 @@ func NewNote() *Note {
return &Note{} return &Note{}
} }
func (n *Note) GetUpdateDateRep() string {
return n.UpdatedAt.Format("2006-01-02 15:04:05")
}
// ExtractTitle return the first level heading content ('# title') // ExtractTitle return the first level heading content ('# title')
func ExtractTitle(mkd string) string { func ExtractTitle(mkd string) string {
if mkd == "" { if mkd == "" {
return "" return ""
} }
if !strings.HasPrefix(mkd, "# ") { lines := strings.Split(mkd, "\n")
return "" for _, line := range lines {
} line = strings.TrimSpace(line)
if strings.HasPrefix(line, "# ") {
var title string // Extract title from # heading
for _, c := range strings.TrimLeft(mkd, "# ") { title := strings.TrimPrefix(line, "# ")
if strings.Contains("*~", string(c)) { title = strings.TrimSpace(title)
continue // Remove common markdown formatting
} title = removeMarkdownFormatting(title)
if string(c) == "\n" {
break
}
title = title + string(c)
}
return title return title
}
}
return ""
}
// removeMarkdownFormatting removes common markdown formatting from text
func removeMarkdownFormatting(text string) string {
// Remove **bold** and *italic* formatting
result := text
result = strings.ReplaceAll(result, "**", "")
result = strings.ReplaceAll(result, "*", "")
result = strings.ReplaceAll(result, "_", "")
result = strings.ReplaceAll(result, "`", "")
result = strings.ReplaceAll(result, "~~", "")
// Clean up multiple spaces
result = strings.Join(strings.Fields(result), " ")
return result
} }
func GenerateNoteID(path string) string { func GenerateNoteID(path string) string {

View File

@ -3,6 +3,7 @@ package render
import ( import (
"fmt" "fmt"
"html/template" "html/template"
"net/http"
"path/filepath" "path/filepath"
"sync" "sync"
@ -21,11 +22,11 @@ type TemplateManager struct {
devMode bool devMode bool
} }
func NewTemplateManager(basePath string, devMode bool) *TemplateManager { func NewTemplateManager(basePath string) *TemplateManager {
return &TemplateManager{ return &TemplateManager{
templates: make(map[string]*template.Template), templates: make(map[string]*template.Template),
basePath: basePath, basePath: basePath,
devMode: devMode, devMode: false,
} }
} }
@ -66,6 +67,36 @@ func (tm *TemplateManager) GetTemplate(td *TemplateData) (*template.Template, er
return tmpl, nil return tmpl, nil
} }
func (tm *TemplateManager) Render(w http.ResponseWriter, name string, data interface{}) error {
// Build the template files - include all necessary templates
var files []string
// Always include base template
files = append(files, tm.buildTemplatePath("base"))
// Include noteList template (used by index)
files = append(files, tm.buildTemplatePath("noteList"))
// Add the specific template
files = append(files, tm.buildTemplatePath(name))
// Parse templates
tmpl, err := template.ParseFiles(files...)
if err != nil {
return fmt.Errorf("parse template %s: %w", name, err)
}
// Set content type
w.Header().Set("Content-Type", "text/html; charset=utf-8")
err = tmpl.ExecuteTemplate(w, "base", data)
if err != nil {
return fmt.Errorf("execute template %s: %w", name, err)
}
return nil
}
// Render markdown to HTML with target="_blank" on links // Render markdown to HTML with target="_blank" on links
func RenderMarkdown(content []byte) (template.HTML, error) { func RenderMarkdown(content []byte) (template.HTML, error) {
renderer := blackfriday.NewHTMLRenderer(blackfriday.HTMLRendererParameters{ renderer := blackfriday.NewHTMLRenderer(blackfriday.HTMLRendererParameters{

View File

@ -1,15 +1,22 @@
package scanner package scanner
import ( import (
"path/filepath"
"donniemarko/internal/note" "donniemarko/internal/note"
"donniemarko/internal/storage" "donniemarko/internal/storage"
"os" "os"
// "log"
) )
type NotesHandler struct { type NotesHandler struct {
storage storage.Storage storage storage.Storage
} }
func NewNotesHandler(storage storage.Storage) *NotesHandler {
return &NotesHandler{storage: storage}
}
func (h *NotesHandler) HandleCreate(path string) error { func (h *NotesHandler) HandleCreate(path string) error {
note, err := ParseNoteFile(path) note, err := ParseNoteFile(path)
if err != nil { if err != nil {
@ -34,9 +41,23 @@ func ParseNoteFile(path string) (*note.Note, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Get file info to get modification time
fileInfo, err := os.Stat(path)
if err != nil {
return nil, err
}
id := note.GenerateNoteID(path) id := note.GenerateNoteID(path)
nn := note.NewNote() nn := note.NewNote()
nn.ID = id nn.ID = id
nn.Content = string(content) nn.Content = string(content)
nn.Title = note.ExtractTitle(nn.Content)
if nn.Title == "" {
// Use filename as title if no heading found
nn.Title = filepath.Base(path)
}
nn.UpdatedAt = fileInfo.ModTime()
nn.CreatedAt = fileInfo.ModTime()
return nn, nil return nn, nil
} }

View File

@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
// "donniemarko/internal/note"
) )
type ChangeType int type ChangeType int
@ -32,13 +33,21 @@ type Change struct {
type ScannerService struct { type ScannerService struct {
RootDir string RootDir string
interval time.Duration Interval time.Duration
lastStates map[string]time.Time LastStates map[string]time.Time
handler ChangeHandler handler ChangeHandler
} }
func NewScanner(path string) *ScannerService { func NewScanner(path string) *ScannerService {
return &ScannerService{RootDir: path} return &ScannerService{
RootDir: path,
Interval: 5 * time.Second,
LastStates: make(map[string]time.Time),
}
}
func (s *ScannerService) SetHandler(handler ChangeHandler) {
s.handler = handler
} }
func (s *ScannerService) FindAll() ([]string, error) { func (s *ScannerService) FindAll() ([]string, error) {
@ -63,6 +72,8 @@ func (s *ScannerService) FindAll() ([]string, error) {
return notePath, err return notePath, err
} }
// Scan walks the root folder and update the states of each notes if
// it has changed since the last time a scan occured
func (s *ScannerService) Scan() ([]Change, error) { func (s *ScannerService) Scan() ([]Change, error) {
var changes []Change var changes []Change
currentStates := make(map[string]time.Time) currentStates := make(map[string]time.Time)
@ -81,8 +92,10 @@ func (s *ScannerService) Scan() ([]Change, error) {
currentStates[path] = info.ModTime() currentStates[path] = info.ModTime()
lastMod, existed := s.lastStates[path] lastMod, existed := s.LastStates[path]
if !existed { if !existed {
// create the note if it didn't exist yet
s.handler.HandleCreate(path)
changes = append(changes, Change{Type: Created, Path: path, ModTime: lastMod}) changes = append(changes, Change{Type: Created, Path: path, ModTime: lastMod})
} else if info.ModTime().After(lastMod) { } else if info.ModTime().After(lastMod) {
changes = append(changes, Change{Type: Modified, Path: path, ModTime: info.ModTime()}) changes = append(changes, Change{Type: Modified, Path: path, ModTime: info.ModTime()})
@ -92,18 +105,20 @@ func (s *ScannerService) Scan() ([]Change, error) {
}) })
// Check for deletions // Check for deletions
for path := range s.lastStates { for path := range s.LastStates {
if _, exists := currentStates[path]; !exists { if _, exists := currentStates[path]; !exists {
changes = append(changes, Change{Type: Deleted, Path: path}) changes = append(changes, Change{Type: Deleted, Path: path})
} }
} }
s.lastStates = currentStates s.LastStates = currentStates
return changes, nil return changes, nil
} }
// Monitor rescan the root folder at each new tick and handle state
// modification
func (s *ScannerService) Monitor(ctx context.Context) error { func (s *ScannerService) Monitor(ctx context.Context) error {
ticker := time.NewTicker(s.interval) ticker := time.NewTicker(s.Interval)
defer ticker.Stop() defer ticker.Stop()
for { for {

View File

@ -1,12 +1,11 @@
package service package service
import ( import (
"sort"
"donniemarko/internal/note" "donniemarko/internal/note"
"donniemarko/internal/storage" "donniemarko/internal/storage"
"sort"
) )
type NotesService struct { type NotesService struct {
storage storage.Storage storage storage.Storage
} }
@ -22,25 +21,35 @@ func NewNoteService() *NotesService {
return &NotesService{} return &NotesService{}
} }
func (s *NotesService) SetStorage(storage storage.Storage) {
s.storage = storage
}
func SortByDate(notes []*note.Note) { func SortByDate(notes []*note.Note) {
sort.Slice(notes, func(i, j int) bool { sort.Slice(notes, func(i, j int) bool {
return notes[i].UpdatedAt.After(notes[j].UpdatedAt) return notes[i].UpdatedAt.After(notes[j].UpdatedAt)
}) })
} }
func SortByTitle(notes []*note.Note) {
sort.Slice(notes, func(i, j int) bool {
return notes[i].Title < notes[j].Title
})
}
func SortByDateAsc(notes []*note.Note) { func SortByDateAsc(notes []*note.Note) {
sort.Slice(notes, func(i, j int) bool { sort.Slice(notes, func(i, j int) bool {
return notes[i].UpdatedAt.Before(notes[j].UpdatedAt) return notes[i].UpdatedAt.Before(notes[j].UpdatedAt)
}) })
} }
func (s *NotesService) GetNotes(sortBy SortOption) ([]*note.Note, error) { func SortByTitle(notes []*note.Note) {
sort.Slice(notes, func(i, j int) bool {
return notes[i].Title < notes[j].Title
})
}
func SortByTitleAsc(notes []*note.Note) {
sort.Slice(notes, func(i, j int) bool {
return notes[i].Title > notes[j].Title
})
}
func (s *NotesService) GetNotesWithSort(sortBy SortOption) ([]*note.Note, error) {
notes := s.storage.GetAll() notes := s.storage.GetAll()
if sortBy != nil { if sortBy != nil {
@ -74,3 +83,11 @@ func (s *NotesService) QueryNotes(opts QueryOptions) ([]*note.Note, error) {
return notes, nil return notes, nil
} }
func (s *NotesService) GetNoteByHash(hash string) (*note.Note, error) {
return s.storage.Get(hash)
}
func (s *NotesService) GetNotes() []*note.Note {
return s.storage.GetAll()
}

View File

@ -1,7 +1,6 @@
package service package service
import ( import (
"net/http/httptest"
"testing" "testing"
"donniemarko/internal/note" "donniemarko/internal/note"
@ -14,22 +13,22 @@ func TestMain(m *testing.M) {
storage := storage.NewNoteStorage() storage := storage.NewNoteStorage()
notes := []*note.Note{ notes := []*note.Note{
{Title: "Golang Tutorial", Content: "Learn Go"}, {ID: "test1", Title: "Golang Tutorial", Content: "Learn Go"},
{Title: "Rust Guide", Content: "Learn Rust"}, {ID: "test2", Title: "Rust Guide", Content: "Learn Rust"},
} }
for _, note := range notes { for _, note := range notes {
storage.Create(note) storage.Create(note)
} }
service = NewNoteService() service = NewNoteService()
service.storage = storage service.SetStorage(storage)
m.Run() m.Run()
} }
func TestQueryNotes_WithSearch(t *testing.T) { func TestQueryNotes_WithSearch(t *testing.T) {
opts := QueryOptions{ opts := QueryOptions{
SearchTerm: "golang", SearchTerm: "Go",
SortBy: "alpha", SortBy: "alpha",
} }
@ -47,23 +46,3 @@ func TestQueryNotes_WithSearch(t *testing.T) {
t.Error("wrong note returned") t.Error("wrong note returned")
} }
} }
func TestHandler_BuildViewState(t *testing.T) {
handler := NewHandler(service, nil)
req := httptest.NewRequest("GET", "/?search=test&sort=alpha", nil)
state, err := handler.buildViewState(req)
if err != nil {
t.Fatal(err)
}
if state.SearchTerm != "test" {
t.Error("search term not extracted")
}
if state.SortBy != "alpha" {
t.Error("sort option not extracted")
}
}

View File

@ -13,6 +13,7 @@ type Storage interface {
Delete(id string) Delete(id string)
Update(id string, n *note.Note) Update(id string, n *note.Note)
Search(query string) []*note.Note Search(query string) []*note.Note
Count() int
} }
type NoteStorage struct { type NoteStorage struct {
@ -23,10 +24,10 @@ func NewNoteStorage() *NoteStorage {
return &NoteStorage{Index: make(map[string]*note.Note)} return &NoteStorage{Index: make(map[string]*note.Note)}
} }
// GetAll returns all notes stored in the index
func (ns *NoteStorage) GetAll() []*note.Note { func (ns *NoteStorage) GetAll() []*note.Note {
notes := make([]*note.Note, 0, len(ns.Index)) var notes []*note.Note
// Step 3: Iterate over the map
for _, value := range ns.Index { for _, value := range ns.Index {
notes = append(notes, value) notes = append(notes, value)
} }
@ -46,6 +47,10 @@ func (ns *NoteStorage) Create(n *note.Note) error {
return nil return nil
} }
func (ns *NoteStorage) Count() int {
return len(ns.Index)
}
func (ns *NoteStorage) Delete(id string) { func (ns *NoteStorage) Delete(id string) {
delete(ns.Index, id) delete(ns.Index, id)
} }
@ -54,7 +59,7 @@ func (ns *NoteStorage) Update(id string, n *note.Note) {
ns.Index[id] = n ns.Index[id] = n
} }
func (ns *NoteStorage) Search(query string) []*note.Note{ func (ns *NoteStorage) Search(query string) []*note.Note {
results := []*note.Note{} results := []*note.Note{}
for _, note := range ns.Index { for _, note := range ns.Index {
lowContent := strings.ToLower(string(note.Content)) lowContent := strings.ToLower(string(note.Content))

View File

@ -35,7 +35,7 @@ func TestNoteStorageDelete(t *testing.T) {
ns.Delete(n1.ID) ns.Delete(n1.ID)
if len(ns.Index) > 1 { if len(ns.Index) > 1 {
t.Errorf("Deleting notes should remove from to the storage. Wanted 1, got '%v'", len(ns.Index)) t.Errorf("Deleting notes should remove them from to the storage. Wanted 1, got '%v'", len(ns.Index))
} }
} }

View File

@ -1,30 +1,54 @@
package web package web
import ( import (
"net/http" "donniemarko/internal/note"
"donniemarko/internal/service"
"donniemarko/internal/render" "donniemarko/internal/render"
"donniemarko/internal/service"
"html/template"
"net/http"
"strings"
) )
type Handler struct { type Handler struct {
notesService *service.NotesService notesService *service.NotesService
templates *render.TemplateManager templates *render.TemplateManager
mux *http.ServeMux
} }
func NewHandler(ns *service.NotesService, tm *render.TemplateManager) *Handler { func NewHandler(ns *service.NotesService, tm *render.TemplateManager) *Handler {
return &Handler{ return &Handler{
notesService: ns, notesService: ns,
templates: tm, templates: tm,
mux: http.NewServeMux(),
} }
} }
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Handle root and note list
if path == "/" {
h.handleRoot(w, r)
return
}
// Handle individual notes
if strings.HasPrefix(path, "/notes/") {
h.handleNotes(w, r)
return
}
// Handle 404 for other paths
http.NotFound(w, r)
}
// ViewState is built per-request, not shared // ViewState is built per-request, not shared
type ViewState struct { type ViewState struct {
Notes []*note.Note Notes []*note.Note
CurrentNote *note.Note RenderedNote template.HTML
SortBy string SortBy string
SearchTerm string SearchTerm string
ActiveHash string LastActive string
} }
func (h *Handler) handleRoot(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleRoot(w http.ResponseWriter, r *http.Request) {
@ -52,19 +76,33 @@ func (h *Handler) buildViewState(r *http.Request) (*ViewState, error) {
// Get notes from service // Get notes from service
var notes []*note.Note var notes []*note.Note
var err error
if searchTerm != "" { if searchTerm != "" {
notes = h.notesService.Search(searchTerm) opts := service.QueryOptions{
SearchTerm: searchTerm,
SortBy: sortBy,
}
notes, err = h.notesService.QueryNotes(opts)
if err != nil {
return nil, err
}
} else { } else {
notes = h.notesService.GetNotes() notes = h.notesService.GetNotes()
}
// Apply sorting // Apply sorting
switch sortBy { switch sortBy {
case "recent": case "recent":
service.SortByDate(notes) service.SortByDate(notes)
case "oldest":
service.SortByDateAsc(notes)
case "alpha": case "alpha":
service.SortByTitle(notes) service.SortByTitle(notes)
case "ralpha":
service.SortByTitleAsc(notes)
default:
service.SortByDate(notes)
}
} }
return &ViewState{ return &ViewState{
@ -74,6 +112,21 @@ func (h *Handler) buildViewState(r *http.Request) (*ViewState, error) {
}, nil }, nil
} }
func (h *Handler) SetupRoutes() {
// Set the handler as the main handler for http.DefaultServeMux
http.Handle("/", h)
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("internal/web/static"))))
}
func extractHash(path string) string {
// Extract hash from /notes/{hash}
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) < 2 || parts[0] != "notes" {
return ""
}
return parts[1]
}
func (h *Handler) handleNotes(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleNotes(w http.ResponseWriter, r *http.Request) {
// Build base state // Build base state
state, err := h.buildViewState(r) state, err := h.buildViewState(r)
@ -92,9 +145,16 @@ func (h *Handler) handleNotes(w http.ResponseWriter, r *http.Request) {
return return
} }
// Add to state // Convert markdown to HTML
state.CurrentNote = note htmlContent, err := render.RenderMarkdown([]byte(note.Content))
state.ActiveHash = hash if err != nil {
http.Error(w, "Failed to render markdown", http.StatusInternalServerError)
return
}
h.templates.Render(w, "note", state) // Add to state
state.RenderedNote = htmlContent
state.LastActive = hash
h.templates.Render(w, "index", state)
} }

View File

@ -259,4 +259,13 @@
margin: 1em 0; margin: 1em 0;
border-bottom: dotted 2px var(--heading1); border-bottom: dotted 2px var(--heading1);
} }
.note-title {
display: flex;
}
.note-title a {
padding-left: 1em;
font-size: 0.9em;
}
} }

View File

@ -1,3 +1,4 @@
{{ define "base" }}
<!doctype html> <!doctype html>
<html class="no-js" lang="en"> <html class="no-js" lang="en">
<head> <head>
@ -10,3 +11,4 @@
{{ template "content" . }} {{ template "content" . }}
</body> </body>
</html> </html>
{{ end }}

View File

@ -3,6 +3,6 @@
{{ template "noteList" . }} {{ template "noteList" . }}
{{/* Markdown notes rendering area */}} {{/* Markdown notes rendering area */}}
<main> <main>
{{ .Note }} {{ .RenderedNote }}
</main> </main>
{{ end }} {{ end }}

View File

@ -24,15 +24,15 @@
{{ define "renderSearch" }} {{ define "renderSearch" }}
{{ if ne .SearchTerm "" }}<h2>Matching results for query '{{ .SearchTerm }}'</h2>{{ end }} {{ if ne .SearchTerm "" }}<h2>Matching results for query '{{ .SearchTerm }}'</h2>{{ end }}
<ul class="search-results"> <ul class="search-results">
{{ range .Nodes }} {{ range .Notes }}
{{ if .IsEnd }} <li {{ if eq .ID $.LastActive }}class="active-note"{{ end }}>
<li {{ if eq .Hash $.LastActive }}class="active-note"{{ end }}> <div class="note-title">
<input type="checkbox"/> <input type="checkbox"/>
<a href="/notes/{{ .Hash }}" data-hash="{{ .Hash }}">{{ .Title }}</a> <a href="/notes/{{ .ID }}" data-hash="{{ .ID }}">{{ if ge (len .Title) 30 }}{{printf "%.30s" .Title }}[...]{{ else }} {{ .Title }}{{ end }}</a>
<span class="last-modified">{{ .LastModified }}</span> </div>
<span class="last-modified">{{ .GetUpdateDateRep }}</span>
</li> </li>
{{ end }} {{ end }}
{{ end }}
</ul> </ul>
{{ end }} {{ end }}