1 Commits

Author SHA1 Message Date
b571588b15 feat(release): v0.2.0
commit 78d6c27c8940da32a6de8e64327c86f74fdaa2eb
Author: adminoo <git@kadath.corp>
Date:   Wed Feb 4 12:59:22 2026 +0100

    feat: freebsd log rotation config thingie

commit 55af4e6c70122e679272ed247c26e04b1247f694
Author: adminoo <git@kadath.corp>
Date:   Wed Feb 4 12:58:43 2026 +0100

    feat: embed templates, static resolution

commit 29c917f929a7378ec29c54315ee2e9f420747787
Author: adminoo <git@kadath.corp>
Date:   Wed Feb 4 10:44:34 2026 +0100

    feat: set log file path

commit 294fd3d1549979eab63587ceec6ff5d0978e9afc
Author: adminoo <git@kadath.corp>
Date:   Wed Feb 4 10:23:53 2026 +0100

    feat: logging HTTP request

commit c9ae80b240d58e1abed7ae3b7b2c3b283a31f1a1
Author: adminoo <git@kadath.corp>
Date:   Wed Feb 4 09:54:05 2026 +0100

    feat: freebsd-specific compile target and scripts

commit 86ca154dedd19aa1fe5f571c445dcf17a8396bfa
Author: adminoo <git@kadath.corp>
Date:   Wed Feb 4 09:25:16 2026 +0100

    feat: mobile friendly CSS

commit 199f4319e0b08a4b6d595d7eb3effb6db6c7beec
Author: adminoo <git@kadath.corp>
Date:   Wed Feb 4 09:25:03 2026 +0100

    feat: persisting rendered note

commit 865e258237e45d7c542685a4653bcad3c5af259d
Author: adminoo <git@kadath.corp>
Date:   Wed Feb 4 08:06:38 2026 +0100

    fix: grouping notes by folder

commit 242d1d074c92461f38212b033c7a9e383f9dc550
Author: adminoo <git@kadath.corp>
Date:   Tue Feb 3 16:52:50 2026 +0100

    feat: storage layer logic

    - Prune notes from db not matching current folder structure at start
    - Detect file system deletion on start by comparing in-db notes
    - Prevent updating of in-db notes at start if modification time is not
      newer
    - Delete by path

commit d75d46bc1ab22bd990d0fdc307e571fe52f0dd99
Author: adminoo <git@kadath.corp>
Date:   Tue Feb 3 15:27:07 2026 +0100

    feat: group notes by root folders

commit e1e25a938e717599332f7b40a449d9bb854b673a
Author: adminoo <git@kadath.corp>
Date:   Tue Feb 3 14:24:37 2026 +0100

    feat: size in kilobytes

commit 61220272a2df2b66c2b8e356ba359ed01de3bd12
Author: adminoo <git@kadath.corp>
Date:   Tue Feb 3 14:19:40 2026 +0100

    feat: styling inputs
2026-02-04 13:17:16 +01:00
23 changed files with 691 additions and 70 deletions

View File

@ -11,4 +11,8 @@ test:
run:
go run main.go
freebsd:
mkdir -p _bin
GOOS=freebsd GOARCH=amd64 go build -o _bin/donniemarko-freebsd cmd/main.go
all: build install

View File

@ -1,6 +1,6 @@
# donniemarko
Version: 0.1.0
Version: 0.2.0
Knowledge Management System over markdown notes.

View File

@ -1 +1 @@
0.1.0
0.2.0

View File

