From 9d1254244f44d7e4bf07391c96d15cbe3be2cdbf Mon Sep 17 00:00:00 2001 From: adminoo Date: Tue, 3 Feb 2026 12:01:17 +0100 Subject: [PATCH] feat(release): v0.1.0 commit 06ed2c3cbee5f1c0c0b56ea0035c51b8a281e4e3 Author: adminoo Date: Tue Feb 3 11:34:24 2026 +0100 fix: changed detected by scanner but no updated by render layer commit 01dcaf882af3ed35d8847a51401b3e183d7b7baf Author: adminoo Date: Tue Feb 3 10:19:05 2026 +0100 feat: VERSION bumb commit 229223f77a68bcf623fa7c098e0d7624423f3bad Author: adminoo Date: Tue Feb 3 09:53:08 2026 +0100 feat: filter and search by tag commit cb11e34798469966fdb7af00c59b9d487863b613 Author: adminoo Date: Tue Feb 3 09:41:03 2026 +0100 feat: tag system commit 3f5cf0d673149ff69d3298952287eb959cd06dc0 Author: adminoo Date: Tue Feb 3 09:15:29 2026 +0100 feat: sqlite storage draft commit d6617cec021a2a9a9ad3855f199dd5a0177a524c Author: adminoo Date: Tue Feb 3 09:04:11 2026 +0100 feat: metadata draft commit 7238d02a1330ed675e6f322b8a7843d3938227ba Author: adminoo Date: Mon Feb 2 10:18:42 2026 +0100 fix: body overflowing commit 16ff83627442c5bd0a23a728bdee92eef44367a7 Author: adminoo Date: Mon Feb 2 10:09:01 2026 +0100 feat: tests for http handlers and render package commit 36ac3f03aaff9d8b226b60d53e9d48b6b521550d Author: adminoo Date: Mon Feb 2 09:45:29 2026 +0100 feat: Dark theme, placeholder metadata panel commit e6923fa4f5b0a332afdc33b3c00031469b52b560 Author: adminoo Date: Sun Feb 1 18:26:59 2026 +0100 fix: uneeded func + uneeded bogus note creation logic commit 4458ba2d15610e31b80f542143e077d83b7d9ea9 Author: adminoo Date: Sun Feb 1 18:26:21 2026 +0100 feat: log when changing note states commit 92a6f845406d3c4e2f7d9c435ffbebef44d61447 Author: adminoo Date: Sun Feb 1 16:55:40 2026 +0100 possibly first working draft commit e27aadc6039d0c23db2da9aa14c2ec1c495d0abb Author: adminoo Date: Sun Feb 1 11:55:16 2026 +0100 draft shits --- .gitignore | 2 + Makefile | 14 ++ README.md | 16 ++ VERSION | 1 + cmd/main.go | 87 +++++++ go.mod | 23 ++ go.sum | 55 ++++ internal/note/note.go | 74 ++++++ internal/note/note_test.go | 87 +++++++ internal/render/render.go | 114 +++++++++ internal/render/render_test.go | 149 +++++++++++ internal/scanner/handler.go | 70 ++++++ internal/scanner/scanner.go | 168 +++++++++++++ internal/scanner/scanner_test.go | 58 +++++ internal/service/note.go | 124 +++++++++ internal/service/note_test.go | 76 ++++++ internal/storage/sqlite.go | 336 +++++++++++++++++++++++++ internal/storage/sqlite_test.go | 238 ++++++++++++++++++ internal/storage/storage.go | 130 ++++++++++ internal/storage/storage_test.go | 70 ++++++ internal/web/handler.go | 250 +++++++++++++++++++ internal/web/handler_test.go | 307 +++++++++++++++++++++++ internal/web/static/css/main.css | 361 +++++++++++++++++++++++++++ internal/web/templates/base.tmpl | 14 ++ internal/web/templates/index.tmpl | 9 + internal/web/templates/metadata.tmpl | 52 ++++ internal/web/templates/noteList.tmpl | 55 ++++ 27 files changed, 2940 insertions(+) create mode 100644 .gitignore create mode 100755 Makefile create mode 100644 README.md create mode 100644 VERSION create mode 100644 cmd/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/note/note.go create mode 100644 internal/note/note_test.go create mode 100644 internal/render/render.go create mode 100644 internal/render/render_test.go create mode 100644 internal/scanner/handler.go create mode 100644 internal/scanner/scanner.go create mode 100644 internal/scanner/scanner_test.go create mode 100644 internal/service/note.go create mode 100644 internal/service/note_test.go create mode 100644 internal/storage/sqlite.go create mode 100644 internal/storage/sqlite_test.go create mode 100644 internal/storage/storage.go create mode 100644 internal/storage/storage_test.go create mode 100644 internal/web/handler.go create mode 100644 internal/web/handler_test.go create mode 100644 internal/web/static/css/main.css create mode 100644 internal/web/templates/base.tmpl create mode 100644 internal/web/templates/index.tmpl create mode 100644 internal/web/templates/metadata.tmpl create mode 100644 internal/web/templates/noteList.tmpl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a4f2bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +_bin +.#* \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..4de3e14 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +build: + mkdir -p _bin + go build -o _bin/donniemarko cmd/main.go + +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..c24d762 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# donniemarko + +Version: 0.1.0 + +Knowledge Management System over markdown notes. + +## Release 0.1.0 +- Core web UI for browsing notes +- Tagging system (add/remove, filter, search) +- SQLite-backed storage with tests + +`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/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..5ef808b --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "flag" + "log" + "net/http" + "os" + "path/filepath" + + "donniemarko/internal/render" + "donniemarko/internal/scanner" + "donniemarko/internal/service" + "donniemarko/internal/storage" + "donniemarko/internal/web" +) + +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") + dbPath := flag.String("db", "", "SQLite database path (empty uses ~/.local/share/donniemarko/notes.db)") + flag.BoolVar(&help, "help", false, "display this program usage") + flag.Parse() + + if help { + flag.PrintDefaults() + return + } + + // Initialize storage + var noteStorage storage.Storage + var sqliteStorage *storage.SQLiteStorage + resolvedDBPath := *dbPath + if resolvedDBPath == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + log.Fatalf("failed to resolve home directory: %v", err) + } + resolvedDBPath = filepath.Join(homeDir, ".local", "share", "donniemarko", "notes.db") + } + + if err := os.MkdirAll(filepath.Dir(resolvedDBPath), 0o700); err != nil { + log.Fatalf("failed to create database directory: %v", err) + } + + var err error + sqliteStorage, err = storage.NewSQLiteStorage(resolvedDBPath) + if err != nil { + log.Fatalf("failed to open sqlite db: %v", err) + } + defer func() { + if err := sqliteStorage.Close(); err != nil { + log.Printf("failed to close sqlite db: %v", err) + } + }() + noteStorage = sqliteStorage + + // Initialize scanner + monitor := scanner.NewScanner(*rootFolder) + + // Initialize notes handler for scanner + 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.Fatal(http.ListenAndServe(*listenAddr, nil)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..95e2c69 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module donniemarko + +go 1.24.0 + +toolchain go1.24.12 + +require ( + github.com/russross/blackfriday/v2 v2.1.0 + modernc.org/sqlite v1.44.3 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/sys v0.40.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2f1e22d --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +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/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY= +modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/note/note.go b/internal/note/note.go new file mode 100644 index 0000000..e22179a --- /dev/null +++ b/internal/note/note.go @@ -0,0 +1,74 @@ +package note + +import ( + "crypto/sha256" + "fmt" + "strings" + "time" +) + +type Note struct { + ID string + Path string + Title string + Content string + Size int64 + // HTMLContent string + // Directly in Note + Tags []string + UpdatedAt time.Time + Published bool +} + +func NewNote() *Note { + return &Note{} +} + +func formatDateRep(date time.Time) string { + return date.Format("2006-01-02 15:04:05") +} + +func (n *Note) GetUpdateDateRep() string { + return formatDateRep(n.UpdatedAt) +} + +// ExtractTitle return the first level heading content ('# title') +func ExtractTitle(mkd string) string { + if mkd == "" { + return "" + } + + lines := strings.Split(mkd, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "# ") { + // Extract title from # heading + title := strings.TrimPrefix(line, "# ") + title = strings.TrimSpace(title) + // Remove common markdown formatting + title = removeMarkdownFormatting(title) + return title + } + } + return "" +} + +// removeMarkdownFormatting removes common markdown formatting from text +func removeMarkdownFormatting(text string) string { + // Remove **bold** and *italic* formatting + result := text + result = strings.ReplaceAll(result, "**", "") + result = strings.ReplaceAll(result, "*", "") + result = strings.ReplaceAll(result, "_", "") + result = strings.ReplaceAll(result, "`", "") + result = strings.ReplaceAll(result, "~~", "") + + // Clean up multiple spaces + result = strings.Join(strings.Fields(result), " ") + + return result +} + +func GenerateNoteID(path string) string { + 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..b096643 --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,114 @@ +package render + +import ( + "fmt" + "html/template" + "net/http" + "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) *TemplateManager { + return &TemplateManager{ + templates: make(map[string]*template.Template), + basePath: basePath, + devMode: false, + } +} + +func (tm *TemplateManager) buildTemplatePath(name string) string { + return filepath.Join(tm.basePath, name+".tmpl") +} + +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 +} + +func (tm *TemplateManager) Render(w http.ResponseWriter, name string, data any) 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")) + + // Include metadata template (used by index) + files = append(files, tm.buildTemplatePath("metadata")) + + // Include metadata template + files = append(files, tm.buildTemplatePath("metadata")) + + // Add the specific template + files = append(files, tm.buildTemplatePath(name)) + + // Parse templates + tmpl, err := template.ParseFiles(files...) + if err != nil { + return fmt.Errorf("parse template %s: %w", name, err) + } + + // Set content type + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + err = tmpl.ExecuteTemplate(w, "base", data) + if err != nil { + return fmt.Errorf("execute template %s: %w", name, err) + } + + return nil +} + +// Render markdown to HTML with target="_blank" on links +func RenderMarkdown(content []byte) (template.HTML, error) { + renderer := blackfriday.NewHTMLRenderer(blackfriday.HTMLRendererParameters{ + Flags: blackfriday.CommonHTMLFlags | blackfriday.HrefTargetBlank, + }) + + html := blackfriday.Run(content, blackfriday.WithRenderer(renderer)) + return template.HTML(html), nil +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..1b07bd0 --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,149 @@ +package render + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "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) + } + } +} + +func TestTemplateManagerGetTemplate_Caches(t *testing.T) { + baseDir := t.TempDir() + writeTemplate(t, baseDir, "base.tmpl", `{{ define "base" }}base{{ end }}`) + writeTemplate(t, baseDir, "index.tmpl", `{{ define "content" }}index{{ end }}`) + + tm := NewTemplateManager(baseDir) + td := &TemplateData{ + Name: "index", + FileNameSet: []string{"base", "index"}, + } + + first, err := tm.GetTemplate(td) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + second, err := tm.GetTemplate(td) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if first != second { + t.Fatalf("expected cached template instance") + } +} + +func TestTemplateManagerGetTemplate_Missing(t *testing.T) { + baseDir := t.TempDir() + tm := NewTemplateManager(baseDir) + + td := &TemplateData{ + Name: "missing", + FileNameSet: []string{"missing"}, + } + + if _, err := tm.GetTemplate(td); err == nil { + t.Fatalf("expected error for missing template") + } +} + +func TestTemplateManagerRender(t *testing.T) { + baseDir := t.TempDir() + writeTemplate(t, baseDir, "base.tmpl", `{{ define "base" }}{{ template "content" . }}{{ end }}`) + writeTemplate(t, baseDir, "noteList.tmpl", `{{ define "noteList" }}notes{{ end }}`) + writeTemplate(t, baseDir, "metadata.tmpl", `{{ define "metadata" }}meta{{ end }}`) + writeTemplate(t, baseDir, "index.tmpl", `{{ define "content" }}hello{{ end }}`) + writeTemplate(t, baseDir, "metadata.tmpl", `{{ define "noteList" }}notes{{ end }}`) + + tm := NewTemplateManager(baseDir) + rec := httptest.NewRecorder() + + err := tm.Render(rec, "index", map[string]string{"msg": "hi"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" { + t.Fatalf("expected content-type text/html; charset=utf-8, got %q", ct) + } + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + if !strings.Contains(rec.Body.String(), "hello") { + t.Fatalf("expected rendered template content") + } +} + +func TestTemplateManagerRender_MissingTemplate(t *testing.T) { + baseDir := t.TempDir() + writeTemplate(t, baseDir, "base.tmpl", `{{ define "base" }}{{ template "content" . }}{{ end }}`) + writeTemplate(t, baseDir, "noteList.tmpl", `{{ define "noteList" }}notes{{ end }}`) + writeTemplate(t, baseDir, "metadata.tmpl", `{{ define "metadata" }}meta{{ end }}`) + writeTemplate(t, baseDir, "metadata.tmpl", `{{ define "noteList" }}notes{{ end }}`) + + tm := NewTemplateManager(baseDir) + rec := httptest.NewRecorder() + + if err := tm.Render(rec, "index", nil); err == nil { + t.Fatalf("expected error for missing template") + } +} + +func writeTemplate(t *testing.T, dir, name, contents string) { + t.Helper() + + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write template %s: %v", name, err) + } +} diff --git a/internal/scanner/handler.go b/internal/scanner/handler.go new file mode 100644 index 0000000..f397401 --- /dev/null +++ b/internal/scanner/handler.go @@ -0,0 +1,70 @@ +package scanner + +import ( + "path/filepath" + + "donniemarko/internal/note" + "donniemarko/internal/storage" + "log" + "os" +) + +type NotesHandler struct { + storage storage.Storage +} + +func NewNotesHandler(storage storage.Storage) *NotesHandler { + return &NotesHandler{storage: storage} +} + +func (h *NotesHandler) HandleCreate(path string) error { + note, err := ParseNoteFile(path) + if err != nil { + return err + } + if err := h.storage.Create(note); err != nil { + return err + } + log.Printf("Created or updated note '%s'\n", path) + 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) + log.Printf("Deleted note '%s' from index\n", path) + return nil +} + +// ParseNoteFile reads a note file on the file system and returns a note struct +// populated with the metadata and content extracted from the file +func ParseNoteFile(path string) (*note.Note, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + // Get file info to get modification time + fileInfo, err := os.Stat(path) + if err != nil { + return nil, err + } + + id := note.GenerateNoteID(path) + nn := note.NewNote() + nn.ID = id + nn.Path = path + nn.Content = string(content) + nn.Title = note.ExtractTitle(nn.Content) + // Use filename as title if no heading found + if nn.Title == "" { + nn.Title = filepath.Base(path) + } + nn.UpdatedAt = fileInfo.ModTime() + nn.Size = fileInfo.Size() + return nn, nil +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go new file mode 100644 index 0000000..f65903b --- /dev/null +++ b/internal/scanner/scanner.go @@ -0,0 +1,168 @@ +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, + Interval: 5 * time.Second, + LastStates: make(map[string]time.Time), + } +} + +func (s *ScannerService) SetHandler(handler ChangeHandler) { + s.handler = handler +} + +// Scan walks the root folder and update the states of each notes if +// it has changed since the last time a scan occured +func (s *ScannerService) Scan() ([]Change, error) { + var changes []Change + currentStates := make(map[string]time.Time) + + // 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 + } + + // ignore anything that isn't a note + if !isValidNoteFile(path, info) { + return nil + } + + currentStates[path] = info.ModTime() + + lastMod, existed := s.LastStates[path] + if !existed { + // create the note if it didn't exist yet + // s.handler.HandleCreate(path) + changes = append(changes, Change{Type: Created, Path: path, ModTime: lastMod}) + } else if info.ModTime().After(lastMod) { + changes = append(changes, Change{Type: Modified, Path: path, ModTime: info.ModTime()}) + } + + 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 +} + +// Monitor rescan the root folder at each new tick and handle state modifications +func (s *ScannerService) Monitor(ctx context.Context) error { + ticker := time.NewTicker(s.Interval) + defer ticker.Stop() + + applyChanges := func(changes []Change) { + 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) + } + } + } + + changes, err := s.Scan() + if err != nil { + log.Printf("scan error: %v", err) + } else { + applyChanges(changes) + } + + for { + select { + case <-ticker.C: + changes, err := s.Scan() + if err != nil { + log.Printf("scan error: %v", err) + continue + } + + applyChanges(changes) + + 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..bf15096 --- /dev/null +++ b/internal/scanner/scanner_test.go @@ -0,0 +1,58 @@ +package scanner + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +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..fa5a6b3 --- /dev/null +++ b/internal/service/note.go @@ -0,0 +1,124 @@ +package service + +import ( + "donniemarko/internal/note" + "donniemarko/internal/storage" + "sort" + "strings" +) + +type NotesService struct { + storage storage.Storage +} + +type SortOption func([]*note.Note) + +type QueryOptions struct { + SearchTerm string + SortBy string +} + +func NewNoteService() *NotesService { + return &NotesService{} +} + +func (s *NotesService) SetStorage(storage storage.Storage) { + s.storage = storage +} + +func SortByDate(notes []*note.Note) { + sort.Slice(notes, func(i, j int) bool { + return notes[i].UpdatedAt.After(notes[j].UpdatedAt) + }) +} + +func SortByDateAsc(notes []*note.Note) { + sort.Slice(notes, func(i, j int) bool { + return notes[i].UpdatedAt.Before(notes[j].UpdatedAt) + }) +} + +func SortByTitle(notes []*note.Note) { + sort.Slice(notes, func(i, j int) bool { + return notes[i].Title < notes[j].Title + }) +} + +func SortByTitleAsc(notes []*note.Note) { + sort.Slice(notes, func(i, j int) bool { + return notes[i].Title > notes[j].Title + }) +} + +func (s *NotesService) GetNotesWithSort(sortBy SortOption) ([]*note.Note, error) { + notes := s.storage.GetAll() + + if sortBy != nil { + sortBy(notes) + } + + return notes, nil +} + +func (s *NotesService) QueryNotes(opts QueryOptions) ([]*note.Note, error) { + var notes []*note.Note + + // Search or get all + if opts.SearchTerm != "" { + notes = s.storage.Search(opts.SearchTerm) + } else { + notes = s.storage.GetAll() + } + + // Apply sorting + switch opts.SortBy { + case "recent": + SortByDate(notes) + case "alpha": + SortByTitle(notes) + case "oldest": + SortByDateAsc(notes) + default: + SortByDate(notes) // Default sort + } + + return notes, nil +} + +func (s *NotesService) GetNoteByHash(hash string) (*note.Note, error) { + return s.storage.Get(hash) +} + +func (s *NotesService) GetNotes() []*note.Note { + return s.storage.GetAll() +} + +func (s *NotesService) AddTag(noteID, tag string) error { + tag = normalizeTag(tag) + if tag == "" { + return nil + } + + if _, err := s.storage.Get(noteID); err != nil { + return err + } + + return s.storage.AddTag(noteID, tag) +} + +func (s *NotesService) RemoveTag(noteID, tag string) error { + tag = normalizeTag(tag) + if tag == "" { + return nil + } + + if _, err := s.storage.Get(noteID); err != nil { + return err + } + + return s.storage.RemoveTag(noteID, tag) +} + +func normalizeTag(tag string) string { + return strings.ToLower(strings.TrimSpace(tag)) +} diff --git a/internal/service/note_test.go b/internal/service/note_test.go new file mode 100644 index 0000000..883738f --- /dev/null +++ b/internal/service/note_test.go @@ -0,0 +1,76 @@ +package service + +import ( + "testing" + + "donniemarko/internal/note" + "donniemarko/internal/storage" +) + +var service *NotesService + +func TestMain(m *testing.M) { + storage := storage.NewNoteStorage() + + notes := []*note.Note{ + {ID: "test1", Title: "Golang Tutorial", Content: "Learn Go"}, + {ID: "test2", Title: "Rust Guide", Content: "Learn Rust"}, + } + for _, note := range notes { + storage.Create(note) + } + + service = NewNoteService() + service.SetStorage(storage) + m.Run() +} + +func TestQueryNotes_WithSearch(t *testing.T) { + + opts := QueryOptions{ + SearchTerm: "Go", + SortBy: "alpha", + } + + results, err := service.QueryNotes(opts) + + if err != nil { + t.Fatal(err) + } + + if len(results) != 1 { + t.Errorf("expected 1 result, got %d", len(results)) + } + + if results[0].Title != "Golang Tutorial" { + t.Error("wrong note returned") + } +} + +func TestTags_NormalizationAndRemove(t *testing.T) { + err := service.AddTag("test1", " Go ") + if err != nil { + t.Fatalf("add tag: %v", err) + } + + n, err := service.GetNoteByHash("test1") + if err != nil { + t.Fatalf("get note: %v", err) + } + if len(n.Tags) != 1 || n.Tags[0] != "go" { + t.Fatalf("expected normalized tag, got %+v", n.Tags) + } + + err = service.RemoveTag("test1", "GO") + if err != nil { + t.Fatalf("remove tag: %v", err) + } + + n, err = service.GetNoteByHash("test1") + if err != nil { + t.Fatalf("get note: %v", err) + } + if len(n.Tags) != 0 { + t.Fatalf("expected tag to be removed, got %+v", n.Tags) + } +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go new file mode 100644 index 0000000..a879e77 --- /dev/null +++ b/internal/storage/sqlite.go @@ -0,0 +1,336 @@ +package storage + +import ( + "database/sql" + "fmt" + "time" + + "donniemarko/internal/note" + + _ "modernc.org/sqlite" +) + +type SQLiteStorage struct { + db *sql.DB +} + +const sqliteSchema = ` +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS notes ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + content TEXT NOT NULL, + updated_at INTEGER NOT NULL, + size INTEGER NOT NULL, + published INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS note_tags ( + note_id TEXT NOT NULL, + tag_id INTEGER NOT NULL, + UNIQUE(note_id, tag_id), + FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE, + FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS notes_updated_at_idx ON notes(updated_at); +CREATE INDEX IF NOT EXISTS notes_path_idx ON notes(path); +CREATE INDEX IF NOT EXISTS note_tags_note_idx ON note_tags(note_id); +CREATE INDEX IF NOT EXISTS note_tags_tag_idx ON note_tags(tag_id); +` + +func NewSQLiteStorage(dbPath string) (*SQLiteStorage, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + + if _, err := db.Exec(sqliteSchema); err != nil { + _ = db.Close() + return nil, fmt.Errorf("init schema: %w", err) + } + + return &SQLiteStorage{db: db}, nil +} + +func (s *SQLiteStorage) Close() error { + if s.db == nil { + return nil + } + return s.db.Close() +} + +func (s *SQLiteStorage) GetAll() []*note.Note { + rows, err := s.db.Query(` + SELECT id, path, title, content, updated_at, size, published + FROM notes + `) + if err != nil { + return []*note.Note{} + } + defer rows.Close() + + var notes []*note.Note + for rows.Next() { + n, err := scanNote(rows) + if err != nil { + continue + } + n.Tags = s.GetTags(n.ID) + notes = append(notes, n) + } + + return notes +} + +func (s *SQLiteStorage) Get(id string) (*note.Note, error) { + row := s.db.QueryRow(` + SELECT id, path, title, content, updated_at, size, published + FROM notes + WHERE id = ? + `, id) + + n, err := scanNote(row) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("No note with id '%s'", id) + } + return nil, err + } + + n.Tags = s.GetTags(n.ID) + return n, nil +} + +func (s *SQLiteStorage) Create(n *note.Note) error { + _, err := s.db.Exec(` + INSERT INTO notes (id, path, title, content, updated_at, size, published) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + path = excluded.path, + title = excluded.title, + content = excluded.content, + updated_at = excluded.updated_at, + size = excluded.size, + published = excluded.published + `, + n.ID, + n.Path, + n.Title, + n.Content, + toUnix(n.UpdatedAt), + n.Size, + boolToInt(n.Published), + ) + if err != nil { + return fmt.Errorf("insert note: %w", err) + } + + return nil +} + +func (s *SQLiteStorage) Delete(id string) { + _, _ = s.db.Exec(`DELETE FROM notes WHERE id = ?`, id) +} + +func (s *SQLiteStorage) Update(id string, n *note.Note) { + _, _ = s.db.Exec(` + UPDATE notes + SET path = ?, title = ?, content = ?, updated_at = ?, size = ?, published = ? + WHERE id = ? + `, + n.Path, + n.Title, + n.Content, + toUnix(n.UpdatedAt), + n.Size, + boolToInt(n.Published), + id, + ) +} + +func (s *SQLiteStorage) Search(query string) []*note.Note { + pattern := "%" + query + "%" + rows, err := s.db.Query(` + SELECT id, path, title, content, updated_at, size, published + FROM notes + WHERE lower(content) LIKE lower(?) + OR id IN ( + SELECT nt.note_id + FROM note_tags nt + JOIN tags t ON t.id = nt.tag_id + WHERE lower(t.name) LIKE lower(?) + ) + `, pattern, pattern) + if err != nil { + return []*note.Note{} + } + defer rows.Close() + + var notes []*note.Note + for rows.Next() { + n, err := scanNote(rows) + if err != nil { + continue + } + n.Tags = s.GetTags(n.ID) + notes = append(notes, n) + } + + return notes +} + +func (s *SQLiteStorage) Count() int { + row := s.db.QueryRow(`SELECT COUNT(*) FROM notes`) + var count int + if err := row.Scan(&count); err != nil { + return 0 + } + return count +} + +func (s *SQLiteStorage) AddTag(noteID, tag string) error { + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + + if _, err := tx.Exec(`INSERT OR IGNORE INTO tags (name) VALUES (?)`, tag); err != nil { + _ = tx.Rollback() + return fmt.Errorf("insert tag: %w", err) + } + + var tagID int64 + if err := tx.QueryRow(`SELECT id FROM tags WHERE name = ?`, tag).Scan(&tagID); err != nil { + _ = tx.Rollback() + return fmt.Errorf("lookup tag: %w", err) + } + + if _, err := tx.Exec(`INSERT OR IGNORE INTO note_tags (note_id, tag_id) VALUES (?, ?)`, noteID, tagID); err != nil { + _ = tx.Rollback() + return fmt.Errorf("attach tag: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit: %w", err) + } + + return nil +} + +func (s *SQLiteStorage) RemoveTag(noteID, tag string) error { + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + + var tagID int64 + err = tx.QueryRow(`SELECT id FROM tags WHERE name = ?`, tag).Scan(&tagID) + if err != nil { + if err == sql.ErrNoRows { + _ = tx.Rollback() + return nil + } + _ = tx.Rollback() + return fmt.Errorf("lookup tag: %w", err) + } + + if _, err := tx.Exec(`DELETE FROM note_tags WHERE note_id = ? AND tag_id = ?`, noteID, tagID); err != nil { + _ = tx.Rollback() + return fmt.Errorf("detach tag: %w", err) + } + + if _, err := tx.Exec(`DELETE FROM tags WHERE id = ? AND NOT EXISTS (SELECT 1 FROM note_tags WHERE tag_id = ?)`, tagID, tagID); err != nil { + _ = tx.Rollback() + return fmt.Errorf("cleanup tag: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit: %w", err) + } + + return nil +} + +func (s *SQLiteStorage) GetTags(noteID string) []string { + rows, err := s.db.Query(` + SELECT t.name + FROM tags t + JOIN note_tags nt ON nt.tag_id = t.id + WHERE nt.note_id = ? + ORDER BY t.name + `, noteID) + if err != nil { + return []string{} + } + defer rows.Close() + + var tags []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + continue + } + tags = append(tags, name) + } + + return tags +} + +type scanner interface { + Scan(dest ...interface{}) error +} + +func scanNote(row scanner) (*note.Note, error) { + var ( + id string + path string + title string + content string + updated int64 + size int64 + published int + ) + + if err := row.Scan(&id, &path, &title, &content, &updated, &size, &published); err != nil { + return nil, err + } + + return ¬e.Note{ + ID: id, + Path: path, + Title: title, + Content: content, + UpdatedAt: fromUnix(updated), + Size: size, + Published: published != 0, + }, nil +} + +func toUnix(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.UTC().Unix() +} + +func fromUnix(v int64) time.Time { + if v == 0 { + return time.Time{} + } + return time.Unix(v, 0).UTC() +} + +func boolToInt(v bool) int { + if v { + return 1 + } + return 0 +} diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go new file mode 100644 index 0000000..e16ab32 --- /dev/null +++ b/internal/storage/sqlite_test.go @@ -0,0 +1,238 @@ +package storage + +import ( + "path/filepath" + "testing" + "time" + + "donniemarko/internal/note" +) + +func newSQLiteStorage(t *testing.T) *SQLiteStorage { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "notes.db") + st, err := NewSQLiteStorage(dbPath) + if err != nil { + t.Fatalf("new sqlite storage: %v", err) + } + t.Cleanup(func() { + _ = st.Close() + }) + + return st +} + +func sampleNote(id, path, title, content string, tstamp time.Time) *note.Note { + return ¬e.Note{ + ID: id, + Path: path, + Title: title, + Content: content, + UpdatedAt: tstamp.Add(2 * time.Hour), + Size: int64(len(content)), + Published: true, + } +} + +func TestSQLiteStorage_CreateGet(t *testing.T) { + st := newSQLiteStorage(t) + + ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + n := sampleNote("n1", "notes/alpha.md", "Alpha", "# Alpha", ts) + + if err := st.Create(n); err != nil { + t.Fatalf("create note: %v", err) + } + + got, err := st.Get("n1") + if err != nil { + t.Fatalf("get note: %v", err) + } + + if got.Title != n.Title || got.Path != n.Path || got.Content != n.Content { + t.Fatalf("unexpected note fields: %+v", got) + } + if !got.UpdatedAt.Equal(n.UpdatedAt) || got.Size != n.Size { + t.Fatalf("unexpected time fields: %+v", got) + } + if !got.Published { + t.Fatalf("expected published to be true") + } +} + +func TestSQLiteStorage_GetAll(t *testing.T) { + st := newSQLiteStorage(t) + + ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + if err := st.Create(sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts)); err != nil { + t.Fatalf("create note: %v", err) + } + if err := st.Create(sampleNote("n2", "notes/beta.md", "Beta", "two", ts)); err != nil { + t.Fatalf("create note: %v", err) + } + + all := st.GetAll() + if len(all) != 2 { + t.Fatalf("expected 2 notes, got %d", len(all)) + } +} + +func TestSQLiteStorage_Update(t *testing.T) { + st := newSQLiteStorage(t) + + ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + n := sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts) + if err := st.Create(n); err != nil { + t.Fatalf("create note: %v", err) + } + + updated := sampleNote("n1", "notes/alpha.md", "Alpha Updated", "two", ts.Add(24*time.Hour)) + st.Update("n1", updated) + + got, err := st.Get("n1") + if err != nil { + t.Fatalf("get note: %v", err) + } + if got.Title != "Alpha Updated" || got.Content != "two" { + t.Fatalf("update did not persist: %+v", got) + } +} + +func TestSQLiteStorage_Delete(t *testing.T) { + st := newSQLiteStorage(t) + + ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + if err := st.Create(sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts)); err != nil { + t.Fatalf("create note: %v", err) + } + + st.Delete("n1") + + if st.Count() != 0 { + t.Fatalf("expected count 0 after delete") + } + + if _, err := st.Get("n1"); err == nil { + t.Fatalf("expected error for missing note") + } +} + +func TestSQLiteStorage_Search(t *testing.T) { + st := newSQLiteStorage(t) + + ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + if err := st.Create(sampleNote("n1", "notes/alpha.md", "Alpha", "Rust tips", ts)); err != nil { + t.Fatalf("create note: %v", err) + } + if err := st.Create(sampleNote("n2", "notes/beta.md", "Beta", "Golang tutorial", ts)); err != nil { + t.Fatalf("create note: %v", err) + } + + results := st.Search("rust") + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].ID != "n1" { + t.Fatalf("expected rust match to be n1") + } +} + +func TestSQLiteStorage_Count(t *testing.T) { + st := newSQLiteStorage(t) + + if st.Count() != 0 { + t.Fatalf("expected empty count to be 0") + } + + ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + if err := st.Create(sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts)); err != nil { + t.Fatalf("create note: %v", err) + } + if err := st.Create(sampleNote("n2", "notes/beta.md", "Beta", "two", ts)); err != nil { + t.Fatalf("create note: %v", err) + } + + if st.Count() != 2 { + t.Fatalf("expected count 2") + } +} + +func TestSQLiteStorage_Tags(t *testing.T) { + st := newSQLiteStorage(t) + + ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + if err := st.Create(sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts)); err != nil { + t.Fatalf("create note: %v", err) + } + + if err := st.AddTag("n1", "go"); err != nil { + t.Fatalf("add tag: %v", err) + } + if err := st.AddTag("n1", "rust"); err != nil { + t.Fatalf("add tag: %v", err) + } + + tags := st.GetTags("n1") + if len(tags) != 2 { + t.Fatalf("expected 2 tags, got %d", len(tags)) + } + + if err := st.RemoveTag("n1", "go"); err != nil { + t.Fatalf("remove tag: %v", err) + } + + tags = st.GetTags("n1") + if len(tags) != 1 || tags[0] != "rust" { + t.Fatalf("expected remaining tag rust, got %+v", tags) + } +} + +func TestSQLiteStorage_SearchByTag(t *testing.T) { + st := newSQLiteStorage(t) + + ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + if err := st.Create(sampleNote("n1", "notes/alpha.md", "Alpha", "no match", ts)); err != nil { + t.Fatalf("create note: %v", err) + } + if err := st.Create(sampleNote("n2", "notes/beta.md", "Beta", "content", ts)); err != nil { + t.Fatalf("create note: %v", err) + } + + if err := st.AddTag("n2", "Go"); err != nil { + t.Fatalf("add tag: %v", err) + } + + results := st.Search("go") + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].ID != "n2" { + t.Fatalf("expected tag match to be n2") + } +} + +func TestSQLiteStorage_Create_Upsert(t *testing.T) { + st := newSQLiteStorage(t) + + ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + n := sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts) + if err := st.Create(n); err != nil { + t.Fatalf("create note: %v", err) + } + + n.Content = "updated" + n.Title = "Alpha Updated" + n.UpdatedAt = ts.Add(2 * time.Hour) + if err := st.Create(n); err != nil { + t.Fatalf("upsert note: %v", err) + } + + got, err := st.Get("n1") + if err != nil { + t.Fatalf("get note: %v", err) + } + if got.Title != "Alpha Updated" || got.Content != "updated" { + t.Fatalf("expected note to be updated, got %+v", got) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..71a8120 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,130 @@ +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 + Count() int + AddTag(noteID, tag string) error + RemoveTag(noteID, tag string) error + GetTags(noteID string) []string +} + +type NoteStorage struct { + Index map[string]*note.Note +} + +func NewNoteStorage() *NoteStorage { + return &NoteStorage{Index: make(map[string]*note.Note)} +} + +// GetAll returns all notes stored in the index +func (ns *NoteStorage) GetAll() []*note.Note { + var notes []*note.Note + + 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) Count() int { + return len(ns.Index) +} + +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) || tagsContain(note.Tags, lowQuery) { + results = append(results, note) + } + } + return results +} + +func tagsContain(tags []string, query string) bool { + if query == "" { + return false + } + for _, tag := range tags { + if strings.Contains(strings.ToLower(tag), query) { + return true + } + } + return false +} + +func (ns *NoteStorage) AddTag(noteID, tag string) error { + n, ok := ns.Index[noteID] + if !ok { + return fmt.Errorf("No note with id '%s'", noteID) + } + + for _, existing := range n.Tags { + if existing == tag { + return nil + } + } + + n.Tags = append(n.Tags, tag) + return nil +} + +func (ns *NoteStorage) RemoveTag(noteID, tag string) error { + n, ok := ns.Index[noteID] + if !ok { + return fmt.Errorf("No note with id '%s'", noteID) + } + + updated := n.Tags[:0] + for _, existing := range n.Tags { + if existing != tag { + updated = append(updated, existing) + } + } + n.Tags = updated + return nil +} + +func (ns *NoteStorage) GetTags(noteID string) []string { + n, ok := ns.Index[noteID] + if !ok { + return []string{} + } + + tags := make([]string, len(n.Tags)) + copy(tags, n.Tags) + return tags +} diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go new file mode 100644 index 0000000..b56135f --- /dev/null +++ b/internal/storage/storage_test.go @@ -0,0 +1,70 @@ +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 them 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) + } +} + +func TestNoteStorageSearch_Tags(t *testing.T) { + ns = NewNoteStorage() + + n := note.NewNote() + n.Path = "note3.md" + n.ID = note.GenerateNoteID(n.Path) + n.Content = "no tag here" + n.Tags = []string{"devops", "go"} + + ns.Create(n) + + results := ns.Search("go") + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } +} diff --git a/internal/web/handler.go b/internal/web/handler.go new file mode 100644 index 0000000..8e91811 --- /dev/null +++ b/internal/web/handler.go @@ -0,0 +1,250 @@ +package web + +import ( + "donniemarko/internal/note" + "donniemarko/internal/render" + "donniemarko/internal/service" + "html/template" + "net/http" + "net/url" + "strings" +) + +type Handler struct { + notesService *service.NotesService + templates *render.TemplateManager + mux *http.ServeMux +} + +func NewHandler(ns *service.NotesService, tm *render.TemplateManager) *Handler { + return &Handler{ + notesService: ns, + templates: tm, + mux: http.NewServeMux(), + } +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + + // Handle root and note list + if path == "/" { + h.handleRoot(w, r) + return + } + + // Handle individual notes + if strings.HasPrefix(path, "/notes/") { + if strings.Contains(path, "/tags") { + h.handleTags(w, r) + return + } + h.handleNotes(w, r) + return + } + + // Handle 404 for other paths + http.NotFound(w, r) +} + +// ViewState is built per-request, not shared +type ViewState struct { + Notes []*note.Note + Note *note.Note + RenderedNote template.HTML + SortBy string + SearchTerm string + TagFilter string + LastActive string +} + +func (h *Handler) handleRoot(w http.ResponseWriter, r *http.Request) { + // Build view state from query params + state, err := h.buildViewState(r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Render with state + h.templates.Render(w, "index", state) +} + +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") + tagFilter := query.Get("tag") + + // Get notes from service + var notes []*note.Note + var err error + + if searchTerm != "" { + opts := service.QueryOptions{ + SearchTerm: searchTerm, + SortBy: sortBy, + } + notes, err = h.notesService.QueryNotes(opts) + if err != nil { + return nil, err + } + } else { + notes = h.notesService.GetNotes() + + // Apply sorting + switch sortBy { + case "recent": + service.SortByDate(notes) + case "oldest": + service.SortByDateAsc(notes) + case "alpha": + service.SortByTitle(notes) + case "ralpha": + service.SortByTitleAsc(notes) + default: + service.SortByDate(notes) + } + } + + if tagFilter != "" { + notes = filterNotesByTag(notes, tagFilter) + } + + return &ViewState{ + Notes: notes, + SortBy: sortBy, + SearchTerm: searchTerm, + TagFilter: tagFilter, + }, nil +} + +func (h *Handler) SetupRoutes() { + // Set the handler as the main handler for http.DefaultServeMux + http.Handle("/", h) + http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("internal/web/static")))) +} + +func extractHash(path string) string { + // Extract hash from /notes/{hash} + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) < 2 || parts[0] != "notes" { + return "" + } + return parts[1] +} + +func (h *Handler) handleNotes(w http.ResponseWriter, r *http.Request) { + // Build base state + state, err := h.buildViewState(r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Extract hash from URL + hash := extractHash(r.URL.Path) + + // Get specific note + note, err := h.notesService.GetNoteByHash(hash) + if err != nil { + http.Error(w, "Note not found", http.StatusNotFound) + return + } + + // Convert markdown to HTML + htmlContent, err := render.RenderMarkdown([]byte(note.Content)) + if err != nil { + http.Error(w, "Failed to render markdown", http.StatusInternalServerError) + return + } + + // Add to state + state.Note = note + state.RenderedNote = htmlContent + state.LastActive = hash + + h.templates.Render(w, "index", state) +} + +func filterNotesByTag(notes []*note.Note, tag string) []*note.Note { + tag = strings.ToLower(strings.TrimSpace(tag)) + if tag == "" { + return notes + } + + filtered := make([]*note.Note, 0, len(notes)) + for _, n := range notes { + for _, t := range n.Tags { + if strings.EqualFold(t, tag) { + filtered = append(filtered, n) + break + } + } + } + return filtered +} + +func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + noteID, tag, isRemove := parseTagRoute(r.URL.Path) + if noteID == "" { + http.NotFound(w, r) + return + } + + if isRemove { + if tag == "" { + http.Error(w, "Missing tag", http.StatusBadRequest) + return + } + if err := h.notesService.RemoveTag(noteID, tag); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } else { + if err := r.ParseForm(); err != nil { + http.Error(w, "Invalid form", http.StatusBadRequest) + return + } + tag := r.FormValue("tag") + if tag == "" { + http.Error(w, "Missing tag", http.StatusBadRequest) + return + } + if err := h.notesService.AddTag(noteID, tag); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + http.Redirect(w, r, "/notes/"+noteID, http.StatusSeeOther) +} + +func parseTagRoute(path string) (noteID string, tag string, isRemove bool) { + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) < 3 || parts[0] != "notes" || parts[2] != "tags" { + return "", "", false + } + + noteID = parts[1] + if len(parts) >= 4 { + decoded, err := url.PathUnescape(parts[3]) + if err != nil { + return noteID, "", true + } + return noteID, decoded, true + } + + return noteID, "", false +} diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go new file mode 100644 index 0000000..0e2974f --- /dev/null +++ b/internal/web/handler_test.go @@ -0,0 +1,307 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "donniemarko/internal/note" + "donniemarko/internal/render" + "donniemarko/internal/service" + "donniemarko/internal/storage" +) + +type testEnv struct { + handler *Handler + storage *storage.NoteStorage +} + +func newTestEnv(t *testing.T) *testEnv { + t.Helper() + + ns := storage.NewNoteStorage() + + notes := []*note.Note{ + { + ID: "a1", + Title: "Alpha", + Content: "# Alpha\ncontent", + UpdatedAt: time.Date(2025, 12, 31, 10, 0, 0, 0, time.UTC), + }, + { + ID: "b2", + Title: "Beta", + Content: "# Beta\nRust tips", + UpdatedAt: time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC), + }, + { + ID: "z9", + Title: "Zulu", + Content: "# Zulu\nRust book", + UpdatedAt: time.Date(2026, 1, 2, 10, 0, 0, 0, time.UTC), + }, + } + + for _, n := range notes { + if err := ns.Create(n); err != nil { + t.Fatalf("create note: %v", err) + } + } + + svc := service.NewNoteService() + svc.SetStorage(ns) + + tm := render.NewTemplateManager("templates") + handler := NewHandler(svc, tm) + + return &testEnv{ + handler: handler, + storage: ns, + } +} + +func assertOrder(t *testing.T, body string, want []string) { + t.Helper() + + prev := -1 + for _, title := range want { + idx := strings.Index(body, title) + if idx == -1 { + t.Fatalf("expected to find %q in response body", title) + } + if idx <= prev { + t.Fatalf("expected %q to appear after previous title", title) + } + prev = idx + } +} + +func noteMarker(id string) string { + return `data-hash="` + id + `"` +} + +func TestHandlerRoot_DefaultSortRecent(t *testing.T) { + env := newTestEnv(t) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + env.handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" { + t.Fatalf("expected content-type text/html; charset=utf-8, got %q", ct) + } + + body := rec.Body.String() + assertOrder(t, body, []string{noteMarker("z9"), noteMarker("b2"), noteMarker("a1")}) +} + +func TestHandlerRoot_SortAlpha(t *testing.T) { + env := newTestEnv(t) + + req := httptest.NewRequest(http.MethodGet, "/?sort=alpha", nil) + rec := httptest.NewRecorder() + + env.handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + body := rec.Body.String() + assertOrder(t, body, []string{noteMarker("a1"), noteMarker("b2"), noteMarker("z9")}) +} + +func TestHandlerRoot_Search(t *testing.T) { + env := newTestEnv(t) + + req := httptest.NewRequest(http.MethodGet, "/?search=rust&sort=alpha", nil) + rec := httptest.NewRecorder() + + env.handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + body := rec.Body.String() + if !strings.Contains(body, "Matching results for query 'rust'") { + t.Fatalf("expected search header to be rendered") + } + + if strings.Contains(body, `data-hash="a1"`) { + t.Fatalf("expected non-matching note to be excluded") + } + + assertOrder(t, body, []string{noteMarker("b2"), noteMarker("z9")}) +} + +func TestHandlerNotes_Success(t *testing.T) { + env := newTestEnv(t) + + req := httptest.NewRequest(http.MethodGet, "/notes/b2", nil) + rec := httptest.NewRecorder() + + env.handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + body := rec.Body.String() + if !strings.Contains(body, "

