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

View File

@ -25,27 +25,45 @@ func NewNote() *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')
func ExtractTitle(mkd string) string {
if mkd == "" {
return ""
}
if !strings.HasPrefix(mkd, "# ") {
return ""
lines := strings.Split(mkd, "\n")
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
for _, c := range strings.TrimLeft(mkd, "# ") {
if strings.Contains("*~", string(c)) {
continue
}
if string(c) == "\n" {
break
}
title = title + string(c)
}
return title
// 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 {

View File

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

View File

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

View File

@ -1,15 +1,22 @@
package scanner
import (
"path/filepath"
"donniemarko/internal/note"
"donniemarko/internal/storage"
"os"
// "log"
)
type NotesHandler struct {
storage storage.Storage
}
func NewNotesHandler(storage storage.Storage) *NotesHandler {
return &NotesHandler{storage: storage}
}
func (h *NotesHandler) HandleCreate(path string) error {
note, err := ParseNoteFile(path)
if err != nil {
@ -34,9 +41,23 @@ func ParseNoteFile(path string) (*note.Note, error) {
if err != nil {
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)
nn := note.NewNote()
nn.ID = id
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
}

View File

@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"time"
// "donniemarko/internal/note"
)
type ChangeType int
@ -32,13 +33,21 @@ type Change struct {
type ScannerService struct {
RootDir string
interval time.Duration
lastStates map[string]time.Time
Interval time.Duration
LastStates map[string]time.Time
handler ChangeHandler
}
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) {
@ -63,6 +72,8 @@ func (s *ScannerService) FindAll() ([]string, error) {
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) {
var changes []Change
currentStates := make(map[string]time.Time)
@ -81,8 +92,10 @@ func (s *ScannerService) Scan() ([]Change, error) {
currentStates[path] = info.ModTime()
lastMod, existed := s.lastStates[path]
lastMod, existed := s.LastStates[path]
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})
} else if info.ModTime().After(lastMod) {
changes = append(changes, Change{Type: Modified, Path: path, ModTime: info.ModTime()})
@ -92,18 +105,20 @@ func (s *ScannerService) Scan() ([]Change, error) {
})
// Check for deletions
for path := range s.lastStates {
for path := range s.LastStates {
if _, exists := currentStates[path]; !exists {
changes = append(changes, Change{Type: Deleted, Path: path})
}
}
s.lastStates = currentStates
s.LastStates = currentStates
return changes, nil
}
// Monitor rescan the root folder at each new tick and handle state
// modification
func (s *ScannerService) Monitor(ctx context.Context) error {
ticker := time.NewTicker(s.interval)
ticker := time.NewTicker(s.Interval)
defer ticker.Stop()
for {

View File

@ -1,76 +1,93 @@
package service
import (
"sort"
"donniemarko/internal/note"
"donniemarko/internal/storage"
"sort"
)
type NotesService struct {
storage storage.Storage
storage storage.Storage
}
type SortOption func([]*note.Note)
type QueryOptions struct {
SearchTerm string
SortBy string
SearchTerm string
SortBy string
}
func NewNoteService() *NotesService {
return &NotesService{}
}
func SortByDate(notes []*note.Note) {
sort.Slice(notes, func(i, j int) bool {
return notes[i].UpdatedAt.After(notes[j].UpdatedAt)
})
func (s *NotesService) SetStorage(storage storage.Storage) {
s.storage = storage
}
func SortByTitle(notes []*note.Note) {
sort.Slice(notes, func(i, j int) bool {
return notes[i].Title < notes[j].Title
})
func SortByDate(notes []*note.Note) {
sort.Slice(notes, func(i, j int) bool {
return notes[i].UpdatedAt.After(notes[j].UpdatedAt)
})
}
func SortByDateAsc(notes []*note.Note) {
sort.Slice(notes, func(i, j int) bool {
return notes[i].UpdatedAt.Before(notes[j].UpdatedAt)
})
sort.Slice(notes, func(i, j int) bool {
return notes[i].UpdatedAt.Before(notes[j].UpdatedAt)
})
}
func (s *NotesService) GetNotes(sortBy SortOption) ([]*note.Note, error) {
notes := s.storage.GetAll()
if sortBy != nil {
sortBy(notes)
}
return notes, nil
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()
if sortBy != nil {
sortBy(notes)
}
return notes, nil
}
func (s *NotesService) QueryNotes(opts QueryOptions) ([]*note.Note, error) {
var notes []*note.Note
// Search or get all
if opts.SearchTerm != "" {
notes = s.storage.Search(opts.SearchTerm)
} else {
notes = s.storage.GetAll()
}
// Apply sorting
switch opts.SortBy {
case "recent":
SortByDate(notes)
case "alpha":
SortByTitle(notes)
case "oldest":
SortByDateAsc(notes)
default:
SortByDate(notes) // Default sort
}
return notes, nil
var notes []*note.Note
// Search or get all
if opts.SearchTerm != "" {
notes = s.storage.Search(opts.SearchTerm)
} else {
notes = s.storage.GetAll()
}
// Apply sorting
switch opts.SortBy {
case "recent":
SortByDate(notes)
case "alpha":
SortByTitle(notes)
case "oldest":
SortByDateAsc(notes)
default:
SortByDate(notes) // Default sort
}
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
import (
"net/http/httptest"
"testing"
"donniemarko/internal/note"
@ -14,56 +13,36 @@ func TestMain(m *testing.M) {
storage := storage.NewNoteStorage()
notes := []*note.Note{
{Title: "Golang Tutorial", Content: "Learn Go"},
{Title: "Rust Guide", Content: "Learn Rust"},
{ID: "test1", Title: "Golang Tutorial", Content: "Learn Go"},
{ID: "test2", Title: "Rust Guide", Content: "Learn Rust"},
}
for _, note := range notes {
storage.Create(note)
}
service = NewNoteService()
service.storage = storage
service.SetStorage(storage)
m.Run()
}
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) {
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")
}
opts := QueryOptions{
SearchTerm: "Go",
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")
}
}

View File

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

View File

@ -35,7 +35,7 @@ func TestNoteStorageDelete(t *testing.T) {
ns.Delete(n1.ID)
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
import (
"net/http"
"donniemarko/internal/service"
"donniemarko/internal/render"
"donniemarko/internal/note"
"donniemarko/internal/render"
"donniemarko/internal/service"
"html/template"
"net/http"
"strings"
)
type Handler struct {
notesService *service.NotesService
templates *render.TemplateManager
notesService *service.NotesService
templates *render.TemplateManager
mux *http.ServeMux
}
func NewHandler(ns *service.NotesService, tm *render.TemplateManager) *Handler {
return &Handler{
notesService: ns,
templates: tm,
}
return &Handler{
notesService: ns,
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
type ViewState struct {
Notes []*note.Note
CurrentNote *note.Note
SortBy string
SearchTerm string
ActiveHash string
Notes []*note.Note
RenderedNote template.HTML
SortBy string
SearchTerm string
LastActive string
}
func (h *Handler) handleRoot(w http.ResponseWriter, r *http.Request) {
// Build view state from query params
state, err := h.buildViewState(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Render with state
h.templates.Render(w, "index", state)
// Build view state from query params
state, err := h.buildViewState(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Render with state
h.templates.Render(w, "index", state)
}
func (h *Handler) buildViewState(r *http.Request) (*ViewState, error) {
query := r.URL.Query()
// Extract params
sortBy := query.Get("sort")
if sortBy == "" {
sortBy = "recent"
}
searchTerm := query.Get("search")
// Get notes from service
var notes []*note.Note
if searchTerm != "" {
notes = h.notesService.Search(searchTerm)
} else {
notes = h.notesService.GetNotes()
}
// Apply sorting
switch sortBy {
case "recent":
service.SortByDate(notes)
case "alpha":
service.SortByTitle(notes)
}
return &ViewState{
Notes: notes,
SortBy: sortBy,
SearchTerm: searchTerm,
}, nil
query := r.URL.Query()
// Extract params
sortBy := query.Get("sort")
if sortBy == "" {
sortBy = "recent"
}
searchTerm := query.Get("search")
// Get notes from service
var notes []*note.Note
var err error
if searchTerm != "" {
opts := service.QueryOptions{
SearchTerm: searchTerm,
SortBy: sortBy,
}
notes, err = h.notesService.QueryNotes(opts)
if err != nil {
return nil, err
}
} else {
notes = h.notesService.GetNotes()
// Apply sorting
switch sortBy {
case "recent":
service.SortByDate(notes)
case "oldest":
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) {
// Build base state
state, err := h.buildViewState(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Extract hash from URL
hash := extractHash(r.URL.Path)
// Get specific note
note, err := h.notesService.GetNoteByHash(hash)
if err != nil {
http.Error(w, "Note not found", http.StatusNotFound)
return
}
// Add to state
state.CurrentNote = note
state.ActiveHash = hash
h.templates.Render(w, "note", state)
// Build base state
state, err := h.buildViewState(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Extract hash from URL
hash := extractHash(r.URL.Path)
// Get specific note
note, err := h.notesService.GetNoteByHash(hash)
if err != nil {
http.Error(w, "Note not found", http.StatusNotFound)
return
}
// Convert markdown to HTML
htmlContent, err := render.RenderMarkdown([]byte(note.Content))
if err != nil {
http.Error(w, "Failed to render markdown", http.StatusInternalServerError)
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;
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>
<html class="no-js" lang="en">
<head>
@ -9,4 +10,5 @@
<body>
{{ template "content" . }}
</body>
</html>
</html>
{{ end }}

View File

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

View File

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