feat: bootstrap foundation application

This commit is contained in:
2026-04-29 00:26:58 -04:00
parent 8e6646499f
commit 4a72e1e030
53 changed files with 4443 additions and 0 deletions

View 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
}

View 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
}