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 := `