From b571588b15b3fb9dc5c3d291d8f92de8cda3af53 Mon Sep 17 00:00:00 2001 From: adminoo Date: Wed, 4 Feb 2026 13:15:55 +0100 Subject: [PATCH] feat(release): v0.2.0 commit 78d6c27c8940da32a6de8e64327c86f74fdaa2eb Author: adminoo Date: Wed Feb 4 12:59:22 2026 +0100 feat: freebsd log rotation config thingie commit 55af4e6c70122e679272ed247c26e04b1247f694 Author: adminoo Date: Wed Feb 4 12:58:43 2026 +0100 feat: embed templates, static resolution commit 29c917f929a7378ec29c54315ee2e9f420747787 Author: adminoo Date: Wed Feb 4 10:44:34 2026 +0100 feat: set log file path commit 294fd3d1549979eab63587ceec6ff5d0978e9afc Author: adminoo Date: Wed Feb 4 10:23:53 2026 +0100 feat: logging HTTP request commit c9ae80b240d58e1abed7ae3b7b2c3b283a31f1a1 Author: adminoo Date: Wed Feb 4 09:54:05 2026 +0100 feat: freebsd-specific compile target and scripts commit 86ca154dedd19aa1fe5f571c445dcf17a8396bfa Author: adminoo Date: Wed Feb 4 09:25:16 2026 +0100 feat: mobile friendly CSS commit 199f4319e0b08a4b6d595d7eb3effb6db6c7beec Author: adminoo Date: Wed Feb 4 09:25:03 2026 +0100 feat: persisting rendered note commit 865e258237e45d7c542685a4653bcad3c5af259d Author: adminoo Date: Wed Feb 4 08:06:38 2026 +0100 fix: grouping notes by folder commit 242d1d074c92461f38212b033c7a9e383f9dc550 Author: adminoo 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 Date: Tue Feb 3 15:27:07 2026 +0100 feat: group notes by root folders commit e1e25a938e717599332f7b40a449d9bb854b673a Author: adminoo Date: Tue Feb 3 14:24:37 2026 +0100 feat: size in kilobytes commit 61220272a2df2b66c2b8e356ba359ed01de3bd12 Author: adminoo Date: Tue Feb 3 14:19:40 2026 +0100 feat: styling inputs --- Makefile | 4 + README.md | 2 +- VERSION | 2 +- cmd/main.go | 12 +- internal/note/note.go | 10 +- internal/render/render.go | 12 +- internal/render/render_test.go | 8 +- internal/scanner/handler.go | 22 ++- internal/scanner/scanner.go | 20 ++- internal/scanner/scanner_test.go | 49 ++++++ internal/storage/sqlite.go | 5 + internal/storage/sqlite_test.go | 42 +++++- internal/storage/storage.go | 9 ++ internal/storage/storage_test.go | 15 ++ internal/web/assets.go | 25 +++ internal/web/handler.go | 117 ++++++++++++++- internal/web/handler_test.go | 142 +++++++++++++++++- internal/web/static/css/main.css | 129 +++++++++++++++- internal/web/templates/base.tmpl | 4 +- internal/web/templates/metadata.tmpl | 29 +--- internal/web/templates/noteList.tmpl | 50 +++--- packaging/freebsd/donniemarko | 52 +++++++ .../freebsd/newsyslog.conf.d/donniemarko | 1 + 23 files changed, 691 insertions(+), 70 deletions(-) create mode 100644 internal/web/assets.go create mode 100644 packaging/freebsd/donniemarko create mode 100644 packaging/freebsd/newsyslog.conf.d/donniemarko diff --git a/Makefile b/Makefile index 4de3e14..a2c2a86 100755 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index c24d762..739f8ef 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # donniemarko -Version: 0.1.0 +Version: 0.2.0 Knowledge Management System over markdown notes. diff --git a/VERSION b/VERSION index 6e8bf73..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 diff --git a/cmd/main.go b/cmd/main.go index 5ef808b..382925f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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) diff --git a/internal/note/note.go b/internal/note/note.go index e22179a..dd65df1 100644 --- a/internal/note/note.go +++ b/internal/note/note.go @@ -12,7 +12,7 @@ type Note struct { Path string Title string Content string - Size int64 + Size int64 // HTMLContent string // Directly in Note Tags []string @@ -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 == "" { diff --git a/internal/render/render.go b/internal/render/render.go index b096643..8adc701 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -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) } diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 1b07bd0..ab53048 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -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 { diff --git a/internal/scanner/handler.go b/internal/scanner/handler.go index f397401..ae320ba 100644 --- a/internal/scanner/handler.go +++ b/internal/scanner/handler.go @@ -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) diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index f65903b..a657c92 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -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 } @@ -62,7 +78,7 @@ func (s *ScannerService) Scan() ([]Change, error) { if s.RootDir == path { return nil } - + // ignore anything that isn't a note if !isValidNoteFile(path, info) { return nil diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index bf15096..5711289 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -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)) + } +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index a879e77..e8a85ab 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -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 diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index e16ab32..950bcff 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -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) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 71a8120..aa0d8a0 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -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 } diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index b56135f..1f6a0d0 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -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) diff --git a/internal/web/assets.go b/internal/web/assets.go new file mode 100644 index 0000000..0410e40 --- /dev/null +++ b/internal/web/assets.go @@ -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 +} diff --git a/internal/web/handler.go b/internal/web/handler.go index 8e91811..ef3174f 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -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) { diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go index 0e2974f..af424d3 100644 --- a/internal/web/handler_test.go +++ b/internal/web/handler_test.go @@ -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 := `