@ -21,6 +21,7 @@ func main() {
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)")
logPath := flag.String("log", "/var/log/donniemarko.log", "Log file path")
flag.BoolVar(&help, "help", false, "display this program usage")
flag.Parse()
@ -57,11 +58,18 @@ func main() {
}()
noteStorage = sqliteStorage
if f, err := os.OpenFile(*logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
log.SetOutput(f)
} else {
log.Printf("failed to open log file %s: %v", *logPath, err)
}
// Initialize scanner
monitor := scanner.NewScanner(*rootFolder)
monitor.SeedExisting(noteStorage.GetAll())
// Initialize notes handler for scanner
notesHandler := scanner.NewNotesHandler(noteStorage)
notesHandler := scanner.NewNotesHandler(noteStorage, *rootFolder)
monitor.SetHandler(notesHandler)
// Initialize service
@ -74,7 +82,7 @@ func main() {
// log.Println("WE GET THERE", len(noteStorage.Index))
// Initialize template manager
tm := render.NewTemplateManager("internal/web/templates")
tm := render.NewTemplateManagerFS(web.TemplatesFS(), "")
// Initialize web handler
handler := web.NewHandler(noteService, tm)

View File

@ -32,6 +32,14 @@ func (n *Note) GetUpdateDateRep() string {
return formatDateRep(n.UpdatedAt)
}
func (n *Note) GetSizeKB() string {
if n.Size <= 0 {
return "0 KB"
}
kb := float64(n.Size) / 1024.0
return fmt.Sprintf("%.1f KB", kb)
}
// ExtractTitle return the first level heading content ('# title')
func ExtractTitle(mkd string) string {
if mkd == "" {

View File

@ -3,7 +3,9 @@ package render
import (
"fmt"
"html/template"
"io/fs"
"net/http"
"os"
"path/filepath"
"sync"
@ -20,13 +22,19 @@ type TemplateManager struct {
mu sync.RWMutex
basePath string
devMode bool
fs fs.FS
}
func NewTemplateManager(basePath string) *TemplateManager {
return NewTemplateManagerFS(os.DirFS("."), basePath)
}
func NewTemplateManagerFS(fsys fs.FS, basePath string) *TemplateManager {
return &TemplateManager{
templates: make(map[string]*template.Template),
basePath: basePath,
devMode: false,
fs: fsys,
}
}
@ -52,7 +60,7 @@ func (tm *TemplateManager) GetTemplate(td *TemplateData) (*template.Template, er
}
// Parse template
tmpl, err := template.ParseFiles(files...)
tmpl, err := template.ParseFS(tm.fs, files...)
if err != nil {
return nil, fmt.Errorf("parse template %s: %w", td.Name, err)
}
@ -87,7 +95,7 @@ func (tm *TemplateManager) Render(w http.ResponseWriter, name string, data any)
files = append(files, tm.buildTemplatePath(name))
// Parse templates
tmpl, err := template.ParseFiles(files...)
tmpl, err := template.ParseFS(tm.fs, files...)
if err != nil {
return fmt.Errorf("parse template %s: %w", name, err)
}

View File

@ -60,7 +60,7 @@ func TestTemplateManagerGetTemplate_Caches(t *testing.T) {
writeTemplate(t, baseDir, "base.tmpl", `{{ define "base" }}base{{ end }}`)
writeTemplate(t, baseDir, "index.tmpl", `{{ define "content" }}index{{ end }}`)
tm := NewTemplateManager(baseDir)
tm := NewTemplateManagerFS(os.DirFS(baseDir), "")
td := &TemplateData{
Name: "index",
FileNameSet: []string{"base", "index"},
@ -83,7 +83,7 @@ func TestTemplateManagerGetTemplate_Caches(t *testing.T) {
func TestTemplateManagerGetTemplate_Missing(t *testing.T) {
baseDir := t.TempDir()
tm := NewTemplateManager(baseDir)
tm := NewTemplateManagerFS(os.DirFS(baseDir), "")
td := &TemplateData{
Name: "missing",
@ -103,7 +103,7 @@ func TestTemplateManagerRender(t *testing.T) {
writeTemplate(t, baseDir, "index.tmpl", `{{ define "content" }}hello{{ end }}`)
writeTemplate(t, baseDir, "metadata.tmpl", `{{ define "noteList" }}notes{{ end }}`)
tm := NewTemplateManager(baseDir)
tm := NewTemplateManagerFS(os.DirFS(baseDir), "")
rec := httptest.NewRecorder()
err := tm.Render(rec, "index", map[string]string{"msg": "hi"})
@ -131,7 +131,7 @@ func TestTemplateManagerRender_MissingTemplate(t *testing.T) {
writeTemplate(t, baseDir, "metadata.tmpl", `{{ define "metadata" }}meta{{ end }}`)
writeTemplate(t, baseDir, "metadata.tmpl", `{{ define "noteList" }}notes{{ end }}`)
tm := NewTemplateManager(baseDir)
tm := NewTemplateManagerFS(os.DirFS(baseDir), "")
rec := httptest.NewRecorder()
if err := tm.Render(rec, "index", nil); err == nil {

View File

@ -11,10 +11,11 @@ import (
type NotesHandler struct {
storage storage.Storage
rootDir string
}
func NewNotesHandler(storage storage.Storage) *NotesHandler {
return &NotesHandler{storage: storage}
func NewNotesHandler(storage storage.Storage, rootDir string) *NotesHandler {
return &NotesHandler{storage: storage, rootDir: rootDir}
}
func (h *NotesHandler) HandleCreate(path string) error {
@ -22,6 +23,12 @@ func (h *NotesHandler) HandleCreate(path string) error {
if err != nil {
return err
}
note.Path = path
if h.rootDir != "" {
if rel, err := filepath.Rel(h.rootDir, path); err == nil {
note.Path = rel
}
}
if err := h.storage.Create(note); err != nil {
return err
}
@ -34,6 +41,17 @@ func (h *NotesHandler) HandleModify(path string) error {
}
func (h *NotesHandler) HandleDelete(path string) error {
relPath := path
if h.rootDir != "" {
if rel, err := filepath.Rel(h.rootDir, path); err == nil {
relPath = rel
}
}
h.storage.DeleteByPath(relPath)
if relPath != path {
h.storage.DeleteByPath(path)
}
id := note.GenerateNoteID(path)
h.storage.Delete(id)
log.Printf("Deleted note '%s' from index\n", path)

View File

@ -7,6 +7,8 @@ import (
"path/filepath"
"strings"
"time"
"donniemarko/internal/note"
)
type ChangeType int
@ -39,12 +41,26 @@ type ScannerService struct {
func NewScanner(path string) *ScannerService {
return &ScannerService{
RootDir: path,
RootDir: filepath.Clean(path),
Interval: 5 * time.Second,
LastStates: make(map[string]time.Time),
}
}
// SeedExisting primes the scanner with already-indexed notes so the first scan can detect deletions.
func (s *ScannerService) SeedExisting(notes []*note.Note) {
for _, n := range notes {
if n == nil || n.Path == "" {
continue
}
path := n.Path
if !filepath.IsAbs(path) {
path = filepath.Join(s.RootDir, path)
}
s.LastStates[path] = n.UpdatedAt
}
}
func (s *ScannerService) SetHandler(handler ChangeHandler) {
s.handler = handler
}

View File

@ -5,6 +5,8 @@ import (
"path/filepath"
"testing"
"time"
"donniemarko/internal/note"
)
func TestScanner_DetectsNewFile(t *testing.T) {
@ -56,3 +58,50 @@ func TestScanner_DetectChanges(t *testing.T) {
t.Errorf("Should find renamed file '%s'. Got '%s'\n", newPath, changes[0].Path)
}
}
func TestScanner_SeedExisting_PrunesMissing(t *testing.T) {
tmpDir := t.TempDir()
sc := NewScanner(tmpDir)
n := note.NewNote()
n.Path = "missing.md"
n.UpdatedAt = time.Now()
sc.SeedExisting([]*note.Note{n})
changes, err := sc.Scan()
if err != nil {
t.Fatalf("scan error: %v", err)
}
if len(changes) != 1 {
t.Fatalf("expected 1 change, got %d", len(changes))
}
if changes[0].Type != Deleted {
t.Fatalf("expected Deleted change, got %v", changes[0].Type)
}
}
func TestScanner_SeedExisting_KeepsExisting(t *testing.T) {
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "kept.md")
os.WriteFile(filePath, []byte("# Kept"), 0644)
sc := NewScanner(tmpDir)
n := note.NewNote()
n.Path = "kept.md"
n.UpdatedAt = time.Now()
sc.SeedExisting([]*note.Note{n})
changes, err := sc.Scan()
if err != nil {
t.Fatalf("scan error: %v", err)
}
if len(changes) != 0 {
t.Fatalf("expected 0 changes, got %d", len(changes))
}
}

View File

@ -120,6 +120,7 @@ func (s *SQLiteStorage) Create(n *note.Note) error {
updated_at = excluded.updated_at,
size = excluded.size,
published = excluded.published
WHERE excluded.updated_at > notes.updated_at
`,
n.ID,
n.Path,
@ -140,6 +141,10 @@ func (s *SQLiteStorage) Delete(id string) {
_, _ = s.db.Exec(`DELETE FROM notes WHERE id = ?`, id)
}
func (s *SQLiteStorage) DeleteByPath(path string) {
_, _ = s.db.Exec(`DELETE FROM notes WHERE path = ?`, path)
}
func (s *SQLiteStorage) Update(id string, n *note.Note) {
_, _ = s.db.Exec(`
UPDATE notes

View File

@ -118,6 +118,22 @@ func TestSQLiteStorage_Delete(t *testing.T) {
}
}
func TestSQLiteStorage_DeleteByPath(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)
}
st.DeleteByPath("notes/alpha.md")
if st.Count() != 0 {
t.Fatalf("expected count 0 after delete by path")
}
}
func TestSQLiteStorage_Search(t *testing.T) {
st := newSQLiteStorage(t)
@ -223,7 +239,7 @@ func TestSQLiteStorage_Create_Upsert(t *testing.T) {
n.Content = "updated"
n.Title = "Alpha Updated"
n.UpdatedAt = ts.Add(2 * time.Hour)
n.UpdatedAt = ts.Add(4 * time.Hour)
if err := st.Create(n); err != nil {
t.Fatalf("upsert note: %v", err)
}
@ -236,3 +252,27 @@ func TestSQLiteStorage_Create_Upsert(t *testing.T) {
t.Fatalf("expected note to be updated, got %+v", got)
}
}
func TestSQLiteStorage_Create_UpsertSkipsOlder(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 = "older"
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.Content != "one" {
t.Fatalf("expected existing content to remain, got %q", got.Content)
}
}

View File

@ -11,6 +11,7 @@ type Storage interface {
Get(id string) (*note.Note, error)
Create(n *note.Note) error
Delete(id string)
DeleteByPath(path string)
Update(id string, n *note.Note)
Search(query string) []*note.Note
Count() int
@ -58,6 +59,14 @@ func (ns *NoteStorage) Delete(id string) {
delete(ns.Index, id)
}
func (ns *NoteStorage) DeleteByPath(path string) {
for id, n := range ns.Index {
if n.Path == path {
delete(ns.Index, id)
}
}
}
func (ns *NoteStorage) Update(id string, n *note.Note) {
ns.Index[id] = n
}

View File

@ -39,6 +39,21 @@ func TestNoteStorageDelete(t *testing.T) {
}
}
func TestNoteStorageDeleteByPath(t *testing.T) {
ns = NewNoteStorage()
n1 = note.NewNote()
n1.Path = "notes/n1.md"
n1.ID = note.GenerateNoteID("abs/n1.md")
n1.Content = "# one"
ns.Create(n1)
ns.DeleteByPath("notes/n1.md")
if len(ns.Index) != 0 {
t.Fatalf("expected delete by path to remove note")
}
}
func TestNoteStorageGetUpdate(t *testing.T) {
ns.Update(n2.ID, n1)

25
internal/web/assets.go Normal file
View File

@ -0,0 +1,25 @@
package web
import (
"embed"
"io/fs"
)
//go:embed templates/*.tmpl static/**
var embeddedFS embed.FS
func TemplatesFS() fs.FS {
sub, err := fs.Sub(embeddedFS, "templates")
if err != nil {
return embeddedFS
}
return sub
}
func StaticFS() fs.FS {
sub, err := fs.Sub(embeddedFS, "static")
if err != nil {
return embeddedFS
}
return sub
}

View File

@ -5,8 +5,11 @@ import (
"donniemarko/internal/render"
"donniemarko/internal/service"
"html/template"
"log"
"net/http"
"net/url"
"path/filepath"
"sort"
"strings"
)
@ -26,6 +29,7 @@ func NewHandler(ns *service.NotesService, tm *render.TemplateManager) *Handler {
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
log.Printf("request: method=%s path=%s host=%s remote=%s", r.Method, path, r.Host, r.RemoteAddr)
// Handle root and note list
if path == "/" {
@ -44,18 +48,27 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// Handle 404 for other paths
log.Printf("not found: method=%s path=%s host=%s remote=%s", r.Method, path, r.Host, r.RemoteAddr)
http.NotFound(w, r)
}
// ViewState is built per-request, not shared
type ViewState struct {
Notes []*note.Note
Groups []NoteGroup
Note *note.Note
RenderedNote template.HTML
SortBy string
SearchTerm string
TagFilter string
LastActive string
// BasePath is an optional URL prefix (e.g. "/donnie") when served behind a reverse proxy.
BasePath string
}
type NoteGroup struct {
Name string
Notes []*note.Note
}
func (h *Handler) handleRoot(w http.ResponseWriter, r *http.Request) {
@ -66,8 +79,16 @@ func (h *Handler) handleRoot(w http.ResponseWriter, r *http.Request) {
return
}
active := r.URL.Query().Get("active")
if active != "" {
_ = h.setActiveNote(state, active)
}
// Render with state
h.templates.Render(w, "index", state)
if err := h.templates.Render(w, "index", state); err != nil {
log.Printf("render error: %v", err)
http.Error(w, "Render error", http.StatusInternalServerError)
}
}
func (h *Handler) buildViewState(r *http.Request) (*ViewState, error) {
@ -81,6 +102,9 @@ func (h *Handler) buildViewState(r *http.Request) (*ViewState, error) {
searchTerm := query.Get("search")
tagFilter := query.Get("tag")
active := query.Get("active")
// Preserve proxy prefix so templates can build correct links and asset URLs.
basePath := basePathFromRequest(r)
// Get notes from service
var notes []*note.Note
@ -119,16 +143,19 @@ func (h *Handler) buildViewState(r *http.Request) (*ViewState, error) {
return &ViewState{
Notes: notes,
Groups: groupNotesByFolder(notes),
SortBy: sortBy,
SearchTerm: searchTerm,
TagFilter: tagFilter,
LastActive: active,
BasePath: basePath,
}, 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"))))
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(StaticFS()))))
}
func extractHash(path string) string {
@ -169,8 +196,30 @@ func (h *Handler) handleNotes(w http.ResponseWriter, r *http.Request) {
state.Note = note
state.RenderedNote = htmlContent
state.LastActive = hash
// Ensure note view carries proxy prefix for links/forms.
state.BasePath = basePathFromRequest(r)
h.templates.Render(w, "index", state)
if err := h.templates.Render(w, "index", state); err != nil {
log.Printf("render error: %v", err)
http.Error(w, "Render error", http.StatusInternalServerError)
}
}
func (h *Handler) setActiveNote(state *ViewState, noteID string) error {
note, err := h.notesService.GetNoteByHash(noteID)
if err != nil {
return err
}
htmlContent, err := render.RenderMarkdown([]byte(note.Content))
if err != nil {
return err
}
state.Note = note
state.RenderedNote = htmlContent
state.LastActive = noteID
return nil
}
func filterNotesByTag(notes []*note.Note, tag string) []*note.Note {
@ -191,6 +240,64 @@ func filterNotesByTag(notes []*note.Note, tag string) []*note.Note {
return filtered
}
func groupNotesByFolder(notes []*note.Note) []NoteGroup {
groups := make(map[string][]*note.Note)
for _, n := range notes {
name := rootFolderName(n.Path)
groups[name] = append(groups[name], n)
}
if len(groups) == 0 {
return nil
}
names := make([]string, 0, len(groups))
for name := range groups {
names = append(names, name)
}
sort.Strings(names)
result := make([]NoteGroup, 0, len(names))
for _, name := range names {
result = append(result, NoteGroup{
Name: name,
Notes: groups[name],
})
}
return result
}
func rootFolderName(path string) string {
if path == "" {
return "root"
}
clean := filepath.ToSlash(filepath.Clean(path))
if clean == "." || clean == "/" {
return "root"
}
clean = strings.TrimPrefix(clean, "/")
parts := strings.Split(clean, "/")
if len(parts) == 0 || parts[0] == "." {
return "root"
}
if len(parts) == 1 {
return "root"
}
return parts[0]
}
func basePathFromRequest(r *http.Request) string {
// X-Forwarded-Prefix is commonly set by reverse proxies for subpath mounts.
prefix := strings.TrimSpace(r.Header.Get("X-Forwarded-Prefix"))
if prefix == "" {
return ""
}
if !strings.HasPrefix(prefix, "/") {
prefix = "/" + prefix
}
return strings.TrimRight(prefix, "/")
}
func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@ -228,7 +335,9 @@ func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request) {
}
}
http.Redirect(w, r, "/notes/"+noteID, http.StatusSeeOther)
// Redirect using proxy prefix when present.
basePath := basePathFromRequest(r)
http.Redirect(w, r, basePath+"/notes/"+noteID, http.StatusSeeOther)
}
func parseTagRoute(path string) (noteID string, tag string, isRemove bool) {

View File

@ -29,18 +29,21 @@ func newTestEnv(t *testing.T) *testEnv {
ID: "a1",
Title: "Alpha",
Content: "# Alpha\ncontent",
Path: "notes/alpha.md",
UpdatedAt: time.Date(2025, 12, 31, 10, 0, 0, 0, time.UTC),
},
{
ID: "b2",
Title: "Beta",
Content: "# Beta\nRust tips",
Path: "dev/beta.md",
UpdatedAt: time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC),
},
{
ID: "z9",
Title: "Zulu",
Content: "# Zulu\nRust book",
Path: "notes/zulu.md",
UpdatedAt: time.Date(2026, 1, 2, 10, 0, 0, 0, time.UTC),
},
}
@ -83,6 +86,20 @@ func noteMarker(id string) string {
return `data-hash="` + id + `"`
}
func groupSection(body, name string) string {
header := `<h3 class="note-group">` + name + `</h3>`
start := strings.Index(body, header)
if start == -1 {
return ""
}
rest := body[start+len(header):]
next := strings.Index(rest, `<h3 class="note-group">`)
if next == -1 {
return rest
}
return rest[:next]
}
func TestHandlerRoot_DefaultSortRecent(t *testing.T) {
env := newTestEnv(t)
@ -100,7 +117,24 @@ func TestHandlerRoot_DefaultSortRecent(t *testing.T) {
}
body := rec.Body.String()
assertOrder(t, body, []string{noteMarker("z9"), noteMarker("b2"), noteMarker("a1")})
if !strings.Contains(body, `<h3 class="note-group">dev</h3>`) {
t.Fatalf("expected dev group header")
}
if !strings.Contains(body, `<h3 class="note-group">notes</h3>`) {
t.Fatalf("expected notes group header")
}
devSection := groupSection(body, "dev")
if devSection == "" {
t.Fatalf("expected dev section")
}
assertOrder(t, devSection, []string{noteMarker("b2")})
notesSection := groupSection(body, "notes")
if notesSection == "" {
t.Fatalf("expected notes section")
}
assertOrder(t, notesSection, []string{noteMarker("z9"), noteMarker("a1")})
}
func TestHandlerRoot_SortAlpha(t *testing.T) {
@ -116,7 +150,17 @@ func TestHandlerRoot_SortAlpha(t *testing.T) {
}
body := rec.Body.String()
assertOrder(t, body, []string{noteMarker("a1"), noteMarker("b2"), noteMarker("z9")})
devSection := groupSection(body, "dev")
if devSection == "" {
t.Fatalf("expected dev section")
}
assertOrder(t, devSection, []string{noteMarker("b2")})
notesSection := groupSection(body, "notes")
if notesSection == "" {
t.Fatalf("expected notes section")
}
assertOrder(t, notesSection, []string{noteMarker("a1"), noteMarker("z9")})
}
func TestHandlerRoot_Search(t *testing.T) {
@ -305,3 +349,97 @@ func TestHandlerRoot_TagFilter(t *testing.T) {
t.Fatalf("expected matching note to be included")
}
}
func TestHandlerRoot_RendersActiveNote(t *testing.T) {
env := newTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/?search=rust&active=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 active note content")
}
}
func TestHandlerRoot_RendersRootGroupHeader(t *testing.T) {
env := newTestEnv(t)
rootNote := &note.Note{
ID: "r1",
Title: "Root Note",
Content: "# Root\ncontent",
Path: "root.md",
UpdatedAt: time.Date(2026, 1, 3, 10, 0, 0, 0, time.UTC),
}
if err := env.storage.Create(rootNote); err != nil {
t.Fatalf("create note: %v", err)
}
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)
}
body := rec.Body.String()
if !strings.Contains(body, `<h3 class="note-group">root</h3>`) {
t.Fatalf("expected root group header")
}
if !strings.Contains(body, noteMarker("r1")) {
t.Fatalf("expected root note to be rendered")
}
}
func TestRootFolderName(t *testing.T) {
cases := []struct {
name string
path string
want string
}{
{name: "empty", path: "", want: "root"},
{name: "dot", path: ".", want: "root"},
{name: "file at root", path: "bookmarks.md", want: "root"},
{name: "single", path: "notes/file.md", want: "notes"},
{name: "nested", path: "dev/ideas/note.md", want: "dev"},
{name: "absolute", path: "/writings/notes/a.md", want: "writings"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := rootFolderName(tc.path); got != tc.want {
t.Fatalf("expected %q, got %q", tc.want, got)
}
})
}
}
func TestGroupNotesByFolder(t *testing.T) {
notes := []*note.Note{
{ID: "a1", Title: "Alpha", Path: "notes/a.md"},
{ID: "b2", Title: "Beta", Path: "dev/b.md"},
{ID: "c3", Title: "Gamma", Path: "notes/c.md"},
}
groups := groupNotesByFolder(notes)
if len(groups) != 2 {
t.Fatalf("expected 2 groups, got %d", len(groups))
}
if groups[0].Name != "dev" || len(groups[0].Notes) != 1 {
t.Fatalf("unexpected dev group: %+v", groups[0])
}
if groups[1].Name != "notes" || len(groups[1].Notes) != 2 {
t.Fatalf("unexpected notes group: %+v", groups[1])
}
}

View File

@ -71,6 +71,10 @@
h4 {
margin-top: 1.5em;
}
.sidebar-toggle, .sidebar-toggle-label {
display: none;
}
}
/* UNIVERSAL */
@ -125,7 +129,6 @@
header h1 {
color: var(--heading1);
font-size: 2em;
border-bottom: 1px solid var(--border-color);
padding-bottom: 1em;
margin: 0;
text-align: center;
@ -164,7 +167,6 @@
}
.note-title a {
padding-left: 0.7em;
font-size: 0.95em;
color: var(--text1);
}
@ -175,17 +177,26 @@
opacity: 0.7;
}
.note-group {
margin: 1.2em 0 0.4em;
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--heading3);
}
/* SEARCH BAR */
.search-form {
display: flex;
gap: 0.5em;
margin-bottom: 1em;
}
.search-bar {
width: 100%;
padding: 0.45em;
padding: 0.45em 0.6em;
border: 1px solid var(--border-color);
border-radius: 4px;
border-radius: 6px;
background: var(--background2);
color: var(--text1);
}
@ -196,6 +207,36 @@
box-shadow: 0 0 6px #4ea1ff55;
}
.search-submit,
.sort-submit {
padding: 0.45em 0.7em;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--background2);
color: var(--text1);
}
.search-submit:hover,
.sort-submit:hover {
background: var(--background-hover);
cursor: pointer;
}
.sort-form {
display: flex;
gap: 0.5em;
margin-bottom: 1em;
}
.sort-dropdown {
width: 100%;
padding: 0.45em 0.6em;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--background2);
color: var(--text1);
}
/* MAIN CONTENT */
main {
flex: 1;
@ -359,3 +400,83 @@
border: 1px solid var(--border-color);
}
}
/* MOBILE */
@media screen and (max-width: 900px) {
body {
flex-direction: column;
overflow: auto;
}
aside,
main,
.metadata-panel {
height: auto;
overflow: visible;
}
aside {
flex: 0 0 auto;
width: 100%;
border-right: 0;
border-bottom: 1px solid var(--border-color);
padding: 1rem;
}
.sidebar-toggle {
position: absolute;
opacity: 0;
pointer-events: none;
}
.sidebar-toggle-label {
display: block;
width: 100%;
padding: 0.6em 0.8em;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--background2);
color: var(--text1);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.8em;
cursor: pointer;
}
.sidebar-content {
display: none;
margin-top: 1rem;
}
.sidebar-toggle:checked + .sidebar-toggle-label + .sidebar-content {
display: block;
}
main {
width: 100%;
padding: 1rem;
}
.metadata-panel {
display: none;
width: 100%;
border-left: 0;
border-top: 1px solid var(--border-color);
padding: 1rem;
}
.search-form,
.sort-form {
flex-direction: column;
align-items: stretch;
}
.search-submit,
.sort-submit {
width: 100%;
}
.note-title a {
padding-left: 0;
}
}

View File

@ -5,7 +5,7 @@
<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">
<link rel="stylesheet" href="{{ .BasePath }}/static/css/main.css" type="text/css">
</head>
<body>
{{ template "content" . }}

View File

@ -5,15 +5,15 @@
<section class="meta-block">
<h3>Tags</h3>
<form method="POST" action="/notes/{{ .Note.ID }}/tags" class="tag-form">
<form method="POST" action="{{ .BasePath }}/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">
<a class="tag-link" href="{{ $.BasePath }}/?tag={{ . | urlquery }}&active={{ $.Note.ID }}">{{ . }}</a>
<form method="POST" action="{{ $.BasePath }}/notes/{{ $.Note.ID }}/tags/{{ . | urlquery }}" class="tag-remove-form">
<button type="submit" class="tag-remove" aria-label="Remove tag {{ . }}">×</button>
</form>
</span>
@ -24,29 +24,12 @@
<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>Path:</strong> {{ .Note.Path }}</li>
<li><strong>Hash:</strong> {{ .Note.ID }}</li>
<li><strong>Size:</strong> {{ .Note.GetSizeKB }}</li>
<li><strong>Last Modified:</strong>{{ .Note.GetUpdateDateRep }}</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 }}

View File

@ -1,12 +1,16 @@
{{ define "noteList" }}
<aside>
<input type="checkbox" id="sidebar-toggle" class="sidebar-toggle">
<label for="sidebar-toggle" class="sidebar-toggle-label">Notes</label>
<div class="sidebar-content">
<header>
<h1 class="main-logo"><a href="/">Donnie Marko</a></h1>
<form method="GET" action="/" class="search-form">
<h1 class="main-logo"><a href="{{ .BasePath }}/">Donnie Marko</a></h1>
<form method="GET" action="{{ .BasePath }}/" 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"/>
{{ if ne .LastActive "" }}<input type="hidden" name="active" value="{{ .LastActive }}">{{ end }}
<input type="submit" value="ok" class="search-submit"/>
</form>
<form method="GET" action="/">
<form method="GET" action="{{ .BasePath }}/" class="sort-form">
<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>
@ -14,26 +18,34 @@
<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" />
{{ if ne .LastActive "" }}<input type="hidden" name="active" value="{{ .LastActive }}">{{ end }}
<input type="submit" value="sort" class="sort-submit"/>
</form>
</header>
{{ template "renderSearch" . }}
</div>
</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 }}
{{ if eq (len .Groups) 0 }}
<p class="meta-empty">No notes match your filters.</p>
{{ end }}
{{ range .Groups }}
<h3 class="note-group">{{ .Name }}</h3>
<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>
<a href="{{ $.BasePath }}/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 }}
{{ end }}

