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, "# ") {
// Extract title from # heading
title := strings.TrimPrefix(line, "# ")
title = strings.TrimSpace(title)
// Remove common markdown formatting
title = removeMarkdownFormatting(title)
return title
}
} }
return ""
}
var title string // removeMarkdownFormatting removes common markdown formatting from text
for _, c := range strings.TrimLeft(mkd, "# ") { func removeMarkdownFormatting(text string) string {
if strings.Contains("*~", string(c)) { // Remove **bold** and *italic* formatting
continue result := text
} result = strings.ReplaceAll(result, "**", "")
if string(c) == "\n" { result = strings.ReplaceAll(result, "*", "")
break result = strings.ReplaceAll(result, "_", "")
} result = strings.ReplaceAll(result, "`", "")
title = title + string(c) result = strings.ReplaceAll(result, "~~", "")
}
return title // Clean up multiple spaces
result = strings.Join(strings.Fields(result), " ")
return result
} }
func GenerateNoteID(path string) string { func GenerateNoteID(path string) string {

View File

@ -1,77 +1,108 @@
package render package render
import ( import (
"fmt" "fmt"
"html/template" "html/template"
"path/filepath" "net/http"
"sync" "path/filepath"
"sync"
"github.com/russross/blackfriday/v2" "github.com/russross/blackfriday/v2"
) )
type TemplateData struct { type TemplateData struct {
Name string Name string
FileNameSet []string FileNameSet []string
} }
type TemplateManager struct { type TemplateManager struct {
templates map[string]*template.Template templates map[string]*template.Template
mu sync.RWMutex mu sync.RWMutex
basePath string basePath string
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,
} }
} }
func (tm *TemplateManager) buildTemplatePath(name string) string { func (tm *TemplateManager) buildTemplatePath(name string) string {
return filepath.Join(tm.basePath, name+".tmpl") return filepath.Join(tm.basePath, name+".tmpl")
} }
func (tm *TemplateManager) GetTemplate(td *TemplateData) (*template.Template, error) { func (tm *TemplateManager) GetTemplate(td *TemplateData) (*template.Template, error) {
// Skip cache in dev mode // Skip cache in dev mode
if !tm.devMode { if !tm.devMode {
tm.mu.RLock() tm.mu.RLock()
if tmpl, exists := tm.templates[td.Name]; exists { if tmpl, exists := tm.templates[td.Name]; exists {
tm.mu.RUnlock() tm.mu.RUnlock()
return tmpl, nil return tmpl, nil
} }
tm.mu.RUnlock() tm.mu.RUnlock()
} }
// Build file paths // Build file paths
var files []string var files []string
for _, file := range td.FileNameSet { for _, file := range td.FileNameSet {
files = append(files, tm.buildTemplatePath(file)) files = append(files, tm.buildTemplatePath(file))
} }
// Parse template // Parse template
tmpl, err := template.ParseFiles(files...) tmpl, err := template.ParseFiles(files...)
if err != nil { if err != nil {
return nil, fmt.Errorf("parse template %s: %w", td.Name, err) return nil, fmt.Errorf("parse template %s: %w", td.Name, err)
} }
// Cache it (unless in dev mode) // Cache it (unless in dev mode)
if !tm.devMode { if !tm.devMode {
tm.mu.Lock() tm.mu.Lock()
tm.templates[td.Name] = tmpl tm.templates[td.Name] = tmpl
tm.mu.Unlock() tm.mu.Unlock()
} }
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{
Flags: blackfriday.CommonHTMLFlags | blackfriday.HrefTargetBlank, Flags: blackfriday.CommonHTMLFlags | blackfriday.HrefTargetBlank,
}) })
html := blackfriday.Run(content, blackfriday.WithRenderer(renderer)) html := blackfriday.Run(content, blackfriday.WithRenderer(renderer))
return template.HTML(html), nil return template.HTML(html), nil
} }

View File