` + name + `

` + start := strings.Index(body, header) + if start == -1 { + return "" + } + rest := body[start+len(header):] + next := strings.Index(rest, `

`) + 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, `

dev

`) { + t.Fatalf("expected dev group header") + } + if !strings.Contains(body, `

notes

`) { + 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, "

Beta

") { + t.Fatalf("expected rendered active note content") + } +} + +func TestHandlerRoot_RendersRootGroupHeader(t *testing.T) { + env := newTestEnv(t) + + rootNote := ¬e.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, `

root

`) { + 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]) + } +} diff --git a/internal/web/static/css/main.css b/internal/web/static/css/main.css index dc7c25d..c2d2896 100644 --- a/internal/web/static/css/main.css +++ b/internal/web/static/css/main.css @@ -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; + } +} diff --git a/internal/web/templates/base.tmpl b/internal/web/templates/base.tmpl index cbaf12b..2419749 100644 --- a/internal/web/templates/base.tmpl +++ b/internal/web/templates/base.tmpl @@ -5,10 +5,10 @@ Donnie Marko - + {{ template "content" . }} -{{ end }} \ No newline at end of file +{{ end }} diff --git a/internal/web/templates/metadata.tmpl b/internal/web/templates/metadata.tmpl index 65e478e..8b2e091 100644 --- a/internal/web/templates/metadata.tmpl +++ b/internal/web/templates/metadata.tmpl @@ -5,15 +5,15 @@

Tags

-
+
{{ range .Note.Tags }} - {{ . }} -
+ {{ . }} +
@@ -24,29 +24,12 @@

File Info

    -
  • Last Modified:{{ .Note.GetUpdateDateRep }}
  • -
  • Size: {{ .Note.Size }}
  • +
  • Path: {{ .Note.Path }}
  • Hash: {{ .Note.ID }}
  • +
  • Size: {{ .Note.GetSizeKB }}
  • +
  • Last Modified:{{ .Note.GetUpdateDateRep }}
- -
-

Category

-
Software Engineering
-
- -
-

Document Stats

-
    -
  • Word Count: 542
  • -
  • Unique Words: 211
  • -
- - -
-

Word cloud / stats visualization
(future)

-
-
{{ end }} {{ end }} diff --git a/internal/web/templates/noteList.tmpl b/internal/web/templates/noteList.tmpl index bc1b086..3e72750 100644 --- a/internal/web/templates/noteList.tmpl +++ b/internal/web/templates/noteList.tmpl @@ -1,39 +1,51 @@ {{ define "noteList" }} {{ end }} {{ define "renderSearch" }} {{ if ne .SearchTerm "" }}

Matching results for query '{{ .SearchTerm }}' (notes or tags)

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

Filtered by tag '{{ .TagFilter }}'

{{ end }} + {{ if eq (len .Groups) 0 }} +

No notes match your filters.

+ {{ end }} + {{ range .Groups }} +

{{ .Name }}

+ {{ end }} {{ end }} diff --git a/packaging/freebsd/donniemarko b/packaging/freebsd/donniemarko new file mode 100644 index 0000000..4658511 --- /dev/null +++ b/packaging/freebsd/donniemarko @@ -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" diff --git a/packaging/freebsd/newsyslog.conf.d/donniemarko b/packaging/freebsd/newsyslog.conf.d/donniemarko new file mode 100644 index 0000000..9a804ef --- /dev/null +++ b/packaging/freebsd/newsyslog.conf.d/donniemarko @@ -0,0 +1 @@ +/var/log/donniemarko.log www:www 644 7 * @T00 J