View File

@ -0,0 +1,52 @@
#!/bin/sh
#
# PROVIDE: donniemarko
# REQUIRE: LOGIN
# KEYWORD: shutdown
#
# Add the following lines to /etc/rc.conf:
# donniemarko_enable="YES"
# donniemarko_user="www"
# donniemarko_group="www"
# donniemarko_root="/path/to/notes"
# donniemarko_db="/var/db/donniemarko/notes.db"
# donniemarko_addr="127.0.0.1:5555"
# donniemarko_log="/var/log/donniemarko.log"
. /etc/rc.subr
name="donniemarko"
rcvar="donniemarko_enable"
load_rc_config $name
: ${donniemarko_enable:="NO"}
: ${donniemarko_user:="www"}
: ${donniemarko_group:="www"}
: ${donniemarko_root:="/var/db/donniemarko/notes"}
: ${donniemarko_db:="/var/db/donniemarko/notes.db"}
: ${donniemarko_addr:="127.0.0.1:5555"}
: ${donniemarko_log:="/var/log/donniemarko.log"}
: ${donniemarko_bin:="/usr/local/bin/donniemarko"}
pidfile="/var/run/${name}.pid"
command="${donniemarko_bin}"
command_args="-root ${donniemarko_root} -db ${donniemarko_db} -addr ${donniemarko_addr} -log ${donniemarko_log}"
start_cmd="${name}_start"
stop_cmd="${name}_stop"
donniemarko_start() {
install -d -m 750 -o "${donniemarko_user}" -g "${donniemarko_group}" "$(dirname "${donniemarko_db}")"
install -d -m 750 -o "${donniemarko_user}" -g "${donniemarko_group}" "${donniemarko_root}"
install -d -m 750 -o "${donniemarko_user}" -g "${donniemarko_group}" "$(dirname "${donniemarko_log}")"
/usr/sbin/daemon -p "${pidfile}" -u "${donniemarko_user}" ${command} ${command_args}
}
donniemarko_stop() {
if [ -f "${pidfile}" ]; then
kill "$(cat "${pidfile}")"
fi
}
run_rc_command "$1"

View File

@ -0,0 +1 @@
/var/log/donniemarko.log www:www 644 7 * @T00 J