359 lines
9.8 KiB
Go
359 lines
9.8 KiB
Go
package docs
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
type Repository struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
type DocumentRecord struct {
|
|
ID string `json:"id"`
|
|
Path string `json:"path"`
|
|
CurrentHash string `json:"currentHash"`
|
|
Title string `json:"title"`
|
|
Tags []string `json:"tags"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
type UserRecord struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
DisplayName string `json:"displayName"`
|
|
Role string `json:"role"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
LastSeenAt time.Time `json:"lastSeenAt,omitempty"`
|
|
}
|
|
|
|
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) ListUsers(ctx context.Context) ([]UserRecord, error) {
|
|
rows, err := r.db.QueryContext(ctx, `
|
|
SELECT id, email, COALESCE(display_name, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '')
|
|
FROM users
|
|
ORDER BY created_at DESC
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list users: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var users []UserRecord
|
|
for rows.Next() {
|
|
var (
|
|
user UserRecord
|
|
created string
|
|
lastSeen string
|
|
)
|
|
if err := rows.Scan(&user.ID, &user.Email, &user.DisplayName, &user.Role, &created, &lastSeen); err != nil {
|
|
return nil, fmt.Errorf("scan user: %w", err)
|
|
}
|
|
createdAt, err := time.Parse(time.RFC3339, created)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse user created_at: %w", err)
|
|
}
|
|
user.CreatedAt = createdAt
|
|
if lastSeen != "" {
|
|
parsed, err := time.Parse(time.RFC3339, lastSeen)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse user last_seen_at: %w", err)
|
|
}
|
|
user.LastSeenAt = parsed
|
|
}
|
|
users = append(users, user)
|
|
}
|
|
|
|
return users, rows.Err()
|
|
}
|
|
|
|
func (r *Repository) UpsertUser(ctx context.Context, user UserRecord) (UserRecord, error) {
|
|
now := time.Now().UTC()
|
|
if user.ID == "" {
|
|
user.ID = "user:" + user.Email
|
|
}
|
|
if user.Role == "" {
|
|
user.Role = "viewer"
|
|
}
|
|
if user.DisplayName == "" {
|
|
user.DisplayName = user.Email
|
|
}
|
|
user.CreatedAt = now
|
|
user.LastSeenAt = now
|
|
|
|
if _, err := r.db.ExecContext(ctx, `
|
|
INSERT INTO users (id, email, display_name, role, created_at, last_seen_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(email) DO UPDATE SET
|
|
display_name = excluded.display_name,
|
|
role = excluded.role,
|
|
last_seen_at = excluded.last_seen_at
|
|
`, user.ID, user.Email, user.DisplayName, user.Role, now.Format(time.RFC3339), now.Format(time.RFC3339)); err != nil {
|
|
return UserRecord{}, fmt.Errorf("upsert user %s: %w", user.Email, err)
|
|
}
|
|
|
|
users, err := r.ListUsers(ctx)
|
|
if err != nil {
|
|
return UserRecord{}, err
|
|
}
|
|
for _, candidate := range users {
|
|
if candidate.Email == user.Email {
|
|
return candidate, nil
|
|
}
|
|
}
|
|
return UserRecord{}, sql.ErrNoRows
|
|
}
|
|
|
|
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) ListAttachments(ctx context.Context) ([]AttachmentRecord, error) {
|
|
rows, err := r.db.QueryContext(ctx, `
|
|
SELECT hash, original_name, content_type, size_bytes, created_at
|
|
FROM attachments
|
|
ORDER BY created_at DESC
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list attachments: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var attachments []AttachmentRecord
|
|
for rows.Next() {
|
|
var record AttachmentRecord
|
|
var created string
|
|
if err := rows.Scan(&record.Hash, &record.OriginalName, &record.ContentType, &record.SizeBytes, &created); err != nil {
|
|
return nil, fmt.Errorf("scan attachment: %w", err)
|
|
}
|
|
record.CreatedAt, err = time.Parse(time.RFC3339, created)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse attachment created_at: %w", err)
|
|
}
|
|
attachments = append(attachments, record)
|
|
}
|
|
return attachments, rows.Err()
|
|
}
|
|
|
|
type SearchResult struct {
|
|
Path string `json:"path"`
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
func (r *Repository) SearchDocuments(ctx context.Context, query string) ([]SearchResult, error) {
|
|
rows, err := r.db.QueryContext(ctx, `
|
|
SELECT d.path, d.title
|
|
FROM document_search ds
|
|
JOIN documents d ON d.rowid = ds.rowid
|
|
WHERE document_search MATCH ?
|
|
ORDER BY rank
|
|
LIMIT 50
|
|
`, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("search documents: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []SearchResult
|
|
for rows.Next() {
|
|
var result SearchResult
|
|
if err := rows.Scan(&result.Path, &result.Title); err != nil {
|
|
return nil, fmt.Errorf("scan search result: %w", err)
|
|
}
|
|
results = append(results, result)
|
|
}
|
|
|
|
return results, rows.Err()
|
|
}
|
|
|
|
func (r *Repository) UpdateSearchContent(ctx context.Context, path string, content string) error {
|
|
if _, err := r.db.ExecContext(ctx, `
|
|
UPDATE document_search SET content = ?
|
|
WHERE rowid = (SELECT rowid FROM documents WHERE path = ?)
|
|
`, content, path); err != nil {
|
|
return fmt.Errorf("update search content: %w", err)
|
|
}
|
|
return 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
|
|
}
|