possibly first working draft
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
_bin
|
||||||
|
.#*
|
||||||
2
Makefile
2
Makefile
@ -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/
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
gator@gargantua.368651:1769498985
|
|
||||||
42
cmd/main.go
42
cmd/main.go
@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
func SortByTitleAsc(notes []*note.Note) {
|
||||||
sortBy(notes)
|
sort.Slice(notes, func(i, j int) bool {
|
||||||
}
|
return notes[i].Title > notes[j].Title
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return notes, nil
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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{
|
opts := QueryOptions{
|
||||||
SearchTerm: "golang",
|
SearchTerm: "Go",
|
||||||
SortBy: "alpha",
|
SortBy: "alpha",
|
||||||
}
|
}
|
||||||
|
|
||||||
results, err := service.QueryNotes(opts)
|
results, err := service.QueryNotes(opts)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(results) != 1 {
|
if len(results) != 1 {
|
||||||
t.Errorf("expected 1 result, got %d", len(results))
|
t.Errorf("expected 1 result, got %d", len(results))
|
||||||
}
|
}
|
||||||
|
|
||||||
if results[0].Title != "Golang Tutorial" {
|
if results[0].Title != "Golang Tutorial" {
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 != "" {
|
if searchTerm != "" {
|
||||||
notes = h.notesService.Search(searchTerm)
|
opts := service.QueryOptions{
|
||||||
} else {
|
SearchTerm: searchTerm,
|
||||||
notes = h.notesService.GetNotes()
|
SortBy: sortBy,
|
||||||
}
|
}
|
||||||
|
notes, err = h.notesService.QueryNotes(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notes = h.notesService.GetNotes()
|
||||||
|
|
||||||
// Apply sorting
|
// Apply sorting
|
||||||
switch sortBy {
|
switch sortBy {
|
||||||
case "recent":
|
case "recent":
|
||||||
service.SortByDate(notes)
|
service.SortByDate(notes)
|
||||||
case "alpha":
|
case "oldest":
|
||||||
service.SortByTitle(notes)
|
service.SortByDateAsc(notes)
|
||||||
}
|
case "alpha":
|
||||||
|
service.SortByTitle(notes)
|
||||||
|
case "ralpha":
|
||||||
|
service.SortByTitleAsc(notes)
|
||||||
|
default:
|
||||||
|
service.SortByDate(notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &ViewState{
|
return &ViewState{
|
||||||
Notes: notes,
|
Notes: notes,
|
||||||
SortBy: sortBy,
|
SortBy: sortBy,
|
||||||
SearchTerm: searchTerm,
|
SearchTerm: searchTerm,
|
||||||
}, 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)
|
||||||
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
h.templates.Render(w, "note", state)
|
// Add to state
|
||||||
|
state.RenderedNote = htmlContent
|
||||||
|
state.LastActive = hash
|
||||||
|
|
||||||
|
h.templates.Render(w, "index", state)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }}
|
||||||
@ -3,6 +3,6 @@
|
|||||||
{{ template "noteList" . }}
|
{{ template "noteList" . }}
|
||||||
{{/* Markdown notes rendering area */}}
|
{{/* Markdown notes rendering area */}}
|
||||||
<main>
|
<main>
|
||||||
{{ .Note }}
|
{{ .RenderedNote }}
|
||||||
</main>
|
</main>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@ -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 }}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user