353 lines
11 KiB
Go
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
|
|
}
|
|
}
|