Files
Gardomatic/internal/web/journal.go
T
kleiax 904d14b64c
CI / test (push) Canceled after 0s
Initial commit
2026-09-12 22:22:17 +02:00

353 lines
11 KiB
Go

package web
import (
"bytes"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"strconv"
"strings"
"time"
"gardomatic.kleiax.de/lib/client"
)
const maxJournalFormSize = 100 << 20
type journalForm struct {
Title string
Body string
DateTime string
Keywords string
TagSuggestions []string
Attachments []client.JournalAttachment
LibraryImageIDs []int
Errors map[string]string
}
type gardenEntryView struct {
entryType string
path string
}
var (
journalEntryView = gardenEntryView{entryType: client.JournalEntryTypeJournal, path: "journal"}
pinboardEntryView = gardenEntryView{entryType: client.JournalEntryTypePinboard, path: "pinboard"}
)
func (app *application) journal(w http.ResponseWriter, r *http.Request) {
app.gardenEntries(w, r, journalEntryView)
}
func (app *application) pinboard(w http.ResponseWriter, r *http.Request) {
app.gardenEntries(w, r, pinboardEntryView)
}
func (app *application) gardenEntries(w http.ResponseWriter, r *http.Request, view gardenEntryView) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
entries, _, err := apiClient.JournalEntriesByType(r.Context(), gardenID, view.entryType)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
entries, data.Pagination = paginateCollection(r, entries)
data.Garden, data.JournalEntries, data.ContentKind = &garden, entries, view.entryType
app.render(w, http.StatusOK, "journal.tmpl", data)
}
func (app *application) journalCreate(w http.ResponseWriter, r *http.Request) {
app.gardenEntryCreate(w, r, journalEntryView)
}
func (app *application) pinboardCreate(w http.ResponseWriter, r *http.Request) {
app.gardenEntryCreate(w, r, pinboardEntryView)
}
func (app *application) gardenEntryCreate(w http.ResponseWriter, r *http.Request, view gardenEntryView) {
form := journalForm{DateTime: time.Now().Format("2006-01-02T15:04"), Errors: map[string]string{}}
app.loadJournalTagSuggestions(r, &form)
app.renderJournalForm(w, r, form, http.StatusOK, 0, view)
}
func (app *application) journalEdit(w http.ResponseWriter, r *http.Request) {
app.gardenEntryEdit(w, r, journalEntryView)
}
func (app *application) pinboardEdit(w http.ResponseWriter, r *http.Request) {
app.gardenEntryEdit(w, r, pinboardEntryView)
}
func (app *application) gardenEntryEdit(w http.ResponseWriter, r *http.Request, view gardenEntryView) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
entryID, err := app.readPathID(r, "entryID")
if err != nil {
app.notFound(w)
return
}
entry, _, err := client.FromContext(r.Context()).JournalEntry(r.Context(), gardenID, entryID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
if entry.EntryType != view.entryType {
app.notFound(w)
return
}
form := journalForm{Title: entry.Title, Body: entry.Body, DateTime: entry.CreatedAt.In(time.Local).Format("2006-01-02T15:04"), Keywords: strings.Join(entry.Tags, ", "), Attachments: entry.Attachments, Errors: map[string]string{}}
app.loadJournalTagSuggestions(r, &form)
app.renderJournalForm(w, r, form, http.StatusOK, entryID, view)
}
func (app *application) journalSave(w http.ResponseWriter, r *http.Request) {
app.gardenEntrySave(w, r, journalEntryView)
}
func (app *application) pinboardSave(w http.ResponseWriter, r *http.Request) {
app.gardenEntrySave(w, r, pinboardEntryView)
}
func (app *application) gardenEntrySave(w http.ResponseWriter, r *http.Request, view gardenEntryView) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
entryID := 0
if id, readErr := app.readPathID(r, "entryID"); readErr == nil {
entryID = id
}
r.Body = http.MaxBytesReader(w, r.Body, maxJournalFormSize)
if err = r.ParseMultipartForm(16 << 20); err != nil {
app.clientError(w, http.StatusRequestEntityTooLarge)
return
}
form := journalForm{Title: strings.TrimSpace(r.FormValue("title")), Body: strings.TrimSpace(r.FormValue("body")), DateTime: r.FormValue("date_time"), Keywords: r.FormValue("keywords"), Errors: map[string]string{}}
for _, value := range r.MultipartForm.Value["library_image_id"] {
if id, parseErr := strconv.Atoi(value); parseErr == nil && id > 0 {
form.LibraryImageIDs = append(form.LibraryImageIDs, id)
}
}
if form.DateTime == "" {
form.DateTime = time.Now().Format("2006-01-02T15:04")
}
app.loadJournalTagSuggestions(r, &form)
if form.Title == "" && view.entryType == client.JournalEntryTypeJournal {
form.Errors["title"] = "Ein Titel ist erforderlich."
}
if len(form.Body) > 100_000 {
form.Errors["body"] = "Der Text darf höchstens 100.000 Zeichen enthalten."
}
createdAt, parseErr := time.ParseInLocation("2006-01-02T15:04", form.DateTime, time.Local)
if parseErr != nil {
form.Errors["date_time"] = "Bitte ein gültiges Datum und eine gültige Uhrzeit angeben."
}
apiClient := client.FromContext(r.Context())
if entryID > 0 {
if existing, _, getErr := apiClient.JournalEntry(r.Context(), gardenID, entryID); getErr == nil {
if existing.EntryType != view.entryType {
app.notFound(w)
return
}
form.Attachments = existing.Attachments
} else {
app.handleAPIError(w, r, getErr)
return
}
}
if len(form.Errors) > 0 {
app.renderJournalForm(w, r, form, http.StatusUnprocessableEntity, entryID, view)
return
}
entryType := view.entryType
input := client.JournalEntryInput{Title: &form.Title, Body: &form.Body, CreatedAt: &createdAt, Tags: parseKeywords(form.Keywords), EntryType: &entryType}
var entry client.JournalEntry
if entryID > 0 {
entry, _, err = apiClient.UpdateJournalEntry(r.Context(), gardenID, entryID, input)
} else {
entry, _, err = apiClient.CreateJournalEntry(r.Context(), gardenID, input)
}
if err != nil {
var apiError *client.APIError
if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
form.Errors = apiError.Validation
app.renderJournalForm(w, r, form, http.StatusUnprocessableEntity, entryID, view)
return
}
app.handleAPIError(w, r, err)
return
}
entryID = entry.ID
for _, value := range r.MultipartForm.Value["remove_attachment"] {
if id, parseErr := strconv.Atoi(value); parseErr == nil {
_, _ = apiClient.DeleteJournalAttachment(r.Context(), gardenID, entryID, id)
}
}
body := form.Body
for index, header := range r.MultipartForm.File["embedded_images"] {
attachment, uploadErr := app.uploadJournalFile(r, apiClient, gardenID, entryID, header)
if uploadErr != nil {
app.handleAPIError(w, r, uploadErr)
return
}
url := webPath(view.path+".attachment", gardenID, entryID, attachment.ID)
body = strings.ReplaceAll(body, fmt.Sprintf("/pending-journal-image/%d", index), url)
}
for _, header := range r.MultipartForm.File["attachments"] {
if _, uploadErr := app.uploadJournalFile(r, apiClient, gardenID, entryID, header); uploadErr != nil {
app.handleAPIError(w, r, uploadErr)
return
}
}
for _, imageID := range form.LibraryImageIDs {
if _, _, attachErr := apiClient.AttachJournalLibraryImage(r.Context(), gardenID, entryID, imageID); attachErr != nil {
app.handleAPIError(w, r, attachErr)
return
}
}
if body != form.Body {
_, _, err = apiClient.UpdateJournalEntry(r.Context(), gardenID, entryID, client.JournalEntryInput{Body: &body})
}
if err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("garden."+view.path, gardenID), http.StatusSeeOther)
}
func (app *application) uploadJournalFile(r *http.Request, apiClient *client.Client, gardenID, entryID int, header *multipart.FileHeader) (client.JournalAttachment, error) {
file, err := header.Open()
if err != nil {
return client.JournalAttachment{}, err
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, (25<<20)+1))
if err != nil {
return client.JournalAttachment{}, err
}
if len(data) > 25<<20 {
return client.JournalAttachment{}, fmt.Errorf("anhang %q ist größer als 25 MB", header.Filename)
}
mediaType := strings.Split(header.Header.Get("Content-Type"), ";")[0]
attachment, _, err := apiClient.UploadJournalAttachment(r.Context(), gardenID, entryID, header.Filename, mediaType, data)
return attachment, err
}
func (app *application) journalDelete(w http.ResponseWriter, r *http.Request) {
app.gardenEntryDelete(w, r, journalEntryView)
}
func (app *application) pinboardDelete(w http.ResponseWriter, r *http.Request) {
app.gardenEntryDelete(w, r, pinboardEntryView)
}
func (app *application) gardenEntryDelete(w http.ResponseWriter, r *http.Request, view gardenEntryView) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
entryID, err := app.readPathID(r, "entryID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
entry, _, err := apiClient.JournalEntry(r.Context(), gardenID, entryID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
if entry.EntryType != view.entryType {
app.notFound(w)
return
}
if _, err = apiClient.DeleteJournalEntry(r.Context(), gardenID, entryID); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("garden."+view.path, gardenID), http.StatusSeeOther)
}
func (app *application) journalAttachment(w http.ResponseWriter, r *http.Request) {
app.gardenEntryAttachment(w, r)
}
func (app *application) pinboardAttachment(w http.ResponseWriter, r *http.Request) {
app.gardenEntryAttachment(w, r)
}
func (app *application) gardenEntryAttachment(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
entryID, err := app.readPathID(r, "entryID")
if err != nil {
app.notFound(w)
return
}
attachmentID, err := app.readPathID(r, "attachmentID")
if err != nil {
app.notFound(w)
return
}
attachment, _, err := client.FromContext(r.Context()).JournalAttachment(r.Context(), gardenID, entryID, attachmentID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
w.Header().Set("Content-Type", attachment.MediaType)
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", strings.ReplaceAll(attachment.FileName, "\"", "")))
w.Header().Set("Cache-Control", "private, max-age=3600")
http.ServeContent(w, r, attachment.FileName, attachment.CreatedAt, bytes.NewReader(attachment.Data))
}
func (app *application) renderJournalForm(w http.ResponseWriter, r *http.Request, form journalForm, status, entryID int, view gardenEntryView) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
garden, _, err := client.FromContext(r.Context()).Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Garden, data.Form, data.EntryID, data.UseJournalEditor, data.ContentKind = &garden, form, entryID, true, view.entryType
data.Images, err = app.loadImageLibrary(r, gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
app.render(w, status, "journal_form.tmpl", data)
}
func (app *application) loadJournalTagSuggestions(r *http.Request, form *journalForm) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
return
}
if tags, _, tagErr := client.FromContext(r.Context()).GardenTags(r.Context(), gardenID); tagErr == nil {
form.TagSuggestions = tags
}
}