Beta

") { + t.Fatalf("expected rendered markdown for note content") + } + if !strings.Contains(body, `class="active-note"`) { + t.Fatalf("expected active note class in list") + } + if !strings.Contains(body, `data-hash="b2"`) { + t.Fatalf("expected active note hash in list") + } +} + +func TestHandlerNotes_NotFound(t *testing.T) { + env := newTestEnv(t) + + req := httptest.NewRequest(http.MethodGet, "/notes/missing", nil) + rec := httptest.NewRecorder() + + env.handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected status 404, got %d", rec.Code) + } + + if !strings.Contains(rec.Body.String(), "Note not found") { + t.Fatalf("expected not found message") + } +} + +func TestHandler_NotFoundPath(t *testing.T) { + env := newTestEnv(t) + + req := httptest.NewRequest(http.MethodGet, "/nope", nil) + rec := httptest.NewRecorder() + + env.handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected status 404, got %d", rec.Code) + } +} + +func TestExtractHash(t *testing.T) { + cases := []struct { + name string + path string + want string + }{ + { + name: "valid note path", + path: "/notes/abcd", + want: "abcd", + }, + { + name: "missing hash", + path: "/notes/", + want: "", + }, + { + name: "wrong prefix", + path: "/other/abcd", + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := extractHash(tc.path); got != tc.want { + t.Fatalf("expected %q, got %q", tc.want, got) + } + }) + } +} + +func TestHandlerTags_Add(t *testing.T) { + env := newTestEnv(t) + + form := url.Values{} + form.Set("tag", " Go ") + req := httptest.NewRequest(http.MethodPost, "/notes/b2/tags", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + env.handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected status 303, got %d", rec.Code) + } + + n, err := env.storage.Get("b2") + if err != nil { + t.Fatalf("get note: %v", err) + } + if len(n.Tags) != 1 || n.Tags[0] != "go" { + t.Fatalf("expected normalized tag on note, got %+v", n.Tags) + } +} + +func TestHandlerTags_Remove(t *testing.T) { + env := newTestEnv(t) + + if err := env.storage.AddTag("b2", "go"); err != nil { + t.Fatalf("seed tag: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/notes/b2/tags/go", nil) + rec := httptest.NewRecorder() + + env.handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected status 303, got %d", rec.Code) + } + + n, err := env.storage.Get("b2") + if err != nil { + t.Fatalf("get note: %v", err) + } + if len(n.Tags) != 0 { + t.Fatalf("expected tag to be removed, got %+v", n.Tags) + } +} + +func TestHandlerRoot_TagFilter(t *testing.T) { + env := newTestEnv(t) + + if err := env.storage.AddTag("a1", "go"); err != nil { + t.Fatalf("seed tag: %v", err) + } + if err := env.storage.AddTag("b2", "rust"); err != nil { + t.Fatalf("seed tag: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/?tag=go", nil) + rec := httptest.NewRecorder() + + env.handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + body := rec.Body.String() + if strings.Contains(body, noteMarker("b2")) { + t.Fatalf("expected non-matching note to be excluded") + } + if !strings.Contains(body, noteMarker("a1")) { + t.Fatalf("expected matching note to be included") + } +} diff --git a/internal/web/static/css/main.css b/internal/web/static/css/main.css new file mode 100644 index 0000000..dc7c25d --- /dev/null +++ b/internal/web/static/css/main.css @@ -0,0 +1,361 @@ +:root { + --background1: #1e1e1e; /* Main background */ + --background2: #252526; /* Code blocks, input backgrounds */ + --background3: #333333; /* Active items */ + --background-hover: #2d2d2d; + + --heading1: #ffffff; /* Primary headings */ + --heading2: #cccccc; /* Secondary headings */ + --heading3: #999999; + + --text1: #d4d4d4; /* Main text */ + --code1: #c5c5c5; /* Inline code text */ + --url: #4ea1ff; /* Link color */ + + --border-color: #3a3a3a; /* Subtle border */ + + --font-main: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + --font-size-base: 16px; + --line-height: 1.6; + + --padding-main: 1cm; + --max-width-main: 210mm; +} + +@page { + size: A4; +} + +/* PRINT MODE */ +@media print { + body { + margin: 2cm 1.5cm 2cm 1.5cm; + background: white; + color: black; + } + + aside, + .note-metadata { + display: none; + } + + main { + margin-top: 0; + break-after: always; + color: black; + } + + h1 { + margin-top: 0; + color: black; + } +} + +/* SCREEN MODE */ +@media screen { + + body { + min-height: 100vh; + overflow: hidden; + } + + aside, + main { + height: 100%; + overflow-y: auto; + } + + h1, + h2, + h3, + h4 { + margin-top: 1.5em; + } +} + +/* UNIVERSAL */ +@media all { + + *, + *::before, + *::after { + box-sizing: border-box; + } + + html, + body { + height: 100%; + } + + 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: 500; + text-decoration: none; + } + + a:hover { + text-decoration: underline; + } + + code { + background-color: var(--background2); + padding: 0.25em 0.45em; + border-radius: 4px; + font-family: monospace; + color: var(--code1); + } + + pre { + background: var(--background2); + padding: 0.8em; + border-radius: 4px; + overflow-x: auto; + } + + header h1 { + color: var(--heading1); + font-size: 2em; + border-bottom: 1px solid var(--border-color); + padding-bottom: 1em; + margin: 0; + text-align: center; + } + + /* SIDEBAR */ + aside { + flex: 0 0 350px; + width: 250px; + border-right: 1px solid var(--border-color); + padding: var(--padding-main); + background-color: #1a1a1a; + } + + aside ul { + list-style: none; + padding: 0; + margin: 0; + } + + aside ul li { + padding: 0.5em 0.3em; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + transition: background 0.15s ease-in-out; + } + + aside ul li:hover { + background: var(--background-hover); + } + + .active-note { + background: var(--background3); + } + + .note-title a { + padding-left: 0.7em; + font-size: 0.95em; + color: var(--text1); + } + + .last-modified { + text-align: right; + font-size: 0.75em; + opacity: 0.7; + } + + /* SEARCH BAR */ + .search-form { + display: flex; + margin-bottom: 1em; + } + + .search-bar { + width: 100%; + padding: 0.45em; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--background2); + color: var(--text1); + } + + .search-bar:focus { + outline: none; + border-color: var(--url); + box-shadow: 0 0 6px #4ea1ff55; + } + + /* MAIN CONTENT */ + main { + flex: 1; + margin: 0 auto; + padding: var(--padding-main); + max-height: 100%; + } + + main h1 { + color: var(--heading1); + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.3em; + text-transform: uppercase; + text-align: center; + } + + main h2 { + color: var(--heading1); + border-bottom: 2px dotted var(--border-color); + padding-bottom: 0.5em; + padding-top: 1em; + } + + main h3, + main h4 { + color: var(--heading3); + } + + p { + margin-bottom: 1em; + } + + blockquote { + margin: 1em 0; + padding: 0 1em; + border-left: 3px solid var(--border-color); + color: var(--heading2); + } + + /* METADATA PANEL (right side) */ + .metadata-panel { + flex: 0 0 300px; /* fixed width panel */ + padding: var(--padding-main); + border-left: 1px solid var(--border-color); + background: #1a1a1a; + color: var(--text1); + height: 100%; + overflow-y: auto; + } + + /* Titles inside metadata panel */ + .metadata-panel h3 { + color: var(--heading1); + font-size: 1em; + margin-top: 1.5em; + margin-bottom: 0.5em; + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.3em; + } + + /* Metadata block wrapper */ + .meta-block { + margin-bottom: 1.5em; + } + + /* Tags UI */ + .tag-form { + display: flex; + gap: 0.5em; + margin-bottom: 0.8em; + } + + .tag-input { + flex: 1; + padding: 0.35em 0.5em; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--background2); + color: var(--text1); + } + + .tag-submit { + padding: 0.35em 0.6em; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--background2); + color: var(--text1); + } + + .meta-tags { + display: flex; + flex-wrap: wrap; + gap: 0.4em; + } + + .tag-chip { + display: inline-flex; + align-items: center; + gap: 0.35em; + background: var(--background2); + border: 1px solid var(--border-color); + border-radius: 999px; + padding: 0.2em 0.45em; + } + + .tag-link { + color: var(--url); + font-size: 0.8em; + } + + .tag-remove-form { + display: inline; + } + + .tag-remove { + border: 0; + background: var(--background3); + color: var(--text1); + width: 1.4em; + height: 1.4em; + border-radius: 999px; + line-height: 1; + } + + .tag-remove:hover { + background: var(--background-hover); + cursor: pointer; + } + + /* Category display */ + .meta-category { + background: var(--background2); + padding: 0.4em 0.6em; + border-radius: 4px; + font-size: 0.9em; + border: 1px solid var(--border-color); + display: inline-block; + } + + /* Lists for metadata fields */ + .meta-list { + list-style: none; + padding: 0; + margin: 0; + } + + .meta-list li { + margin: 0.3em 0; + font-size: 0.9em; + } + + /* Placeholder for future stats visualizations */ + .meta-stats-placeholder { + margin-top: 1em; + padding: 1em; + text-align: center; + font-size: 0.8em; + opacity: 0.6; + background: var(--background2); + border-radius: 4px; + border: 1px solid var(--border-color); + } +} diff --git a/internal/web/templates/base.tmpl b/internal/web/templates/base.tmpl new file mode 100644 index 0000000..cbaf12b --- /dev/null +++ b/internal/web/templates/base.tmpl @@ -0,0 +1,14 @@ +{{ define "base" }} + + + + + Donnie Marko + + + + + {{ template "content" . }} + + +{{ end }} \ 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..6b42128 --- /dev/null +++ b/internal/web/templates/index.tmpl @@ -0,0 +1,9 @@ +{{ define "content" }} + {{/* List of notes and searching utilities */}} + {{ template "noteList" . }} + {{/* Markdown notes rendering area */}} +
+ {{ .RenderedNote }} +
+ {{ template "metadata" . }} +{{ end }} diff --git a/internal/web/templates/metadata.tmpl b/internal/web/templates/metadata.tmpl new file mode 100644 index 0000000..65e478e --- /dev/null +++ b/internal/web/templates/metadata.tmpl @@ -0,0 +1,52 @@ +{{ define "metadata" }} + +{{ if .RenderedNote }} + +{{ end }} +{{ end }} diff --git a/internal/web/templates/noteList.tmpl b/internal/web/templates/noteList.tmpl new file mode 100644 index 0000000..bc1b086 --- /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 }}' (notes or tags)

{{ end }} +{{ if ne .TagFilter "" }}

Filtered by tag '{{ .TagFilter }}'

{{ end }} + +{{ end }} + + +{{/* not used for now, the opposition between flat list from hashmap and tree structure is confusing */}} +{{ define "renderTree" }} +
    + {{ range . }} + {{ if .IsEnd }} +
  • {{ .Title }}{{ .LastModified }}
  • + {{ else }} + {{ if .Children }} +
  • {{ .Path }} + {{ template "renderTree" .Children }} +
  • + {{ end }} + {{ end }} + {{ end }} +
+{{ end }}