diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..1864b23 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +build: + mkdir -p _bin + go build -o _bin/donniemarko + +install: + cp bin/donniemarko ~/.local/bin/ + +test: + go test -v -cover ./... + +run: + go run main.go + +all: build install diff --git a/README.md b/README.md new file mode 100644 index 0000000..55eb4b5 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# donniemarko + +Knowledge Management System over markdown notes. + +`donniemarko` works as a read-only (for now) interface over a set of markdown notes. Its goals are: +- Ensuring notes intented to be published online are correctly formatted +- Rendering the notes in a printable-friendly format, taking advantage of HTML/CSS styling +- Providing an interface to aggregate the content of those notes for quickly retrieving bits of information through searching and filtering +- Providing an interface to cross-reference those notes through a tagging system, in the same fashion as a blog or a wiki diff --git a/cmd/.#main.go b/cmd/.#main.go new file mode 120000 index 0000000..888ccc7 --- /dev/null +++ b/cmd/.#main.go @@ -0,0 +1 @@ +gator@gargantua.368651:1769498985 \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..af73ada --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "flag" + "log" + "net/http" + + "kadath.corp/git/adminoo/donniemarko/web" + + "kadath.corp/git/adminoo/donniemarko/models" +) + +func main() { + // Define command line flags + var help bool + rootFolder := flag.String("root", ".", "Root folder to serve files from") + listenAddr := flag.String("addr", "localhost:5555", "Address to listen on") + flag.BoolVar(&help, "help", false, "display this program usage") + flag.Parse() + + if help { + flag.PrintDefaults() + return + } + + // Initialize the directory manager + dm := models.NewTree(*rootFolder) + go dm.MonitorFileChange() + + tm := web.NewTemplateManager("web/templates") + + rh := web.NewRouteHandler(dm, tm) + rh.SetupRoutes() + + log.Printf("Serving on http://%s", *listenAddr) + http.ListenAndServe(*listenAddr, nil) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..879cf6d --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module donniemarko + +go 1.24.0 + +toolchain go1.24.12 + +require ( + github.com/russross/blackfriday/v2 v2.1.0 + golang.org/x/net v0.49.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..188c718 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= diff --git a/internal/note/note.go b/internal/note/note.go new file mode 100644 index 0000000..8955f45 --- /dev/null +++ b/internal/note/note.go @@ -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] +} diff --git a/internal/note/note_test.go b/internal/note/note_test.go new file mode 100644 index 0000000..939215a --- /dev/null +++ b/internal/note/note_test.go @@ -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") + } + }) + } +} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..44d070f --- /dev/null +++ b/internal/render/render.go @@ -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 +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..2b40636 --- /dev/null +++ b/internal/render/render_test.go @@ -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: `

Test

01/24/26 09:14:20 - some entry

check this out

`, + }, + { + 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: `

Test 2

01/24/26 09:14:20 - some entry (bare link)

Check this out http://tatata.toto here

`, + }, + { + 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: `

Test 2

01/24/26 09:14:20 - some entry (bare link)

Check this out here

`, + }, + } + + 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) + } + } +} diff --git a/internal/scanner/handler.go b/internal/scanner/handler.go new file mode 100644 index 0000000..0badfac --- /dev/null +++ b/internal/scanner/handler.go @@ -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 +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go new file mode 100644 index 0000000..f415b6f --- /dev/null +++ b/internal/scanner/scanner.go @@ -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 +} diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go new file mode 100644 index 0000000..4b72a36 --- /dev/null +++ b/internal/scanner/scanner_test.go @@ -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) + } +} diff --git a/internal/service/note.go b/internal/service/note.go new file mode 100644 index 0000000..d2b164e --- /dev/null +++ b/internal/service/note.go @@ -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 +} diff --git a/internal/service/note_test.go b/internal/service/note_test.go new file mode 100644 index 0000000..e7eb642 --- /dev/null +++ b/internal/service/note_test.go @@ -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") + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..96313b7 --- /dev/null +++ b/internal/storage/storage.go @@ -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 +} diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go new file mode 100644 index 0000000..1bec420 --- /dev/null +++ b/internal/storage/storage_test.go @@ -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) + } +} diff --git a/internal/web/handler.go b/internal/web/handler.go new file mode 100644 index 0000000..c4ff95e --- /dev/null +++ b/internal/web/handler.go @@ -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) +} diff --git a/internal/web/static/css/main.css b/internal/web/static/css/main.css new file mode 100644 index 0000000..dc04ce5 --- /dev/null +++ b/internal/web/static/css/main.css @@ -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); + } +} diff --git a/internal/web/templates/base.tmpl b/internal/web/templates/base.tmpl new file mode 100644 index 0000000..ea8854f --- /dev/null +++ b/internal/web/templates/base.tmpl @@ -0,0 +1,12 @@ + + + + + Donnie Marko + + + + + {{ template "content" . }} + + \ No newline at end of file diff --git a/internal/web/templates/index.tmpl b/internal/web/templates/index.tmpl new file mode 100644 index 0000000..3e25733 --- /dev/null +++ b/internal/web/templates/index.tmpl @@ -0,0 +1,8 @@ +{{ define "content" }} + {{/* List of notes and searching utilities */}} + {{ template "noteList" . }} + {{/* Markdown notes rendering area */}} +
+ {{ .Note }} +
+{{ end }} \ No newline at end of file diff --git a/internal/web/templates/noteList.tmpl b/internal/web/templates/noteList.tmpl new file mode 100644 index 0000000..ae8f73d --- /dev/null +++ b/internal/web/templates/noteList.tmpl @@ -0,0 +1,55 @@ +{{ define "noteList" }} + +{{ end }} + +{{ define "renderSearch" }} +{{ if ne .SearchTerm "" }}

Matching results for query '{{ .SearchTerm }}'

{{ end }} + +{{ end }} + + +{{/* not used for now, the opposition between flat list from hashmap and tree structure is confusing */}} +{{ define "renderTree" }} + +{{ end }} \ No newline at end of file