added conference export backend (untested)
This commit is contained in:
@@ -14,12 +14,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.erbosoft.com/amy/amsterdam/database"
|
||||
"git.erbosoft.com/amy/amsterdam/email"
|
||||
"git.erbosoft.com/amy/amsterdam/exports"
|
||||
"git.erbosoft.com/amy/amsterdam/ui"
|
||||
"git.erbosoft.com/amy/amsterdam/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -681,6 +683,70 @@ func ConferenceExportForm(ctxt ui.AmContext) (string, any) {
|
||||
return "framed", "conf_export.jet"
|
||||
}
|
||||
|
||||
/* ConferenceExport exports data from a conference to a downloaded VCIF file.
|
||||
* Parameters:
|
||||
* ctxt - The AmContext for the request.
|
||||
* Returns:
|
||||
* Command string dictating what to be rendered.
|
||||
* Data as a parameter for the command string.
|
||||
*/
|
||||
func ConferenceExport(ctxt ui.AmContext) (string, any) {
|
||||
comm := ctxt.CurrentCommunity()
|
||||
conf := ctxt.GetScratch("currentConference").(*database.Conference)
|
||||
myLevel := ctxt.GetScratch("levelInConference").(uint16)
|
||||
if !conf.TestPermission("Conference.Change", myLevel) {
|
||||
return "error", ENOPERM
|
||||
}
|
||||
|
||||
if ctxt.FormFieldIsSet("cancel") {
|
||||
return "redirect", fmt.Sprintf("/comm/%s/conf/%s/manage", comm.Alias, ctxt.GetScratch("currentAlias"))
|
||||
} else if !ctxt.FormFieldIsSet("export") {
|
||||
return "error", EBUTTON
|
||||
}
|
||||
|
||||
// Get the topic numbers selected.
|
||||
topicNumStrs, err := ctxt.FormFieldValues("tselect")
|
||||
if err != nil {
|
||||
return "error", err
|
||||
}
|
||||
if len(topicNumStrs) == 0 {
|
||||
return "nocontent", nil // this is a no-op
|
||||
}
|
||||
|
||||
// Convert into a list of topics.
|
||||
topics := make([]*database.Topic, len(topicNumStrs))
|
||||
for i, tn := range topicNumStrs {
|
||||
tnum, err := strconv.ParseInt(tn, 10, 16)
|
||||
if err == nil {
|
||||
topics[i], err = database.AmGetTopicByNumber(ctxt.Ctx(), conf, int16(tnum))
|
||||
}
|
||||
if err != nil {
|
||||
return "error", err
|
||||
}
|
||||
}
|
||||
|
||||
// The tricky bit! We use a dedicated goroutine to generate the streamed output and send it to the inlet end of a pipe.
|
||||
filename := time.Now().Format("exported-data-20060102.xml")
|
||||
r, w := io.Pipe()
|
||||
go func() {
|
||||
start := time.Now()
|
||||
err := exports.VCIFStreamTopicFile(context.Background(), w, topics)
|
||||
if err != nil {
|
||||
log.Errorf("ConferenceExport task failed with %v", err)
|
||||
s := fmt.Sprintf("<!-- ***PROCESSING ERROR*** %v -->\r\n", err)
|
||||
w.Write([]byte(s))
|
||||
}
|
||||
w.Close()
|
||||
dur := time.Since(start)
|
||||
log.Infof("ConferenceExport task completed in %v", dur)
|
||||
}()
|
||||
|
||||
// Now we connect the outlet end of the pipe to the output to the browser.
|
||||
ctxt.SetOutputType("text/xml")
|
||||
ctxt.SetHeader("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
return "stream", r
|
||||
}
|
||||
|
||||
/* CreateConferenceForm displays the dialog for creating a new conference.
|
||||
* Parameters:
|
||||
* ctxt - The AmContext for the request.
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ func (p *PostHeader) IsPublished(ctx context.Context) (bool, error) {
|
||||
* Parameters:
|
||||
* ctx - Standard Go context value.
|
||||
* Returns:
|
||||
* Pointer to structure with post attachment info.
|
||||
* Pointer to structure with post attachment info, or nil if there is no attachment.
|
||||
* Standard Go error status.
|
||||
*/
|
||||
func (p *PostHeader) AttachmentInfo(ctx context.Context) (*PostAttachInfo, error) {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* Amsterdam Web Communities System
|
||||
* Copyright (c) 2025-2026 Erbosoft Metaverse Design Solutions, All Rights Reserved
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
// Package exports contains interfacing code for external data formats.
|
||||
package exports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"git.erbosoft.com/amy/amsterdam/database"
|
||||
)
|
||||
|
||||
/*
|
||||
* This file defines structures and code for working with Venice Conference Interchange Format (VCIF) files.
|
||||
* Amsterdam uses this name for the format for backwards compatibility.
|
||||
*/
|
||||
|
||||
const ISO8601 = "20060102T150405"
|
||||
|
||||
// VCIFBase is the top-level element for a VCIF file.
|
||||
type VCIFBase struct {
|
||||
XMLName xml.Name `xml:"vcif"` // I am the <vcif> element
|
||||
Topics []VCIFTopic `xml:"topic"` // list of topics
|
||||
}
|
||||
|
||||
// VCIFTopic is the VCIF element representing a topic.
|
||||
type VCIFTopic struct {
|
||||
XMLName xml.Name `xml:"topic"` // I am the <topic> element
|
||||
Index int `xml:"index,attr"` // topic index (number, not TopicId)
|
||||
Frozen bool `xml:"frozen,attr"` // is topic frozen?
|
||||
Archived bool `xml:"archived,attr"` // is topic archived?
|
||||
Name string `xml:"topicname"` // topic name
|
||||
Posts []VCIFPost `xml:"post"` // posts in the topic
|
||||
}
|
||||
|
||||
// VCIFPost is the VCIF element representing a post in a topic.
|
||||
type VCIFPost struct {
|
||||
XMLName xml.Name `xml:"post"` // I am the <post> element
|
||||
ID int64 `xml:"id,attr"` // post ID (PostId)
|
||||
Parent int64 `xml:"parent,attr"` // parent PostId (usually 0)
|
||||
Index int `xml:"index,attr"` // post index (number)
|
||||
Lines int `xml:"lines,attr"` // line count
|
||||
Author string `xml:"author,attr"` // author username
|
||||
DateISO8601 string `xml:"date,attr"` // post date, ISO 8601 format
|
||||
Hidden bool `xml:"hidden,attr"` // is post hidden?
|
||||
Scribbled *VCIFScribble `xml:"scribbled"` // is post scribbled?
|
||||
Pseud string `xml:"pseud"` // post pseud
|
||||
Text string `xml:"text"` // post text
|
||||
Attachment *VCIFPostAttachment `xml:"attachment"` // attachment data
|
||||
}
|
||||
|
||||
// VCIFScribble is the VCIF element representing that a post has been scribbled.
|
||||
type VCIFScribble struct {
|
||||
XMLName xml.Name `xml:"scribbled"` // I am the <scribbled> element
|
||||
ByUser string `xml:"by,attr"` // who scribbled it?
|
||||
OnDateISO8601 string `xml:"date,attr"` // scribble date, ISO 8601 format
|
||||
}
|
||||
|
||||
// VCIFPostAttachment is the VCIF element representing an attachment to a post.
|
||||
type VCIFPostAttachment struct {
|
||||
XMLName xml.Name `xml:"attachment"` // I am the <attachment> element
|
||||
Length int `xml:"length,attr"` // length in bytes
|
||||
MIMEType string `xml:"type,attr"` // MIME datatype
|
||||
Filename string `xml:"filename,attr"` // attachment filename
|
||||
Base64Data string `xml:",chardata"` // attachment data, Base 64 encoded
|
||||
}
|
||||
|
||||
/* VCIFFromPost fills in a VCIF post structure with data from a conference post.
|
||||
* Parameters:
|
||||
* ctx - Standard Go context value.
|
||||
* target - Pointer to the target VCIF post structure.
|
||||
* post - The post to fill the target from.
|
||||
* Returns:
|
||||
* Standard Go error status.
|
||||
*/
|
||||
func VCIFFromPost(ctx context.Context, target *VCIFPost, post *database.PostHeader) error {
|
||||
// Fill in the posting user.
|
||||
user, err := post.Creator(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target.Author = user.Username
|
||||
|
||||
// Fill in the post text.
|
||||
target.Text, err = post.Text(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Fill in the scribble data.
|
||||
if post.IsScribbled() {
|
||||
scribbler, err := database.AmGetUser(ctx, *post.ScribbleUid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scribbleData := VCIFScribble{
|
||||
ByUser: scribbler.Username,
|
||||
OnDateISO8601: post.ScribbleDate.Format(ISO8601),
|
||||
}
|
||||
target.Scribbled = &scribbleData
|
||||
} else {
|
||||
target.Scribbled = nil
|
||||
}
|
||||
|
||||
// Fill in the attachment data.
|
||||
ainfo, err := post.AttachmentInfo(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ainfo != nil {
|
||||
newAttachment := VCIFPostAttachment{
|
||||
Length: int(ainfo.Length),
|
||||
MIMEType: ainfo.MIMEType,
|
||||
Filename: ainfo.Filename,
|
||||
}
|
||||
data, err := post.AttachmentData(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newAttachment.Base64Data = base64.StdEncoding.EncodeToString(data)
|
||||
target.Attachment = &newAttachment
|
||||
} else {
|
||||
target.Attachment = nil
|
||||
}
|
||||
|
||||
// Fill in the rest of the data that can't fail.
|
||||
target.ID = post.PostId
|
||||
target.Parent = post.Parent
|
||||
target.Index = int(post.Num)
|
||||
if post.LineCount != nil {
|
||||
target.Lines = int(*post.LineCount)
|
||||
} else {
|
||||
target.Lines = 0
|
||||
}
|
||||
target.DateISO8601 = post.Posted.Format(ISO8601)
|
||||
target.Hidden = post.Hidden
|
||||
if post.Pseud != nil {
|
||||
target.Pseud = *post.Pseud
|
||||
} else {
|
||||
target.Pseud = ""
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/* VCIFFromTopic fills in a VCIF topic structure with cata from a conference topic.
|
||||
* Parameterrs:
|
||||
* ctx - Standard Go context value.
|
||||
* target - Pointer to the target VCIF topic value.
|
||||
* topic - The topic to fill the target from.
|
||||
* Returns:
|
||||
* Standard Go error status.
|
||||
*/
|
||||
func VCIFFromTopic(ctx context.Context, target *VCIFTopic, topic *database.Topic) error {
|
||||
// Get all posts in the topic.
|
||||
posts, err := database.AmGetPostRange(ctx, topic, 0, topic.TopMessage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build the posts array.
|
||||
myPostArray := make([]VCIFPost, len(posts))
|
||||
for i, p := range posts {
|
||||
err = VCIFFromPost(ctx, &(myPostArray[i]), p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error converting post %d: %v", p.Num, err)
|
||||
}
|
||||
}
|
||||
target.Posts = myPostArray
|
||||
|
||||
// Fill in the rest of the data that can't fail.
|
||||
target.Index = int(topic.Number)
|
||||
target.Frozen = topic.Frozen
|
||||
target.Archived = topic.Archived
|
||||
target.Name = topic.Name
|
||||
return nil
|
||||
}
|
||||
|
||||
/* VCIFStreamTopicFile takes a list of topics and writes their VCIF XML encoding to the specified writer.
|
||||
* Parameters:
|
||||
* ctx - Standard Go context value.
|
||||
* w - Writer to receive the XML data.
|
||||
* topics - Array of topics to be written.
|
||||
* Returns:
|
||||
* Standard Go error status.
|
||||
*/
|
||||
func VCIFStreamTopicFile(ctx context.Context, w io.Writer, topics []*database.Topic) error {
|
||||
// Write the header of the file.
|
||||
var b strings.Builder
|
||||
b.WriteString(xml.Header)
|
||||
b.WriteString("\r\n<vcif>\r\n")
|
||||
_, err := w.Write([]byte(b.String()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create the XML encoder.
|
||||
enc := xml.NewEncoder(w)
|
||||
enc.Indent(" ", " ")
|
||||
|
||||
// Encode each topic in turn and write it to the writer.
|
||||
for _, t := range topics {
|
||||
var encodedTopic VCIFTopic
|
||||
err = VCIFFromTopic(ctx, &encodedTopic, t)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error converting topic %d: %v", t.Number, err)
|
||||
}
|
||||
enc.Encode(encodedTopic)
|
||||
}
|
||||
|
||||
// Write the trailing tag.
|
||||
_, err = w.Write([]byte("</vcif>\r\n"))
|
||||
return err
|
||||
}
|
||||
@@ -125,6 +125,7 @@ func setupEcho() *echo.Echo {
|
||||
confGroup.GET("/email", ui.AmWrap(ConferenceEmailForm))
|
||||
confGroup.POST("/email", ui.AmWrap(ConferenceEmail))
|
||||
confGroup.GET("/export", ui.AmWrap(ConferenceExportForm))
|
||||
confGroup.POST("/export", ui.AmWrap(ConferenceExport))
|
||||
confGroup.GET("/hotlist", ui.AmWrap(AddToHotlist))
|
||||
confGroup.GET("/invite", ui.AmWrap(InviteToConference))
|
||||
confGroup.GET("/r/:topic", ui.AmWrap(ReadPosts), ui.SetTopic)
|
||||
|
||||
@@ -12,6 +12,7 @@ package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -51,6 +52,7 @@ type AmContext interface {
|
||||
FormField(string) string
|
||||
FormFieldInt(string) (int, error)
|
||||
FormFieldIsSet(string) bool
|
||||
FormFieldValues(string) ([]string, error)
|
||||
FormFile(string) (*multipart.FileHeader, error)
|
||||
FrameTitle() string
|
||||
FrameMetadata(int) map[string]string
|
||||
@@ -236,6 +238,18 @@ func (c *amContext) FormFieldIsSet(name string) bool {
|
||||
return req.Form.Has(name)
|
||||
}
|
||||
|
||||
// FormFieldValues returns all values for a specified parameter name.
|
||||
func (c *amContext) FormFieldValues(name string) ([]string, error) {
|
||||
vals, err := c.echoContext.FormParams()
|
||||
if err != nil {
|
||||
return make([]string, 0), err
|
||||
}
|
||||
if rc, ok := vals[name]; ok {
|
||||
return rc, nil
|
||||
}
|
||||
return make([]string, 0), errors.New("parameter not found")
|
||||
}
|
||||
|
||||
// FormFile returns a "file" parameter from a multipart upload form.
|
||||
func (c *amContext) FormFile(name string) (*multipart.FileHeader, error) {
|
||||
return c.echoContext.FormFile(name)
|
||||
|
||||
@@ -12,6 +12,7 @@ package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -88,8 +89,12 @@ func AmSendPageData(ctxt echo.Context, amctxt AmContext, command string, data an
|
||||
switch command {
|
||||
case "bytes":
|
||||
err = ctxt.Blob(httprc, amctxt.OutputType(), data.([]byte))
|
||||
case "stream":
|
||||
err = ctxt.Stream(httprc, amctxt.OutputType(), data.(io.Reader))
|
||||
case "redirect":
|
||||
err = ctxt.Redirect(http.StatusFound, data.(string))
|
||||
case "nocontent":
|
||||
err = ctxt.NoContent(http.StatusNoContent)
|
||||
case "string":
|
||||
err = ctxt.String(httprc, data.(string))
|
||||
case "template":
|
||||
|
||||
Reference in New Issue
Block a user