feat: bootstrap foundation application
This commit is contained in:
205
apps/server/internal/docs/repository.go
Normal file
205
apps/server/internal/docs/repository.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type DocumentRecord struct {
|
||||
ID string
|
||||
Path string
|
||||
CurrentHash string
|
||||
Title string
|
||||
Tags []string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AttachmentRecord struct {
|
||||
Hash string
|
||||
OriginalName string
|
||||
ContentType string
|
||||
SizeBytes int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type PersistDocumentInput struct {
|
||||
ID string
|
||||
Path string
|
||||
Title string
|
||||
Hash string
|
||||
PreviousHash string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
func NewRepository(db *sql.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
func (r *Repository) PersistDocument(ctx context.Context, input PersistDocumentInput) error {
|
||||
now := time.Now().UTC()
|
||||
tagsJSON, err := json.Marshal(input.Tags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal tags: %w", err)
|
||||
}
|
||||
|
||||
existing, err := r.GetDocumentByPath(ctx, input.Path)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
documentID := input.ID
|
||||
createdAt := now
|
||||
if existing != nil {
|
||||
documentID = existing.ID
|
||||
createdAt = existing.CreatedAt
|
||||
}
|
||||
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO documents (id, path, current_hash, title, tags, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
current_hash = excluded.current_hash,
|
||||
title = excluded.title,
|
||||
tags = excluded.tags,
|
||||
updated_at = excluded.updated_at
|
||||
`, documentID, input.Path, input.Hash, input.Title, string(tagsJSON), createdAt.Format(time.RFC3339), now.Format(time.RFC3339)); err != nil {
|
||||
return fmt.Errorf("upsert document %s: %w", input.Path, err)
|
||||
}
|
||||
|
||||
if existing != nil && existing.CurrentHash == input.Hash {
|
||||
return nil
|
||||
}
|
||||
|
||||
versionID := documentID + ":" + input.Hash[:12]
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT OR IGNORE INTO document_versions (id, document_id, hash, previous_hash, created_at, change_summary)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, versionID, documentID, input.Hash, input.PreviousHash, now.Format(time.RFC3339), "filesystem sync"); err != nil {
|
||||
return fmt.Errorf("insert document version %s: %w", input.Path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetDocumentByPath(ctx context.Context, path string) (*DocumentRecord, error) {
|
||||
var (
|
||||
record DocumentRecord
|
||||
tagsJSON string
|
||||
created string
|
||||
updated string
|
||||
)
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, path, current_hash, title, tags, created_at, updated_at
|
||||
FROM documents
|
||||
WHERE path = ?
|
||||
`, path).Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := parseDocumentTimes(&record, created, updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal document tags: %w", err)
|
||||
}
|
||||
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, path, current_hash, title, tags, created_at, updated_at
|
||||
FROM documents
|
||||
ORDER BY path ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list documents: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []DocumentRecord
|
||||
for rows.Next() {
|
||||
var (
|
||||
record DocumentRecord
|
||||
tagsJSON string
|
||||
created string
|
||||
updated string
|
||||
)
|
||||
if err := rows.Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated); err != nil {
|
||||
return nil, fmt.Errorf("scan document: %w", err)
|
||||
}
|
||||
if err := parseDocumentTimes(&record, created, updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal document tags: %w", err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) SaveAttachment(ctx context.Context, record AttachmentRecord) error {
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO attachments (hash, original_name, content_type, size_bytes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(hash) DO UPDATE SET
|
||||
original_name = excluded.original_name,
|
||||
content_type = excluded.content_type,
|
||||
size_bytes = excluded.size_bytes
|
||||
`, record.Hash, record.OriginalName, record.ContentType, record.SizeBytes, record.CreatedAt.Format(time.RFC3339)); err != nil {
|
||||
return fmt.Errorf("save attachment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetAttachment(ctx context.Context, hash string) (*AttachmentRecord, error) {
|
||||
var (
|
||||
record AttachmentRecord
|
||||
created string
|
||||
)
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT hash, original_name, content_type, size_bytes, created_at
|
||||
FROM attachments
|
||||
WHERE hash = ?
|
||||
`, hash).Scan(&record.Hash, &record.OriginalName, &record.ContentType, &record.SizeBytes, &created)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, created)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse attachment created_at: %w", err)
|
||||
}
|
||||
record.CreatedAt = parsed
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (r *Repository) Ping(ctx context.Context) error {
|
||||
return r.db.PingContext(ctx)
|
||||
}
|
||||
|
||||
func parseDocumentTimes(record *DocumentRecord, created string, updated string) error {
|
||||
createdAt, err := time.Parse(time.RFC3339, created)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse document created_at: %w", err)
|
||||
}
|
||||
updatedAt, err := time.Parse(time.RFC3339, updated)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse document updated_at: %w", err)
|
||||
}
|
||||
record.CreatedAt = createdAt
|
||||
record.UpdatedAt = updatedAt
|
||||
return nil
|
||||
}
|
||||
154
apps/server/internal/docs/service.go
Normal file
154
apps/server/internal/docs/service.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/tim/md-hub-secure/apps/server/internal/markdown"
|
||||
"github.com/tim/md-hub-secure/apps/server/internal/store"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
sourceDir string
|
||||
store *store.ContentStore
|
||||
renderer *markdown.Renderer
|
||||
repo *Repository
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type Page struct {
|
||||
Path string
|
||||
Title string
|
||||
Tags []string
|
||||
HTML string
|
||||
Hash string
|
||||
}
|
||||
|
||||
func NewService(sourceDir string, store *store.ContentStore, renderer *markdown.Renderer, repo *Repository, logger *slog.Logger) *Service {
|
||||
return &Service{
|
||||
sourceDir: sourceDir,
|
||||
store: store,
|
||||
renderer: renderer,
|
||||
repo: repo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) SyncSourceDir(ctx context.Context) error {
|
||||
return filepath.WalkDir(s.sourceDir, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() || filepath.Ext(path) != ".md" {
|
||||
return nil
|
||||
}
|
||||
return s.syncFile(ctx, path)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ListDocuments(ctx context.Context) ([]DocumentRecord, error) {
|
||||
if err := s.SyncSourceDir(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListDocuments(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, error) {
|
||||
if err := s.SyncSourceDir(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
normalized := normalizeRequestPath(requestPath)
|
||||
record, err := s.repo.GetDocumentByPath(ctx, normalized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
content, err := s.store.Read(record.CurrentHash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read content %s: %w", record.CurrentHash, err)
|
||||
}
|
||||
|
||||
rendered, err := s.renderer.Render(content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render page %s: %w", normalized, err)
|
||||
}
|
||||
|
||||
return &Page{
|
||||
Path: record.Path,
|
||||
Title: rendered.Title,
|
||||
Tags: record.Tags,
|
||||
HTML: string(rendered.HTML),
|
||||
Hash: record.CurrentHash,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeRequestPath(path string) string {
|
||||
path = strings.Trim(path, "/")
|
||||
if path == "" {
|
||||
return "getting-started.md"
|
||||
}
|
||||
if !strings.HasSuffix(path, ".md") {
|
||||
path += ".md"
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func (s *Service) syncFile(ctx context.Context, path string) error {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read content file %s: %w", path, err)
|
||||
}
|
||||
|
||||
rendered, err := s.renderer.Render(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render content file %s: %w", path, err)
|
||||
}
|
||||
|
||||
record, err := s.store.PutBytes(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store content file %s: %w", path, err)
|
||||
}
|
||||
|
||||
relative, err := filepath.Rel(s.sourceDir, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compute relative path %s: %w", path, err)
|
||||
}
|
||||
relative = filepath.ToSlash(relative)
|
||||
|
||||
existing, err := s.repo.GetDocumentByPath(ctx, relative)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
previousHash := ""
|
||||
documentID := "doc:" + strings.TrimSuffix(relative, ".md")
|
||||
if existing != nil {
|
||||
previousHash = existing.CurrentHash
|
||||
documentID = existing.ID
|
||||
if existing.CurrentHash == record.Hash {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.repo.PersistDocument(ctx, PersistDocumentInput{
|
||||
ID: documentID,
|
||||
Path: relative,
|
||||
Title: rendered.Title,
|
||||
Hash: record.Hash,
|
||||
PreviousHash: previousHash,
|
||||
Tags: rendered.Tags,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Debug("synced document", "path", relative, "hash", record.Hash)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user