draft shits
This commit is contained in:
53
internal/note/note.go
Normal file
53
internal/note/note.go
Normal file
@ -0,0 +1,53 @@
|
||||
package note
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Note struct {
|
||||
ID string
|
||||
Path string
|
||||
Title string
|
||||
Content string
|
||||
// HTMLContent string
|
||||
ModTime time.Time
|
||||
// Directly in Note
|
||||
Tags []string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Published bool
|
||||
}
|
||||
|
||||
func NewNote() *Note {
|
||||
return &Note{}
|
||||
}
|
||||
|
||||
// ExtractTitle return the first level heading content ('# title')
|
||||
func ExtractTitle(mkd string) string {
|
||||
if mkd == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(mkd, "# ") {
|
||||
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
|
||||
}
|
||||
|
||||
func GenerateNoteID(path string) string {
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(path)))[:10]
|
||||
}
|
||||
87
internal/note/note_test.go
Normal file
87
internal/note/note_test.go
Normal file
@ -0,0 +1,87 @@
|
||||
package note
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractTitle(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
markdown string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "first h1 becomes title",
|
||||
markdown: "# My Note\n\nSome content",
|
||||
want: "My Note",
|
||||
},
|
||||
{
|
||||
name: "no h1 uses filename",
|
||||
markdown: "## Just h2\n\nContent",
|
||||
want: "", // or filename from context
|
||||
},
|
||||
{
|
||||
name: "multiple h1s takes first",
|
||||
markdown: "# First\n\n# Second",
|
||||
want: "First",
|
||||
},
|
||||
{
|
||||
name: "h1 with formatting",
|
||||
markdown: "# **Bold** and *italic* title",
|
||||
want: "Bold and italic title",
|
||||
},
|
||||
{
|
||||
name: "empty file",
|
||||
markdown: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ExtractTitle(tt.markdown)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateNoteID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string // SHA-256 hash
|
||||
}{
|
||||
{
|
||||
name: "consistent hashing",
|
||||
path: "notes/test.md",
|
||||
want: "110bab5b8f",
|
||||
},
|
||||
{
|
||||
name: "different paths different ids",
|
||||
path: "notes/other.md",
|
||||
want: "858d15849b",
|
||||
},
|
||||
{
|
||||
name: "root folder as 'current folder'",
|
||||
path: ".",
|
||||
want: "cdb4ee2aea",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := GenerateNoteID(tt.path)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
|
||||
// Test consistency
|
||||
got2 := GenerateNoteID(tt.path)
|
||||
if got != got2 {
|
||||
t.Error("same path should produce same ID")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
77
internal/render/render.go
Normal file
77
internal/render/render.go
Normal file
@ -0,0 +1,77 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/russross/blackfriday/v2"
|
||||
)
|
||||
|
||||
type TemplateData struct {
|
||||
Name string
|
||||
FileNameSet []string
|
||||
}
|
||||
|
||||
type TemplateManager struct {
|
||||
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 (tm *TemplateManager) buildTemplatePath(name string) string {
|
||||
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()
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Cache it (unless in dev mode)
|
||||
if !tm.devMode {
|
||||
tm.mu.Lock()
|
||||
tm.templates[td.Name] = tmpl
|
||||
tm.mu.Unlock()
|
||||
}
|
||||
|
||||
return tmpl, 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
|
||||
}
|
||||
52
internal/render/render_test.go
Normal file
52
internal/render/render_test.go
Normal file
@ -0,0 +1,52 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderMarkdown(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
markdown string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Markdown, no link",
|
||||
markdown: `# Test
|
||||
|
||||
## 01/24/26 09:14:20 - some entry
|
||||
check this out`,
|
||||
want: `<h1>Test</h1><h2>01/24/26 09:14:20 - some entry</h2><p>check this out</p>`,
|
||||
},
|
||||
{
|
||||
name: "Markdown, some link",
|
||||
markdown: `# Test 2
|
||||
## 01/24/26 09:14:20 - some entry (bare link)
|
||||
Check this out http://tatata.toto here
|
||||
`,
|
||||
want: `<h1>Test 2</h1><h2>01/24/26 09:14:20 - some entry (bare link)</h2><p>Check this out <a href="http://tatata.toto" target="_blank">http://tatata.toto</a> here</p>`,
|
||||
},
|
||||
{
|
||||
name: "Markdown, some link with description",
|
||||
markdown: `# Test 2
|
||||
## 01/24/26 09:14:20 - some entry (bare link)
|
||||
Check this out [here](http://tatata.toto)
|
||||
`,
|
||||
want: `<h1>Test 2</h1><h2>01/24/26 09:14:20 - some entry (bare link)</h2><p>Check this out <a href="http://tatata.toto" target="_blank">here</a></p>`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range cases {
|
||||
got, err := RenderMarkdown([]byte(test.markdown))
|
||||
if err != nil {
|
||||
t.Errorf("Error rendering markdown: '%s'\n", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
42
internal/scanner/handler.go
Normal file
42
internal/scanner/handler.go
Normal file
@ -0,0 +1,42 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"donniemarko/internal/note"
|
||||
"donniemarko/internal/storage"
|
||||
"os"
|
||||
)
|
||||
|
||||
type NotesHandler struct {
|
||||
storage storage.Storage
|
||||
}
|
||||
|
||||
func (h *NotesHandler) HandleCreate(path string) error {
|
||||
note, err := ParseNoteFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.storage.Create(note)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *NotesHandler) HandleModify(path string) error {
|
||||
return h.HandleCreate(path)
|
||||
}
|
||||
|
||||
func (h *NotesHandler) HandleDelete(path string) error {
|
||||
id := note.GenerateNoteID(path)
|
||||
h.storage.Delete(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseNoteFile(path string) (*note.Note, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := note.GenerateNoteID(path)
|
||||
nn := note.NewNote()
|
||||
nn.ID = id
|
||||
nn.Content = string(content)
|
||||
return nn, nil
|
||||
}
|
||||
165
internal/scanner/scanner.go
Normal file
165
internal/scanner/scanner.go
Normal file
@ -0,0 +1,165 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ChangeType int
|
||||
|
||||
const (
|
||||
Unchanged ChangeType = iota
|
||||
Created
|
||||
Modified
|
||||
Deleted
|
||||
)
|
||||
|
||||
type ChangeHandler interface {
|
||||
HandleCreate(path string) error
|
||||
HandleModify(path string) error
|
||||
HandleDelete(path string) error
|
||||
}
|
||||
|
||||
type Change struct {
|
||||
Type ChangeType
|
||||
Path string
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
type ScannerService struct {
|
||||
RootDir string
|
||||
interval time.Duration
|
||||
lastStates map[string]time.Time
|
||||
handler ChangeHandler
|
||||
}
|
||||
|
||||
func NewScanner(path string) *ScannerService {
|
||||
return &ScannerService{RootDir: path}
|
||||
}
|
||||
|
||||
func (s *ScannerService) FindAll() ([]string, error) {
|
||||
var notePath []string
|
||||
err := filepath.Walk(s.RootDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// skip the root dir itself
|
||||
if s.RootDir == path {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !isValidNoteFile(path, info) {
|
||||
return nil
|
||||
}
|
||||
|
||||
notePath = append(notePath, path)
|
||||
return nil
|
||||
})
|
||||
return notePath, err
|
||||
}
|
||||
|
||||
func (s *ScannerService) Scan() ([]Change, error) {
|
||||
var changes []Change
|
||||
currentStates := make(map[string]time.Time)
|
||||
|
||||
// Walk filesystem
|
||||
filepath.Walk(s.RootDir, func(path string, info os.FileInfo, err error) error {
|
||||
|
||||
// skip the root dir itself
|
||||
if s.RootDir == path {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !isValidNoteFile(path, info) {
|
||||
return nil
|
||||
}
|
||||
|
||||
currentStates[path] = info.ModTime()
|
||||
|
||||
lastMod, existed := s.lastStates[path]
|
||||
if !existed {
|
||||
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()})
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// Check for deletions
|
||||
for path := range s.lastStates {
|
||||
if _, exists := currentStates[path]; !exists {
|
||||
changes = append(changes, Change{Type: Deleted, Path: path})
|
||||
}
|
||||
}
|
||||
|
||||
s.lastStates = currentStates
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func (s *ScannerService) Monitor(ctx context.Context) error {
|
||||
ticker := time.NewTicker(s.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
changes, err := s.Scan()
|
||||
if err != nil {
|
||||
log.Printf("scan error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, change := range changes {
|
||||
var err error
|
||||
switch change.Type {
|
||||
case Created:
|
||||
err = s.handler.HandleCreate(change.Path)
|
||||
case Modified:
|
||||
err = s.handler.HandleModify(change.Path)
|
||||
case Deleted:
|
||||
err = s.handler.HandleDelete(change.Path)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("handler error for %s: %v", change.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isValidNoteFile(path string, info os.FileInfo) bool {
|
||||
// ignore temp and backup files
|
||||
for _, nono := range []string{".#", "~"} {
|
||||
if strings.Contains(path, nono) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ignore files that are not markdown
|
||||
if info.IsDir() || filepath.Ext(path) != ".md" {
|
||||
return false
|
||||
}
|
||||
|
||||
// ignore empty folder
|
||||
if info.IsDir() {
|
||||
files, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
log.Println(err.Error())
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
88
internal/scanner/scanner_test.go
Normal file
88
internal/scanner/scanner_test.go
Normal file
@ -0,0 +1,88 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestScanner_FindMarkdownFiles(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
files := map[string]string{
|
||||
"note1.md": "# Note 1",
|
||||
"note2.markdown": "# Note 2",
|
||||
"folder/note3.md": "# Note 3",
|
||||
"folder/nested/note4.md": "# Note 4",
|
||||
"readme.txt": "not markdown",
|
||||
}
|
||||
|
||||
for path, content := range files {
|
||||
fullPath := filepath.Join(tmpDir, path)
|
||||
os.MkdirAll(filepath.Dir(fullPath), 0755)
|
||||
os.WriteFile(fullPath, []byte(content), 0644)
|
||||
}
|
||||
|
||||
scanner := NewScanner(tmpDir)
|
||||
found, err := scanner.FindAll()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should only return markdown files
|
||||
if len(found) != 3 {
|
||||
t.Errorf("expected 4 files, got %d", len(found))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanner_DetectsNewFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scanner := NewScanner(tmpDir)
|
||||
|
||||
scanner.Scan() // Initial scan
|
||||
|
||||
os.WriteFile(filepath.Join(tmpDir, "new.md"), []byte("# New"), 0644)
|
||||
|
||||
changes, _ := scanner.Scan()
|
||||
|
||||
if len(changes) != 1 || changes[0].Type != Created {
|
||||
t.Error("should detect new file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanner_DetectChanges(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
filePath := filepath.Join(tmpDir, "test.md")
|
||||
|
||||
// Initial state
|
||||
os.WriteFile(filePath, []byte("# Original"), 0644)
|
||||
|
||||
scanner := NewScanner(tmpDir)
|
||||
changes, _ := scanner.Scan()
|
||||
originalModTime := changes[0].ModTime
|
||||
|
||||
// Wait and modify
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
os.WriteFile(filePath, []byte("# Modified"), 0644)
|
||||
|
||||
changes, _ = scanner.Scan()
|
||||
newModTime := changes[0].ModTime
|
||||
|
||||
if !newModTime.After(originalModTime) {
|
||||
t.Error("should detect file modification")
|
||||
}
|
||||
|
||||
if changes[0].Type != Modified {
|
||||
t.Errorf("Last state should be modified, got '%v'\n", changes[0].Type)
|
||||
}
|
||||
|
||||
newPath := filepath.Join(tmpDir, "test_renamed.md")
|
||||
os.Rename(filePath, newPath)
|
||||
changes, _ = scanner.Scan()
|
||||
|
||||
if changes[0].Path != newPath {
|
||||
t.Errorf("Should find renamed file '%s'. Got '%s'\n", newPath, changes[0].Path)
|
||||
}
|
||||
}
|
||||
76
internal/service/note.go
Normal file
76
internal/service/note.go
Normal file
@ -0,0 +1,76 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"donniemarko/internal/note"
|
||||
"donniemarko/internal/storage"
|
||||
)
|
||||
|
||||
|
||||
type NotesService struct {
|
||||
storage storage.Storage
|
||||
}
|
||||
|
||||
type SortOption func([]*note.Note)
|
||||
|
||||
type QueryOptions struct {
|
||||
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 SortByTitle(notes []*note.Note) {
|
||||
sort.Slice(notes, func(i, j int) bool {
|
||||
return notes[i].Title < notes[j].Title
|
||||
})
|
||||
}
|
||||
|
||||
func SortByDateAsc(notes []*note.Note) {
|
||||
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 (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
|
||||
}
|
||||
69
internal/service/note_test.go
Normal file
69
internal/service/note_test.go
Normal file
@ -0,0 +1,69 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"donniemarko/internal/note"
|
||||
"donniemarko/internal/storage"
|
||||
)
|
||||
|
||||
var service *NotesService
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
storage := storage.NewNoteStorage()
|
||||
|
||||
notes := []*note.Note{
|
||||
{Title: "Golang Tutorial", Content: "Learn Go"},
|
||||
{Title: "Rust Guide", Content: "Learn Rust"},
|
||||
}
|
||||
for _, note := range notes {
|
||||
storage.Create(note)
|
||||
}
|
||||
|
||||
service = NewNoteService()
|
||||
service.storage = 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")
|
||||
}
|
||||
}
|
||||
67
internal/storage/storage.go
Normal file
67
internal/storage/storage.go
Normal file
@ -0,0 +1,67 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"donniemarko/internal/note"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Storage interface {
|
||||
GetAll() []*note.Note
|
||||
Get(id string) (*note.Note, error)
|
||||
Create(n *note.Note) error
|
||||
Delete(id string)
|
||||
Update(id string, n *note.Note)
|
||||
Search(query string) []*note.Note
|
||||
}
|
||||
|
||||
type NoteStorage struct {
|
||||
Index map[string]*note.Note
|
||||
}
|
||||
|
||||
func NewNoteStorage() *NoteStorage {
|
||||
return &NoteStorage{Index: make(map[string]*note.Note)}
|
||||
}
|
||||
|
||||
func (ns *NoteStorage) GetAll() []*note.Note {
|
||||
notes := make([]*note.Note, 0, len(ns.Index))
|
||||
|
||||
// Step 3: Iterate over the map
|
||||
for _, value := range ns.Index {
|
||||
notes = append(notes, value)
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
func (ns *NoteStorage) Get(id string) (*note.Note, error) {
|
||||
n, ok := ns.Index[id]
|
||||
if ok {
|
||||
return n, nil
|
||||
}
|
||||
return nil, fmt.Errorf("No note with id '%s'", id)
|
||||
}
|
||||
|
||||
func (ns *NoteStorage) Create(n *note.Note) error {
|
||||
ns.Index[n.ID] = n
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ns *NoteStorage) Delete(id string) {
|
||||
delete(ns.Index, id)
|
||||
}
|
||||
|
||||
func (ns *NoteStorage) Update(id string, n *note.Note) {
|
||||
ns.Index[id] = n
|
||||
}
|
||||
|
||||
func (ns *NoteStorage) Search(query string) []*note.Note{
|
||||
results := []*note.Note{}
|
||||
for _, note := range ns.Index {
|
||||
lowContent := strings.ToLower(string(note.Content))
|
||||
lowQuery := strings.ToLower(query)
|
||||
if strings.Contains(lowContent, lowQuery) {
|
||||
results = append(results, note)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
53
internal/storage/storage_test.go
Normal file
53
internal/storage/storage_test.go
Normal file
@ -0,0 +1,53 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"donniemarko/internal/note"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var ns *NoteStorage
|
||||
var n1, n2 *note.Note
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
ns = NewNoteStorage()
|
||||
n1 = note.NewNote()
|
||||
n1.Path = "test/note1.md"
|
||||
n1.ID = note.GenerateNoteID(n1.Path)
|
||||
n1.Content = "# hola amigo"
|
||||
|
||||
n2 = note.NewNote()
|
||||
n2.Path = "note2.md"
|
||||
n2.ID = note.GenerateNoteID(n2.Path)
|
||||
n2.Content = "# ah si ?"
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestNoteStorageCreate(t *testing.T) {
|
||||
ns.Create(n1)
|
||||
ns.Create(n2)
|
||||
|
||||
if len(ns.Index) < 2 {
|
||||
t.Errorf("Creating notes should add them to the storage. Wanted 2, got '%v'", len(ns.Index))
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteStorageGetUpdate(t *testing.T) {
|
||||
ns.Update(n2.ID, n1)
|
||||
|
||||
nn2, err := ns.Get(n2.ID)
|
||||
if err != nil {
|
||||
t.Errorf("Error retrieving note with id '%s': '%v'", n2.ID, err)
|
||||
}
|
||||
|
||||
if nn2.Content != n1.Content {
|
||||
t.Errorf("Updating a note should reflect it in storage. Wanted '%s', got '%s'\n", n1.Content, nn2.Content)
|
||||
}
|
||||
}
|
||||
100
internal/web/handler.go
Normal file
100
internal/web/handler.go
Normal file
@ -0,0 +1,100 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"donniemarko/internal/service"
|
||||
"donniemarko/internal/render"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
notesService *service.NotesService
|
||||
templates *render.TemplateManager
|
||||
}
|
||||
|
||||
func NewHandler(ns *service.NotesService, tm *render.TemplateManager) *Handler {
|
||||
return &Handler{
|
||||
notesService: ns,
|
||||
templates: tm,
|
||||
}
|
||||
}
|
||||
|
||||
// ViewState is built per-request, not shared
|
||||
type ViewState struct {
|
||||
Notes []*note.Note
|
||||
CurrentNote *note.Note
|
||||
SortBy string
|
||||
SearchTerm string
|
||||
ActiveHash 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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
262
internal/web/static/css/main.css
Normal file
262
internal/web/static/css/main.css
Normal file
@ -0,0 +1,262 @@
|
||||
:root {
|
||||
--background1: #ffffff;
|
||||
--background2: #f0f0f0;
|
||||
--background3: #c4c3c3;
|
||||
--heading1: #2a2a2a;
|
||||
--heading2: #4a4a4a;
|
||||
--heading3: #7c7b7b;
|
||||
--text1: #5a5a5a;
|
||||
--code1: #6a6a6a;
|
||||
--url: #090b0e;
|
||||
--border-color: #d0d0d0;
|
||||
--font-main: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
--font-size-base: 16px;
|
||||
--line-height: 1.5;
|
||||
--padding-main: 1cm;
|
||||
--max-width-main: 210mm;
|
||||
}
|
||||
|
||||
@page {
|
||||
size: A4;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
margin: 2cm 1.5cm 2cm 1.5cm;
|
||||
}
|
||||
|
||||
/* Don't display the list of notes in the printed page */
|
||||
aside,
|
||||
.note-metadata {
|
||||
display: none;
|
||||
}
|
||||
|
||||
main {
|
||||
margin-top: 0;
|
||||
break-after: always;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen {
|
||||
|
||||
aside,
|
||||
main {
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
@media all {
|
||||
body {
|
||||
display: flex;
|
||||
background: var(--background1);
|
||||
margin: 0;
|
||||
font-family: var(--font-main);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height);
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--url);
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--background2);
|
||||
padding: 0.8em;
|
||||
}
|
||||
|
||||
pre code {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
line-break: loose;
|
||||
background-color: var(--background2);
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
color: var(--code1);
|
||||
overflow-x: auto;
|
||||
text-wrap: wrap;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
color: var(--heading1);
|
||||
font-size: 2em;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 1em;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
aside {
|
||||
flex: 0 0 400px;
|
||||
width: 250px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: var(--padding-main);
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
aside ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
aside ul li {
|
||||
padding: 0.5em 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
aside ul li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
aside ul li a {
|
||||
display: block;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
aside ul li a:hover {
|
||||
background-color: var(--background2);
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
margin: 0 auto;
|
||||
padding: var(--padding-main);
|
||||
overflow-y: auto;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
main a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
main ul {
|
||||
padding: 0;
|
||||
margin: 0 1em;
|
||||
}
|
||||
|
||||
main h1 {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 0.3em;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
main h2 {
|
||||
padding: 0.7em 0;
|
||||
border-bottom: 2px dotted var(--border-color);
|
||||
}
|
||||
|
||||
|
||||
main h3, main h4 {
|
||||
color: var(--heading3);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
margin-bottom: 0.5em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--heading1);
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: var(--heading1);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: var(--heading2);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1em 0;
|
||||
padding: 0 1em;
|
||||
border-left: 3px solid var(--border-color);
|
||||
color: var(--heading2);
|
||||
}
|
||||
|
||||
.active-note {
|
||||
background: var(--background3);
|
||||
}
|
||||
|
||||
.last-modified {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.folder {
|
||||
font-weight: bold;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.folder span {
|
||||
padding: 0 1em;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.search-form > * {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Add this to the end of the file */
|
||||
.search-bar {
|
||||
width: 100%;
|
||||
padding: 0.5em;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-main);
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.search-bar:focus {
|
||||
outline: none;
|
||||
border-color: var(--heading1);
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
margin-bottom: 1em;
|
||||
margin: 1em 0;
|
||||
border-bottom: dotted 2px var(--heading1);
|
||||
}
|
||||
}
|
||||
12
internal/web/templates/base.tmpl
Normal file
12
internal/web/templates/base.tmpl
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html class="no-js" lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Donnie Marko</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/static/css/main.css" type="text/css">
|
||||
</head>
|
||||
<body>
|
||||
{{ template "content" . }}
|
||||
</body>
|
||||
</html>
|
||||
8
internal/web/templates/index.tmpl
Normal file
8
internal/web/templates/index.tmpl
Normal file
@ -0,0 +1,8 @@
|
||||
{{ define "content" }}
|
||||
{{/* List of notes and searching utilities */}}
|
||||
{{ template "noteList" . }}
|
||||
{{/* Markdown notes rendering area */}}
|
||||
<main>
|
||||
{{ .Note }}
|
||||
</main>
|
||||
{{ end }}
|
||||
55
internal/web/templates/noteList.tmpl
Normal file
55
internal/web/templates/noteList.tmpl
Normal file
@ -0,0 +1,55 @@
|
||||
{{ define "noteList" }}
|
||||
<aside>
|
||||
<header>
|
||||
<h1 class="main-logo"><a href="/">Donnie Marko</a></h1>
|
||||
<form method="GET" action="/" class="search-form">
|
||||
<input type="text" name="search" class="search-bar" placeholder="Search... (empty query to clear)">
|
||||
<input type="submit" value="ok"/>
|
||||
</form>
|
||||
<form method="GET" action="/">
|
||||
<select name="sort" value="sort" class="sort-dropdown">
|
||||
<option value="" disabled {{ if eq "" .SortBy }}selected{{ end }}>Sort by</option>
|
||||
<option value="recent" {{ if eq "recent" .SortBy }}selected{{ end }}>Recent</option>
|
||||
<option value="oldest" {{ if eq "oldest" .SortBy }}selected{{ end }}>Oldest</option>
|
||||
<option value="alpha" {{ if eq "alpha" .SortBy }}selected{{ end }}>Alphabetical</option>
|
||||
<option value="ralpha" {{ if eq "ralpha" .SortBy }}selected{{ end }}>Reverse Alphabetical</option>
|
||||
</select>
|
||||
<input type="submit" value="sort" />
|
||||
</form>
|
||||
</header>
|
||||
{{ template "renderSearch" . }}
|
||||
</aside>
|
||||
{{ end }}
|
||||
|
||||
{{ 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 }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
|
||||
|
||||
{{/* not used for now, the opposition between flat list from hashmap and tree structure is confusing */}}
|
||||
{{ define "renderTree" }}
|
||||
<ul>
|
||||
{{ range . }}
|
||||
{{ if .IsEnd }}
|
||||
<li><input type="checkbox"/><a href="/notes/{{ .Hash }}" data-hash="{{ .Hash }}">{{ .Title }}</a><span class="last-modified">{{ .LastModified }}</span></li>
|
||||
{{ else }}
|
||||
{{ if .Children }}
|
||||
<li><div class="folder"><input type="checkbox"/><span class="folder">{{ .Path }}</span></folder>
|
||||
{{ template "renderTree" .Children }}
|
||||
</li>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
Reference in New Issue
Block a user