feat(release): v0.1.0
commit06ed2c3cbeAuthor: adminoo <git@kadath.corp> Date: Tue Feb 3 11:34:24 2026 +0100 fix: changed detected by scanner but no updated by render layer commit01dcaf882aAuthor: adminoo <git@kadath.corp> Date: Tue Feb 3 10:19:05 2026 +0100 feat: VERSION bumb commit229223f77aAuthor: adminoo <git@kadath.corp> Date: Tue Feb 3 09:53:08 2026 +0100 feat: filter and search by tag commitcb11e34798Author: adminoo <git@kadath.corp> Date: Tue Feb 3 09:41:03 2026 +0100 feat: tag system commit3f5cf0d673Author: adminoo <git@kadath.corp> Date: Tue Feb 3 09:15:29 2026 +0100 feat: sqlite storage draft commitd6617cec02Author: adminoo <git@kadath.corp> Date: Tue Feb 3 09:04:11 2026 +0100 feat: metadata draft commit7238d02a13Author: adminoo <git@kadath.corp> Date: Mon Feb 2 10:18:42 2026 +0100 fix: body overflowing commit16ff836274Author: adminoo <git@kadath.corp> Date: Mon Feb 2 10:09:01 2026 +0100 feat: tests for http handlers and render package commit36ac3f03aaAuthor: adminoo <git@kadath.corp> Date: Mon Feb 2 09:45:29 2026 +0100 feat: Dark theme, placeholder metadata panel commite6923fa4f5Author: adminoo <git@kadath.corp> Date: Sun Feb 1 18:26:59 2026 +0100 fix: uneeded func + uneeded bogus note creation logic commit4458ba2d15Author: adminoo <git@kadath.corp> Date: Sun Feb 1 18:26:21 2026 +0100 feat: log when changing note states commit92a6f84540Author: adminoo <git@kadath.corp> Date: Sun Feb 1 16:55:40 2026 +0100 possibly first working draft commite27aadc603Author: adminoo <git@kadath.corp> Date: Sun Feb 1 11:55:16 2026 +0100 draft shits
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
_bin
|
||||
.#*
|
||||
14
Makefile
Executable file
14
Makefile
Executable file
@ -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
|
||||
16
README.md
Normal file
16
README.md
Normal file
@ -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
|
||||
87
cmd/main.go
Normal file
87
cmd/main.go
Normal file
@ -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))
|
||||
}
|
||||
23
go.mod
Normal file
23
go.mod
Normal file
@ -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
|
||||
)
|
||||
55
go.sum
Normal file
55
go.sum
Normal file
@ -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=
|
||||
74
internal/note/note.go
Normal file
74
internal/note/note.go
Normal file
@ -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]
|
||||
}
|
||||
87
internal/note/note_test.go
Normal file
87
internal/note/note_test.go
Normal file
@ -0,0 +1,87 @@
|
||||
package note
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractTitle(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
markdown string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "first h1 becomes title",
|
||||
markdown: "# My Note\n\nSome content",
|
||||
want: "My Note",
|
||||
},
|
||||
{
|
||||
name: "no h1 uses filename",
|
||||
markdown: "## Just h2\n\nContent",
|
||||
want: "", // or filename from context
|
||||
},
|
||||
{
|
||||
name: "multiple h1s takes first",
|
||||
markdown: "# First\n\n# Second",
|
||||
want: "First",
|
||||
},
|
||||
{
|
||||
name: "h1 with formatting",
|
||||
markdown: "# **Bold** and *italic* title",
|
||||
want: "Bold and italic title",
|
||||
},
|
||||
{
|
||||
name: "empty file",
|
||||
markdown: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ExtractTitle(tt.markdown)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateNoteID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string // SHA-256 hash
|
||||
}{
|
||||
{
|
||||
name: "consistent hashing",
|
||||
path: "notes/test.md",
|
||||
want: "110bab5b8f",
|
||||
},
|
||||
{
|
||||
name: "different paths different ids",
|
||||
path: "notes/other.md",
|
||||
want: "858d15849b",
|
||||
},
|
||||
{
|
||||
name: "root folder as 'current folder'",
|
||||
path: ".",
|
||||
want: "cdb4ee2aea",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := GenerateNoteID(tt.path)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
|
||||
// Test consistency
|
||||
got2 := GenerateNoteID(tt.path)
|
||||
if got != got2 {
|
||||
t.Error("same path should produce same ID")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
114
internal/render/render.go
Normal file
114
internal/render/render.go
Normal file
@ -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
|
||||
}
|
||||
149
internal/render/render_test.go
Normal file
149
internal/render/render_test.go
Normal file
@ -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: `<h1>Test</h1><h2>01/24/26 09:14:20 - some entry</h2><p>check this out</p>`,
|
||||
},
|
||||
{
|
||||
name: "Markdown, some link",
|
||||
markdown: `# Test 2
|
||||
## 01/24/26 09:14:20 - some entry (bare link)
|
||||
Check this out http://tatata.toto here
|
||||
`,
|
||||
want: `<h1>Test 2</h1><h2>01/24/26 09:14:20 - some entry (bare link)</h2><p>Check this out <a href="http://tatata.toto" target="_blank">http://tatata.toto</a> here</p>`,
|
||||
},
|
||||
{
|
||||
name: "Markdown, some link with description",
|
||||
markdown: `# Test 2
|
||||
## 01/24/26 09:14:20 - some entry (bare link)
|
||||
Check this out [here](http://tatata.toto)
|
||||
`,
|
||||
want: `<h1>Test 2</h1><h2>01/24/26 09:14:20 - some entry (bare link)</h2><p>Check this out <a href="http://tatata.toto" target="_blank">here</a></p>`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range cases {
|
||||
got, err := RenderMarkdown([]byte(test.markdown))
|
||||
if err != nil {
|
||||
t.Errorf("Error rendering markdown: '%s'\n", err)
|
||||
}
|
||||
strip := strings.ReplaceAll(string(got), "\n", "")
|
||||
strip = strings.Trim(strip, " ")
|
||||
|
||||
if strip != test.want {
|
||||
t.Errorf("Rendering markdown: Wanted '%s', got '%s'.\n", test.want, strip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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" }}<html>{{ template "content" . }}</html>{{ 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" }}<html>{{ template "content" . }}</html>{{ 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)
|
||||
}
|
||||
}
|
||||
70
internal/scanner/handler.go
Normal file
70
internal/scanner/handler.go
Normal file
@ -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
|
||||
}
|
||||
168
internal/scanner/scanner.go
Normal file
168
internal/scanner/scanner.go
Normal file
@ -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
|
||||
}
|
||||
58
internal/scanner/scanner_test.go
Normal file
58
internal/scanner/scanner_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
124
internal/service/note.go
Normal file
124
internal/service/note.go
Normal file
@ -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))
|
||||
}
|
||||
76
internal/service/note_test.go
Normal file
76
internal/service/note_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
336
internal/storage/sqlite.go
Normal file
336
internal/storage/sqlite.go
Normal file
@ -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
|
||||
}
|
||||
238
internal/storage/sqlite_test.go
Normal file
238
internal/storage/sqlite_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
130
internal/storage/storage.go
Normal file
130
internal/storage/storage.go
Normal file
@ -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
|
||||
}
|
||||
70
internal/storage/storage_test.go
Normal file
70
internal/storage/storage_test.go
Normal file
@ -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))
|
||||
}
|
||||
}
|
||||
250
internal/web/handler.go
Normal file
250
internal/web/handler.go
Normal file
@ -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
|
||||
}
|
||||
307
internal/web/handler_test.go
Normal file
307
internal/web/handler_test.go
Normal file
@ -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, "<h1>Beta</h1>") {
|
||||
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")
|
||||
}
|
||||
}
|
||||
361
internal/web/static/css/main.css
Normal file
361
internal/web/static/css/main.css
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
14
internal/web/templates/base.tmpl
Normal file
14
internal/web/templates/base.tmpl
Normal file
@ -0,0 +1,14 @@
|
||||
{{ define "base" }}
|
||||
<!doctype html>
|
||||
<html class="no-js" lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Donnie Marko</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/static/css/main.css" type="text/css">
|
||||
</head>
|
||||
<body>
|
||||
{{ template "content" . }}
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
9
internal/web/templates/index.tmpl
Normal file
9
internal/web/templates/index.tmpl
Normal file
@ -0,0 +1,9 @@
|
||||
{{ define "content" }}
|
||||
{{/* List of notes and searching utilities */}}
|
||||
{{ template "noteList" . }}
|
||||
{{/* Markdown notes rendering area */}}
|
||||
<main>
|
||||
{{ .RenderedNote }}
|
||||
</main>
|
||||
{{ template "metadata" . }}
|
||||
{{ end }}
|
||||
52
internal/web/templates/metadata.tmpl
Normal file
52
internal/web/templates/metadata.tmpl
Normal file
@ -0,0 +1,52 @@
|
||||
{{ define "metadata" }}
|
||||
|
||||
{{ if .RenderedNote }}
|
||||
<aside class="metadata-panel">
|
||||
|
||||
<section class="meta-block">
|
||||
<h3>Tags</h3>
|
||||
<form method="POST" action="/notes/{{ .Note.ID }}/tags" class="tag-form">
|
||||
<input type="text" name="tag" class="tag-input" placeholder="Add tag">
|
||||
<input type="submit" value="add" class="tag-submit" />
|
||||
</form>
|
||||
<div class="meta-tags">
|
||||
{{ range .Note.Tags }}
|
||||
<span class="tag-chip">
|
||||
<a class="tag-link" href="/?tag={{ . | urlquery }}">{{ . }}</a>
|
||||
<form method="POST" action="/notes/{{ $.Note.ID }}/tags/{{ . | urlquery }}" class="tag-remove-form">
|
||||
<button type="submit" class="tag-remove" aria-label="Remove tag {{ . }}">×</button>
|
||||
</form>
|
||||
</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="meta-block">
|
||||
<h3>File Info</h3>
|
||||
<ul class="meta-list">
|
||||
<li><strong>Last Modified:</strong>{{ .Note.GetUpdateDateRep }}</li>
|
||||
<li><strong>Size:</strong> {{ .Note.Size }}</li>
|
||||
<li><strong>Hash:</strong> {{ .Note.ID }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="meta-block">
|
||||
<h3>Category</h3>
|
||||
<div class="meta-category">Software Engineering</div>
|
||||
</section>
|
||||
|
||||
<section class="meta-block">
|
||||
<h3>Document Stats</h3>
|
||||
<ul class="meta-list">
|
||||
<li><strong>Word Count:</strong> 542</li>
|
||||
<li><strong>Unique Words:</strong> 211</li>
|
||||
</ul>
|
||||
|
||||
<!-- Placeholder for future stats such as word cloud -->
|
||||
<div class="meta-stats-placeholder">
|
||||
<p>Word cloud / stats visualization<br>(future)</p>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
55
internal/web/templates/noteList.tmpl
Normal file
55
internal/web/templates/noteList.tmpl
Normal file
@ -0,0 +1,55 @@
|
||||
{{ define "noteList" }}
|
||||
<aside>
|
||||
<header>
|
||||
<h1 class="main-logo"><a href="/">Donnie Marko</a></h1>
|
||||
<form method="GET" action="/" class="search-form">
|
||||
<input type="text" name="search" class="search-bar" placeholder="Search notes or tags... (empty query to clear)">
|
||||
<input type="submit" value="ok"/>
|
||||
</form>
|
||||
<form method="GET" action="/">
|
||||
<select name="sort" value="sort" class="sort-dropdown">
|
||||
<option value="" disabled {{ if eq "" .SortBy }}selected{{ end }}>Sort by</option>
|
||||
<option value="recent" {{ if eq "recent" .SortBy }}selected{{ end }}>Recent</option>
|
||||
<option value="oldest" {{ if eq "oldest" .SortBy }}selected{{ end }}>Oldest</option>
|
||||
<option value="alpha" {{ if eq "alpha" .SortBy }}selected{{ end }}>Alphabetical</option>
|
||||
<option value="ralpha" {{ if eq "ralpha" .SortBy }}selected{{ end }}>Reverse Alphabetical</option>
|
||||
</select>
|
||||
<input type="submit" value="sort" />
|
||||
</form>
|
||||
</header>
|
||||
{{ template "renderSearch" . }}
|
||||
</aside>
|
||||
{{ end }}
|
||||
|
||||
{{ define "renderSearch" }}
|
||||
{{ if ne .SearchTerm "" }}<h2>Matching results for query '{{ .SearchTerm }}' (notes or tags)</h2>{{ end }}
|
||||
{{ if ne .TagFilter "" }}<h2>Filtered by tag '{{ .TagFilter }}'</h2>{{ end }}
|
||||
<ul class="search-results">
|
||||
{{ range .Notes }}
|
||||
<li {{ if eq .ID $.LastActive }}class="active-note"{{ end }}>
|
||||
<div class="note-title">
|
||||
<a href="/notes/{{ .ID }}" data-hash="{{ .ID }}">{{ if ge (len .Title) 30 }}{{printf "%.30s" .Title }}[...]{{ else }} {{ .Title }}{{ end }}</a>
|
||||
</div>
|
||||
<span class="last-modified">{{ .GetUpdateDateRep }}</span>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
|
||||
|
||||
{{/* not used for now, the opposition between flat list from hashmap and tree structure is confusing */}}
|
||||
{{ define "renderTree" }}
|
||||
<ul>
|
||||
{{ range . }}
|
||||
{{ if .IsEnd }}
|
||||
<li><input type="checkbox"/><a href="/notes/{{ .Hash }}" data-hash="{{ .Hash }}">{{ .Title }}</a><span class="last-modified">{{ .LastModified }}</span></li>
|
||||
{{ else }}
|
||||
{{ if .Children }}
|
||||
<li><div class="folder"><input type="checkbox"/><span class="folder">{{ .Path }}</span></folder>
|
||||
{{ template "renderTree" .Children }}
|
||||
</li>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
Reference in New Issue
Block a user