Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2359933f1b | |||
| 5fdcede6f8 | |||
| b571588b15 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
_bin
|
||||
.#*
|
||||
.#*
|
||||
vendor/
|
||||
|
||||
24
CHANGELOG.md
Normal file
24
CHANGELOG.md
Normal file
@ -0,0 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## Release 0.3.1
|
||||
- Metadata fix only
|
||||
|
||||
## Release 0.3.0
|
||||
- New section endpoints: `GET /notes/{id}/sections/{sectionID}`
|
||||
- Level-two headings are now linkable and point to their section pages
|
||||
- Print CSS improvements (page breaks before headings, avoid splitting blocks, reduced margins)
|
||||
- Vendoring support for offline builds (`make vendor`)
|
||||
- Makefile now defaults to `-mod=vendor` for build/test/run (breaking change)
|
||||
- README now includes a UI screenshot
|
||||
|
||||
## Release 0.2.0
|
||||
- Group notes by root folders
|
||||
- Metadatas
|
||||
- Mobile-friendly CSS
|
||||
- FreeBSD-specific compile target and scripts
|
||||
- Logging http requests and errors
|
||||
|
||||
## Release 0.1.0
|
||||
- Core web UI for browsing notes
|
||||
- Tagging system (add/remove, filter, search)
|
||||
- SQLite-backed storage with tests
|
||||
26
Makefile
26
Makefile
@ -1,14 +1,34 @@
|
||||
build:
|
||||
mkdir -p _bin
|
||||
go build -o _bin/donniemarko cmd/main.go
|
||||
GOFLAGS=-mod=vendor go build -o _bin/donniemarko cmd/main.go
|
||||
|
||||
install:
|
||||
cp bin/donniemarko ~/.local/bin/
|
||||
|
||||
vendor:
|
||||
go mod vendor
|
||||
|
||||
test:
|
||||
go test -v -cover ./...
|
||||
GOFLAGS=-mod=vendor go test -v -cover ./...
|
||||
|
||||
run:
|
||||
go run main.go
|
||||
GOFLAGS=-mod=vendor go run main.go
|
||||
|
||||
freebsd:
|
||||
mkdir -p _bin
|
||||
GOOS=freebsd GOARCH=amd64 GOFLAGS=-mod=vendor go build -o _bin/donniemarko-freebsd cmd/main.go
|
||||
@stage_dir="_bin/freebsd-release"; \
|
||||
rm -rf "$$stage_dir"; \
|
||||
ver="$$(cat VERSION 2>/dev/null || echo 0.0.0)"; \
|
||||
archive_dir="donniemarko-freebsd-$${ver}"; \
|
||||
archive_root="$$stage_dir/$$archive_dir"; \
|
||||
mkdir -p "$$archive_root/usr/local/bin" \
|
||||
"$$archive_root/usr/local/etc/rc.d" \
|
||||
"$$archive_root/usr/local/etc/newsyslog.conf.d"; \
|
||||
cp _bin/donniemarko-freebsd "$$archive_root/usr/local/bin/donniemarko"; \
|
||||
cp packaging/freebsd/donniemarko "$$archive_root/usr/local/etc/rc.d/donniemarko"; \
|
||||
cp packaging/freebsd/newsyslog.conf.d/donniemarko "$$archive_root/usr/local/etc/newsyslog.conf.d/donniemarko"; \
|
||||
cp packaging/freebsd/release.Makefile "$$archive_root/Makefile"; \
|
||||
tar -C "$$stage_dir" -czf "_bin/$${archive_dir}.tar.gz" "$$archive_dir"
|
||||
|
||||
all: build install
|
||||
|
||||
18
README.md
18
README.md
@ -1,16 +1,24 @@
|
||||
# donniemarko
|
||||
|
||||
Version: 0.1.0
|
||||
Version: 0.2.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
|
||||

