From fc92f4038ad1104e54e99cb85201339c1079e994 Mon Sep 17 00:00:00 2001 From: Amy Gale Ruth Bowersox Date: Sat, 14 Feb 2026 23:09:53 -0700 Subject: [PATCH] added conference export backend (untested) --- conferenceadmin.go | 66 +++++++++++++ database/post.go | 2 +- exports/vcif_xml.go | 223 ++++++++++++++++++++++++++++++++++++++++++++ main.go | 1 + ui/amcontext.go | 14 +++ ui/render_wrap.go | 5 + 6 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 exports/vcif_xml.go diff --git a/conferenceadmin.go b/conferenceadmin.go index 31ef482..a01a09f 100644 --- a/conferenceadmin.go +++ b/conferenceadmin.go @@ -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("\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. diff --git a/database/post.go b/database/post.go index 3805cd4..0be8f78 100644 --- a/database/post.go +++ b/database/post.go @@ -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) { diff --git a/exports/vcif_xml.go b/exports/vcif_xml.go new file mode 100644 index 0000000..f0affda --- /dev/null +++ b/exports/vcif_xml.go @@ -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 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 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 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 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 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\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("\r\n")) + return err +} diff --git a/main.go b/main.go index 643e6b3..ab1f95f 100644 --- a/main.go +++ b/main.go @@ -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) diff --git a/ui/amcontext.go b/ui/amcontext.go index 26ec7fc..3481253 100644 --- a/ui/amcontext.go +++ b/ui/amcontext.go @@ -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) diff --git a/ui/render_wrap.go b/ui/render_wrap.go index a59bc48..f3fa03c 100644 --- a/ui/render_wrap.go +++ b/ui/render_wrap.go @@ -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":