@ -7,9 +7,9 @@ import (
func TestRenderMarkdown(t *testing.T) { func TestRenderMarkdown(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
markdown string markdown string
want string want string
}{ }{
{ {
name: "Markdown, no link", name: "Markdown, no link",
@ -44,7 +44,7 @@ Check this out [here](http://tatata.toto)
} }
strip := strings.ReplaceAll(string(got), "\n", "") strip := strings.ReplaceAll(string(got), "\n", "")
strip = strings.Trim(strip, " ") strip = strings.Trim(strip, " ")
if strip != test.want { if strip != test.want {
t.Errorf("Rendering markdown: Wanted '%s', got '%s'.\n", test.want, strip) t.Errorf("Rendering markdown: Wanted '%s', got '%s'.\n", test.want, strip)
} }

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,76 +1,93 @@
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
} }
type SortOption func([]*note.Note) type SortOption func([]*note.Note)
type QueryOptions struct { type QueryOptions struct {
SearchTerm string SearchTerm string
SortBy string SortBy string
} }
func NewNoteService() *NotesService { func NewNoteService() *NotesService {
return &NotesService{} return &NotesService{}
} }
func SortByDate(notes []*note.Note) { func (s *NotesService) SetStorage(storage storage.Storage) {
sort.Slice(notes, func(i, j int) bool { s.storage = storage
return notes[i].UpdatedAt.After(notes[j].UpdatedAt)
})
} }
func SortByTitle(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].Title < notes[j].Title return notes[i].UpdatedAt.After(notes[j].UpdatedAt)
}) })
} }
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) {
notes := s.storage.GetAll() sort.Slice(notes, func(i, j int) bool {
return notes[i].Title < notes[j].Title
if sortBy != nil { })
sortBy(notes) }
}
func SortByTitleAsc(notes []*note.Note) {
return notes, nil 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()
if sortBy != nil {
sortBy(notes)
}
return notes, nil
} }
func (s *NotesService) QueryNotes(opts QueryOptions) ([]*note.Note, error) { func (s *NotesService) QueryNotes(opts QueryOptions) ([]*note.Note, error) {
var notes []*note.Note var notes []*note.Note
// Search or get all // Search or get all
if opts.SearchTerm != "" { if opts.SearchTerm != "" {
notes = s.storage.Search(opts.SearchTerm) notes = s.storage.Search(opts.SearchTerm)
} else { } else {
notes = s.storage.GetAll() notes = s.storage.GetAll()
} }
// Apply sorting // Apply sorting
switch opts.SortBy { switch opts.SortBy {
case "recent": case "recent":
SortByDate(notes) SortByDate(notes)
case "alpha": case "alpha":
SortByTitle(notes) SortByTitle(notes)
case "oldest": case "oldest":
SortByDateAsc(notes) SortByDateAsc(notes)
default: default:
SortByDate(notes) // Default sort SortByDate(notes) // Default sort
} }
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,56 +13,36 @@ 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{
SearchTerm: "golang",
SortBy: "alpha",
}
results, err := service.QueryNotes(opts)
if err != nil {
t.Fatal(err)
}
if len(results) != 1 {
t.Errorf("expected 1 result, got %d", len(results))
}
if results[0].Title != "Golang Tutorial" {
t.Error("wrong note returned")
}
}
func TestHandler_BuildViewState(t *testing.T) { opts := QueryOptions{
handler := NewHandler(service, nil) SearchTerm: "Go",
SortBy: "alpha",
req := httptest.NewRequest("GET", "/?search=test&sort=alpha", nil) }
state, err := handler.buildViewState(req) results, err := service.QueryNotes(opts)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if state.SearchTerm != "test" { if len(results) != 1 {
t.Error("search term not extracted") t.Errorf("expected 1 result, got %d", len(results))
} }
if state.SortBy != "alpha" { if results[0].Title != "Golang Tutorial" {
t.Error("sort option not extracted") t.Error("wrong note returned")
} }
} }

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,13 +24,13 @@ 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) }
}
return notes return notes
} }
@ -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,100 +1,160 @@
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) {
// Build view state from query params // Build view state from query params
state, err := h.buildViewState(r) state, err := h.buildViewState(r)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
// Render with state // Render with state
h.templates.Render(w, "index", state) h.templates.Render(w, "index", state)
} }
func (h *Handler) buildViewState(r *http.Request) (*ViewState, error) { func (h *Handler) buildViewState(r *http.Request) (*ViewState, error) {
query := r.URL.Query() query := r.URL.Query()
// Extract params // Extract params
sortBy := query.Get("sort") sortBy := query.Get("sort")
if sortBy == "" { if sortBy == "" {
sortBy = "recent" sortBy = "recent"
} }
searchTerm := query.Get("search") searchTerm := query.Get("search")
// Get notes from service // Get notes from service
var notes []*note.Note var notes []*note.Note
var err error
if searchTerm != "" {
notes = h.notesService.Search(searchTerm) if searchTerm != "" {
} else { opts := service.QueryOptions{
notes = h.notesService.GetNotes() SearchTerm: searchTerm,
} SortBy: sortBy,
}
// Apply sorting notes, err = h.notesService.QueryNotes(opts)
switch sortBy { if err != nil {
case "recent": return nil, err
service.SortByDate(notes) }
case "alpha": } else {
service.SortByTitle(notes) notes = h.notesService.GetNotes()
}
// Apply sorting
return &ViewState{ switch sortBy {
Notes: notes, case "recent":
SortBy: sortBy, service.SortByDate(notes)
SearchTerm: searchTerm, case "oldest":
}, nil service.SortByDateAsc(notes)
case "alpha":
service.SortByTitle(notes)
case "ralpha":
service.SortByTitleAsc(notes)
default:
service.SortByDate(notes)
}
}
return &ViewState{
Notes: notes,
SortBy: sortBy,
SearchTerm: searchTerm,
}, 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)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
// Extract hash from URL // Extract hash from URL
hash := extractHash(r.URL.Path) hash := extractHash(r.URL.Path)
// Get specific note // Get specific note
note, err := h.notesService.GetNoteByHash(hash) note, err := h.notesService.GetNoteByHash(hash)
if err != nil { if err != nil {
http.Error(w, "Note not found", http.StatusNotFound) http.Error(w, "Note not found", http.StatusNotFound)
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)
h.templates.Render(w, "note", state) return
}
// 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>
@ -9,4 +10,5 @@
<body> <body>
{{ 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>
</li> <span class="last-modified">{{ .GetUpdateDateRep }}</span>
{{ end }} </li>
{{ end }} {{ end }}
</ul> </ul>
{{ end }} {{ end }}
@ -52,4 +52,4 @@
{{ end }} {{ end }}
{{ end }} {{ end }}
</ul> </ul>
{{ end }} {{ end }}