|
||||
|
||||
## About donniemarko
|
||||
|
||||
`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
|
||||
|
||||
## Development
|
||||
|
||||
Vendoring is supported for offline builds.
|
||||
|
||||
Common commands:
|
||||
- `make vendor` to populate `vendor/`
|
||||
- `make build` to build using vendored deps
|
||||
- `make test` to run tests using vendored deps
|
||||
|
||||
12
cmd/main.go
12
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)
|
||||
|
||||
BIN
dm_screen.jpg
Normal file
BIN
dm_screen.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
@ -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 == "" {
|
||||
|
||||
103
internal/note/sections.go
Normal file
103
internal/note/sections.go
Normal file
@ -0,0 +1,103 @@
|
||||
package note
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type Section struct {
|
||||
ID string
|
||||
Heading string
|
||||
Content string
|
||||
}
|
||||
|
||||
// ParseH2Heading returns the heading text for a level-two markdown heading.
|
||||
func ParseH2Heading(line string) (string, bool) {
|
||||
trimmed := strings.TrimLeft(line, " \t")
|
||||
if !strings.HasPrefix(trimmed, "## ") {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimPrefix(trimmed, "## ")), true
|
||||
}
|
||||
|
||||
// ParseSections splits markdown into level-two heading sections.
|
||||
func ParseSections(content string) []Section {
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
var sections []Section
|
||||
|
||||
var current *Section
|
||||
var builder strings.Builder
|
||||
counts := make(map[string]int)
|
||||
|
||||
flush := func() {
|
||||
if current == nil {
|
||||
return
|
||||
}
|
||||
current.Content = builder.String()
|
||||
sections = append(sections, *current)
|
||||
current = nil
|
||||
builder.Reset()
|
||||
}
|
||||
|
||||
for i, line := range lines {
|
||||
heading, ok := ParseH2Heading(line)
|
||||
if ok {
|
||||
flush()
|
||||
base := slugifyHeading(heading)
|
||||
if base == "" {
|
||||
base = "section"
|
||||
}
|
||||
counts[base]++
|
||||
id := base
|
||||
if counts[base] > 1 {
|
||||
id = base + "-" + strconv.Itoa(counts[base])
|
||||
}
|
||||
current = &Section{
|
||||
ID: id,
|
||||
Heading: heading,
|
||||
}
|
||||
}
|
||||
|
||||
if current != nil {
|
||||
builder.WriteString(line)
|
||||
if i < len(lines)-1 {
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flush()
|
||||
return sections
|
||||
}
|
||||
|
||||
func slugifyHeading(input string) string {
|
||||
in := strings.TrimSpace(strings.ToLower(input))
|
||||
if in == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
prevDash := false
|
||||
|
||||
for _, r := range in {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
prevDash = false
|
||||
continue
|
||||
}
|
||||
|
||||
if !prevDash {
|
||||
b.WriteByte('-')
|
||||
prevDash = true
|
||||
}
|
||||
}
|
||||
|
||||
out := b.String()
|
||||
out = strings.Trim(out, "-")
|
||||
return out
|
||||
}
|
||||
69
internal/note/sections_test.go
Normal file
69
internal/note/sections_test.go
Normal file
@ -0,0 +1,69 @@
|
||||
package note
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSlugifyHeading(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "simple", input: "Glossary", want: "glossary"},
|
||||
{name: "date and title", input: "2026-01-01 - First", want: "2026-01-01-first"},
|
||||
{name: "punctuation", input: "Hello, World!", want: "hello-world"},
|
||||
{name: "trim", input: " spaced out ", want: "spaced-out"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := slugifyHeading(tc.input); got != tc.want {
|
||||
t.Fatalf("expected %q, got %q", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSections(t *testing.T) {
|
||||
content := "# Title\nIntro\n## Alpha\nA1\nA2\n## Beta\nB1\n"
|
||||
|
||||
sections := ParseSections(content)
|
||||
if len(sections) != 2 {
|
||||
t.Fatalf("expected 2 sections, got %d", len(sections))
|
||||
}
|
||||
|
||||
if sections[0].Heading != "Alpha" {
|
||||
t.Fatalf("expected first heading Alpha, got %q", sections[0].Heading)
|
||||
}
|
||||
if sections[0].ID != "alpha" {
|
||||
t.Fatalf("expected first id alpha, got %q", sections[0].ID)
|
||||
}
|
||||
if want := "## Alpha\nA1\nA2\n"; sections[0].Content != want {
|
||||
t.Fatalf("expected first content %q, got %q", want, sections[0].Content)
|
||||
}
|
||||
|
||||
if sections[1].Heading != "Beta" {
|
||||
t.Fatalf("expected second heading Beta, got %q", sections[1].Heading)
|
||||
}
|
||||
if sections[1].ID != "beta" {
|
||||
t.Fatalf("expected second id beta, got %q", sections[1].ID)
|
||||
}
|
||||
if want := "## Beta\nB1\n"; sections[1].Content != want {
|
||||
t.Fatalf("expected second content %q, got %q", want, sections[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSections_DuplicateHeadings(t *testing.T) {
|
||||
content := "## Glossary\nTerm A\n## Glossary\nTerm B\n"
|
||||
|
||||
sections := ParseSections(content)
|
||||
if len(sections) != 2 {
|
||||
t.Fatalf("expected 2 sections, got %d", len(sections))
|
||||
}
|
||||
|
||||
if sections[0].ID != "glossary" {
|
||||
t.Fatalf("expected first id glossary, got %q", sections[0].ID)
|
||||
}
|
||||
if sections[1].ID != "glossary-2" {
|
||||
t.Fatalf("expected second id glossary-2, got %q", sections[1].ID)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
25
internal/web/assets.go
Normal 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
|
||||
}
|
||||
@ -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 == "/" {
|
||||
@ -39,23 +43,36 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleTags(w, r)
|
||||
return
|
||||
}
|
||||
if strings.Contains(path, "/sections/") {
|
||||
h.handleSections(w, r)
|
||||
return
|
||||
}
|
||||
h.handleNotes(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// 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 +83,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 +106,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 +147,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 {
|
||||
@ -158,8 +189,9 @@ func (h *Handler) handleNotes(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert markdown to HTML
|
||||
htmlContent, err := render.RenderMarkdown([]byte(note.Content))
|
||||
// Convert markdown to HTML, linking section headings
|
||||
basePath := basePathFromRequest(r)
|
||||
htmlContent, err := renderNoteMarkdown(note.Content, note.ID, basePath)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to render markdown", http.StatusInternalServerError)
|
||||
return
|
||||
@ -169,8 +201,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 = basePath
|
||||
|
||||
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 := renderNoteMarkdown(note.Content, note.ID, state.BasePath)
|
||||
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 +245,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 +340,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) {
|
||||
@ -248,3 +362,92 @@ func parseTagRoute(path string) (noteID string, tag string, isRemove bool) {
|
||||
|
||||
return noteID, "", false
|
||||
}
|
||||
|
||||
func parseSectionRoute(path string) (noteID string, sectionID string) {
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
if len(parts) < 4 || parts[0] != "notes" || parts[2] != "sections" {
|
||||
return "", ""
|
||||
}
|
||||
return parts[1], parts[3]
|
||||
}
|
||||
|
||||
func (h *Handler) handleSections(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
|
||||
}
|
||||
|
||||
noteID, sectionID := parseSectionRoute(r.URL.Path)
|
||||
if noteID == "" || sectionID == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
n, err := h.notesService.GetNoteByHash(noteID)
|
||||
if err != nil {
|
||||
http.Error(w, "Note not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var sectionContent string
|
||||
for _, section := range note.ParseSections(n.Content) {
|
||||
if section.ID == sectionID {
|
||||
sectionContent = section.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if sectionContent == "" {
|
||||
http.Error(w, "Section not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
basePath := basePathFromRequest(r)
|
||||
htmlContent, err := render.RenderMarkdown([]byte(sectionContent))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to render markdown", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
state.Note = n
|
||||
state.RenderedNote = htmlContent
|
||||
state.LastActive = noteID
|
||||
state.BasePath = basePath
|
||||
|
||||
if err := h.templates.Render(w, "index", state); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Render error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func renderNoteMarkdown(content, noteID, basePath string) (template.HTML, error) {
|
||||
linked := linkifySectionsMarkdown(content, noteID, basePath)
|
||||
return render.RenderMarkdown([]byte(linked))
|
||||
}
|
||||
|
||||
func linkifySectionsMarkdown(content, noteID, basePath string) string {
|
||||
sections := note.ParseSections(content)
|
||||
if len(sections) == 0 {
|
||||
return content
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
sectionIdx := 0
|
||||
|
||||
for i, line := range lines {
|
||||
heading, ok := note.ParseH2Heading(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if sectionIdx >= len(sections) {
|
||||
break
|
||||
}
|
||||
link := basePath + "/notes/" + noteID + "/sections/" + sections[sectionIdx].ID
|
||||
lines[i] = "## [" + heading + "](" + link + ")"
|
||||
sectionIdx++
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
@ -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,233 @@ 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 := ¬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, `<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])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerNotes_SectionLinks(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
sectionNote := ¬e.Note{
|
||||
ID: "s1",
|
||||
Title: "Sections",
|
||||
Content: "# Sections\nIntro\n## 2026-01-01 - First\nFirst body\n## Glossary\nTerm A\n## Glossary\nTerm B\n",
|
||||
Path: "notes/sections.md",
|
||||
UpdatedAt: time.Date(2026, 1, 4, 10, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
if err := env.storage.Create(sectionNote); err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/notes/s1", 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, `href="/notes/s1/sections/2026-01-01-first"`) {
|
||||
t.Fatalf("expected link for first section")
|
||||
}
|
||||
if !strings.Contains(body, `href="/notes/s1/sections/glossary"`) {
|
||||
t.Fatalf("expected link for glossary section")
|
||||
}
|
||||
if !strings.Contains(body, `href="/notes/s1/sections/glossary-2"`) {
|
||||
t.Fatalf("expected link for duplicate glossary section")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerNotes_SectionRoute(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
sectionNote := ¬e.Note{
|
||||
ID: "s1",
|
||||
Title: "Sections",
|
||||
Content: "# Sections\nIntro\n## 2026-01-01 - First\nFirst body\n## Glossary\nTerm A\n## Glossary\nTerm B\n",
|
||||
Path: "notes/sections.md",
|
||||
UpdatedAt: time.Date(2026, 1, 4, 10, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
if err := env.storage.Create(sectionNote); err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/notes/s1/sections/glossary", 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, "<h2>Glossary</h2>") {
|
||||
t.Fatalf("expected glossary heading to be rendered")
|
||||
}
|
||||
if !strings.Contains(body, "Term A") {
|
||||
t.Fatalf("expected first glossary content")
|
||||
}
|
||||
if strings.Contains(body, "First body") {
|
||||
t.Fatalf("expected other section content to be excluded")
|
||||
}
|
||||
if strings.Contains(body, "Term B") {
|
||||
t.Fatalf("expected second glossary content to be excluded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerNotes_SectionRoute_Duplicate(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
sectionNote := ¬e.Note{
|
||||
ID: "s1",
|
||||
Title: "Sections",
|
||||
Content: "# Sections\nIntro\n## Glossary\nTerm A\n## Glossary\nTerm B\n",
|
||||
Path: "notes/sections.md",
|
||||
UpdatedAt: time.Date(2026, 1, 4, 10, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
if err := env.storage.Create(sectionNote); err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/notes/s1/sections/glossary-2", 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, "<h2>Glossary</h2>") {
|
||||
t.Fatalf("expected glossary heading to be rendered")
|
||||
}
|
||||
if !strings.Contains(body, "Term B") {
|
||||
t.Fatalf("expected second glossary content")
|
||||
}
|
||||
if strings.Contains(body, "Term A") {
|
||||
t.Fatalf("expected first glossary content to be excluded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerNotes_SectionRoute_NotFound(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
sectionNote := ¬e.Note{
|
||||
ID: "s1",
|
||||
Title: "Sections",
|
||||
Content: "# Sections\n## Alpha\nA\n",
|
||||
Path: "notes/sections.md",
|
||||
UpdatedAt: time.Date(2026, 1, 4, 10, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
if err := env.storage.Create(sectionNote); err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/notes/s1/sections/missing", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
env.handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected status 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
/* PRINT MODE */
|
||||
@media print {
|
||||
body {
|
||||
margin: 2cm 1.5cm 2cm 1.5cm;
|
||||
margin: 1cm;
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
@ -40,7 +40,8 @@
|
||||
}
|
||||
|
||||
main {
|
||||
margin-top: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
break-after: always;
|
||||
color: black;
|
||||
}
|
||||
@ -49,6 +50,28 @@
|
||||
margin-top: 0;
|
||||
color: black;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
break-before: page;
|
||||
page-break-before: always;
|
||||
break-after: avoid;
|
||||
page-break-after: avoid;
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
li,
|
||||
pre,
|
||||
blockquote,
|
||||
table {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
/* SCREEN MODE */
|
||||
@ -71,6 +94,10 @@
|
||||
h4 {
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
.sidebar-toggle, .sidebar-toggle-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* UNIVERSAL */
|
||||
@ -125,7 +152,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 +190,6 @@
|
||||
}
|
||||
|
||||
.note-title a {
|
||||
padding-left: 0.7em;
|
||||
font-size: 0.95em;
|
||||
color: var(--text1);
|
||||
}
|
||||
@ -175,17 +200,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 +230,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 +423,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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,10 +5,10 @@
|
||||
<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" . }}
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -1,39 +1,51 @@
|
||||
{{ 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" . }}
|
||||
<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="{{ .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)">
|
||||
{{ if ne .LastActive "" }}<input type="hidden" name="active" value="{{ .LastActive }}">{{ end }}
|
||||
<input type="submit" value="ok" class="search-submit"/>
|
||||
</form>
|
||||
<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>
|
||||
<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>
|
||||
{{ 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 }}
|
||||
|
||||
|
||||
|
||||
52
packaging/freebsd/donniemarko
Normal file
52
packaging/freebsd/donniemarko
Normal 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"
|
||||
1
packaging/freebsd/newsyslog.conf.d/donniemarko
Normal file
1
packaging/freebsd/newsyslog.conf.d/donniemarko
Normal file
@ -0,0 +1 @@
|
||||
/var/log/donniemarko.log www:www 644 7 * @T00 J
|
||||
12
packaging/freebsd/release.Makefile
Normal file
12
packaging/freebsd/release.Makefile
Normal file
@ -0,0 +1,12 @@
|
||||
PREFIX ?= /usr/local
|
||||
|
||||
BIN_DIR := $(PREFIX)/bin
|
||||
ETC_DIR := $(PREFIX)/etc
|
||||
|
||||
install:
|
||||
install -d $(DESTDIR)$(BIN_DIR)
|
||||
install -m 755 usr/local/bin/donniemarko $(DESTDIR)$(BIN_DIR)/donniemarko
|
||||
install -d $(DESTDIR)$(ETC_DIR)/rc.d
|
||||
install -m 755 usr/local/etc/rc.d/donniemarko $(DESTDIR)$(ETC_DIR)/rc.d/donniemarko
|
||||
install -d $(DESTDIR)$(ETC_DIR)/newsyslog.conf.d
|
||||
install -m 644 usr/local/etc/newsyslog.conf.d/donniemarko $(DESTDIR)$(ETC_DIR)/newsyslog.conf.d/donniemarko
|
||||
Reference in New Issue
Block a user