Changeset 0.34.0 (#208)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
244
server/export/chatgpt.go
Normal file
244
server/export/chatgpt.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package export
|
||||
|
||||
// chatgpt.go — v0.34.0 CS4
|
||||
//
|
||||
// Parses ChatGPT conversation exports (conversations.json) and converts
|
||||
// them to Switchboard channel/message models.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"chat-switchboard/models"
|
||||
"chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── ChatGPT format types ─────────────────────
|
||||
|
||||
// ChatGPTExport is the top-level conversations.json structure.
|
||||
type ChatGPTExport []ChatGPTConversation
|
||||
|
||||
// ChatGPTConversation represents one conversation in the ChatGPT export.
|
||||
type ChatGPTConversation struct {
|
||||
Title string `json:"title"`
|
||||
CreateTime float64 `json:"create_time"`
|
||||
UpdateTime float64 `json:"update_time"`
|
||||
Mapping map[string]ChatGPTMappingNode `json:"mapping"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
}
|
||||
|
||||
// ChatGPTMappingNode is a node in the conversation DAG.
|
||||
type ChatGPTMappingNode struct {
|
||||
ID string `json:"id"`
|
||||
Parent *string `json:"parent"`
|
||||
Children []string `json:"children"`
|
||||
Message *ChatGPTMessage `json:"message"`
|
||||
}
|
||||
|
||||
// ChatGPTMessage is a message within a mapping node.
|
||||
type ChatGPTMessage struct {
|
||||
ID string `json:"id"`
|
||||
Author ChatGPTAuthor `json:"author"`
|
||||
CreateTime *float64 `json:"create_time"`
|
||||
Content ChatGPTContent `json:"content"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
|
||||
// ChatGPTAuthor identifies the message sender.
|
||||
type ChatGPTAuthor struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// ChatGPTContent holds the message content.
|
||||
type ChatGPTContent struct {
|
||||
ContentType string `json:"content_type"`
|
||||
Parts []interface{} `json:"parts"`
|
||||
}
|
||||
|
||||
// ── Parsing ──────────────────────────────────
|
||||
|
||||
// ParseChatGPTExport reads and parses a conversations.json file.
|
||||
func ParseChatGPTExport(r io.Reader) (ChatGPTExport, error) {
|
||||
var out ChatGPTExport
|
||||
if err := json.NewDecoder(r).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("chatgpt: parse error: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── Conversion ───────────────────────────────
|
||||
|
||||
// ChatGPTImportResult holds converted channels and messages.
|
||||
type ChatGPTImportResult struct {
|
||||
Channels []models.Channel
|
||||
Messages []models.Message
|
||||
}
|
||||
|
||||
// ConvertChatGPTExport converts ChatGPT conversations to Switchboard models.
|
||||
func ConvertChatGPTExport(convs ChatGPTExport, userID string) *ChatGPTImportResult {
|
||||
result := &ChatGPTImportResult{}
|
||||
|
||||
for _, conv := range convs {
|
||||
ch, msgs := convertConversation(conv, userID)
|
||||
if ch != nil {
|
||||
result.Channels = append(result.Channels, *ch)
|
||||
result.Messages = append(result.Messages, msgs...)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func convertConversation(conv ChatGPTConversation, userID string) (*models.Channel, []models.Message) {
|
||||
channelID := store.NewID()
|
||||
|
||||
title := conv.Title
|
||||
if title == "" {
|
||||
title = "Imported Conversation"
|
||||
}
|
||||
|
||||
createdAt := floatToTime(conv.CreateTime)
|
||||
updatedAt := floatToTime(conv.UpdateTime)
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = createdAt
|
||||
}
|
||||
|
||||
ch := &models.Channel{
|
||||
BaseModel: models.BaseModel{
|
||||
ID: channelID,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
Type: "direct",
|
||||
}
|
||||
|
||||
// Walk the DAG depth-first to produce ordered messages
|
||||
msgs := walkDAG(conv.Mapping, channelID, userID)
|
||||
|
||||
return ch, msgs
|
||||
}
|
||||
|
||||
func walkDAG(mapping map[string]ChatGPTMappingNode, channelID, userID string) []models.Message {
|
||||
// Find root nodes (no parent or parent not in mapping)
|
||||
var roots []string
|
||||
for id, node := range mapping {
|
||||
if node.Parent == nil {
|
||||
roots = append(roots, id)
|
||||
} else if _, ok := mapping[*node.Parent]; !ok {
|
||||
roots = append(roots, id)
|
||||
}
|
||||
}
|
||||
|
||||
var msgs []models.Message
|
||||
idMap := make(map[string]string) // chatgpt node ID → switchboard message ID
|
||||
|
||||
var walk func(nodeID string, parentMsgID *string, siblingIdx int)
|
||||
walk = func(nodeID string, parentMsgID *string, siblingIdx int) {
|
||||
node, ok := mapping[nodeID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if node.Message != nil && node.Message.Content.ContentType != "" {
|
||||
content := extractParts(node.Message.Content.Parts)
|
||||
if content != "" {
|
||||
msgID := store.NewID()
|
||||
idMap[nodeID] = msgID
|
||||
|
||||
role := mapRole(node.Message.Author.Role)
|
||||
createdAt := time.Now().UTC()
|
||||
if node.Message.CreateTime != nil {
|
||||
createdAt = floatToTime(*node.Message.CreateTime)
|
||||
}
|
||||
|
||||
msg := models.Message{
|
||||
BaseModel: models.BaseModel{
|
||||
ID: msgID,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
},
|
||||
ChannelID: channelID,
|
||||
Role: role,
|
||||
Content: content,
|
||||
ParentID: parentMsgID,
|
||||
SiblingIndex: siblingIdx,
|
||||
ParticipantType: "user",
|
||||
ParticipantID: userID,
|
||||
}
|
||||
|
||||
if role == "assistant" || role == "system" || role == "tool" {
|
||||
msg.ParticipantType = role
|
||||
msg.ParticipantID = ""
|
||||
}
|
||||
|
||||
msgs = append(msgs, msg)
|
||||
parentMsgID = &msgID
|
||||
}
|
||||
}
|
||||
|
||||
// Sort children for deterministic order
|
||||
children := make([]string, len(node.Children))
|
||||
copy(children, node.Children)
|
||||
sort.Strings(children)
|
||||
|
||||
for i, childID := range children {
|
||||
walk(childID, parentMsgID, i)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(roots)
|
||||
for _, root := range roots {
|
||||
walk(root, nil, 0)
|
||||
}
|
||||
|
||||
return msgs
|
||||
}
|
||||
|
||||
func mapRole(role string) string {
|
||||
switch role {
|
||||
case "user":
|
||||
return "user"
|
||||
case "assistant":
|
||||
return "assistant"
|
||||
case "system":
|
||||
return "system"
|
||||
case "tool":
|
||||
return "tool"
|
||||
default:
|
||||
return "user"
|
||||
}
|
||||
}
|
||||
|
||||
func extractParts(parts []interface{}) string {
|
||||
var texts []string
|
||||
for _, part := range parts {
|
||||
switch v := part.(type) {
|
||||
case string:
|
||||
if v != "" {
|
||||
texts = append(texts, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(texts) == 0 {
|
||||
return ""
|
||||
}
|
||||
result := texts[0]
|
||||
for i := 1; i < len(texts); i++ {
|
||||
result += "\n" + texts[i]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func floatToTime(f float64) time.Time {
|
||||
if f <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
sec := int64(f)
|
||||
nsec := int64((f - float64(sec)) * 1e9)
|
||||
return time.Unix(sec, nsec).UTC()
|
||||
}
|
||||
216
server/export/entities.go
Normal file
216
server/export/entities.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package export
|
||||
|
||||
// entities.go — v0.34.0 CS0
|
||||
//
|
||||
// Per-entity sanitization: strips sensitive, instance-specific, or
|
||||
// non-portable fields before writing to the export archive. Each
|
||||
// Sanitize* function returns a clean map ready for JSON marshalling.
|
||||
|
||||
import (
|
||||
"chat-switchboard/models"
|
||||
)
|
||||
|
||||
// ── User ─────────────────────────────────────
|
||||
|
||||
// SanitizeUser strips password_hash (json:"-" already), vault fields,
|
||||
// and external_id. The User struct's json tags already exclude
|
||||
// password_hash, so standard marshalling is safe. We nil out a few
|
||||
// more fields for cross-instance portability.
|
||||
func SanitizeUser(u *models.User) *models.User {
|
||||
out := *u // shallow copy
|
||||
out.PasswordHash = ""
|
||||
out.ExternalID = nil
|
||||
return &out
|
||||
}
|
||||
|
||||
// ── Channel ──────────────────────────────────
|
||||
|
||||
// ExportChannel is a Channel without instance-specific provider binding.
|
||||
type ExportChannel struct {
|
||||
models.Channel
|
||||
ProviderConfigID *string `json:"provider_config_id,omitempty"` // always nil in export
|
||||
}
|
||||
|
||||
// SanitizeChannel strips provider_config_id.
|
||||
func SanitizeChannel(ch *models.Channel) ExportChannel {
|
||||
return ExportChannel{
|
||||
Channel: *ch,
|
||||
ProviderConfigID: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// SanitizeChannels batch-sanitizes a slice.
|
||||
func SanitizeChannels(chs []models.Channel) []ExportChannel {
|
||||
out := make([]ExportChannel, len(chs))
|
||||
for i := range chs {
|
||||
chs[i].ProviderConfigID = nil
|
||||
out[i] = SanitizeChannel(&chs[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Message ──────────────────────────────────
|
||||
|
||||
// ExportMessage is a Message without provider_config_id.
|
||||
type ExportMessage struct {
|
||||
models.Message
|
||||
ProviderConfigID *string `json:"provider_config_id,omitempty"` // always nil
|
||||
}
|
||||
|
||||
// SanitizeMessages strips provider_config_id from each message.
|
||||
func SanitizeMessages(msgs []models.Message) []ExportMessage {
|
||||
out := make([]ExportMessage, len(msgs))
|
||||
for i := range msgs {
|
||||
msgs[i].ProviderConfigID = nil
|
||||
out[i] = ExportMessage{Message: msgs[i]}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Channel Model ────────────────────────────
|
||||
|
||||
// ExportChannelModel strips provider_config_id and persona_id.
|
||||
type ExportChannelModel struct {
|
||||
ID string `json:"id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
ModelID string `json:"model_id"`
|
||||
Handle string `json:"handle,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
// SanitizeChannelModels strips instance-specific refs.
|
||||
func SanitizeChannelModels(cms []models.ChannelModel) []ExportChannelModel {
|
||||
out := make([]ExportChannelModel, len(cms))
|
||||
for i, cm := range cms {
|
||||
out[i] = ExportChannelModel{
|
||||
ID: cm.ID,
|
||||
ChannelID: cm.ChannelID,
|
||||
ModelID: cm.ModelID,
|
||||
Handle: cm.Handle,
|
||||
DisplayName: cm.DisplayName,
|
||||
SystemPrompt: cm.SystemPrompt,
|
||||
IsDefault: cm.IsDefault,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── User Model Settings ──────────────────────
|
||||
|
||||
// ExportUserModelSetting strips provider_config_id.
|
||||
type ExportUserModelSetting struct {
|
||||
models.UserModelSetting
|
||||
ProviderConfigID *string `json:"provider_config_id,omitempty"` // always nil
|
||||
}
|
||||
|
||||
// SanitizeUserModelSettings strips instance-specific provider binding.
|
||||
func SanitizeUserModelSettings(ums []models.UserModelSetting) []ExportUserModelSetting {
|
||||
out := make([]ExportUserModelSetting, len(ums))
|
||||
for i := range ums {
|
||||
ums[i].ProviderConfigID = nil
|
||||
out[i] = ExportUserModelSetting{UserModelSetting: ums[i]}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Usage Entry ──────────────────────────────
|
||||
|
||||
// ExportUsageEntry strips provider_config_id and routing_decision.
|
||||
type ExportUsageEntry struct {
|
||||
ID string `json:"id"`
|
||||
ChannelID *string `json:"channel_id,omitempty"`
|
||||
UserID string `json:"user_id"`
|
||||
ModelID string `json:"model_id"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
CacheCreationTokens int `json:"cache_creation_tokens"`
|
||||
CacheReadTokens int `json:"cache_read_tokens"`
|
||||
CostInput *float64 `json:"cost_input,omitempty"`
|
||||
CostOutput *float64 `json:"cost_output,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// SanitizeUsageEntries strips instance-specific fields.
|
||||
func SanitizeUsageEntries(ues []models.UsageEntry) []ExportUsageEntry {
|
||||
out := make([]ExportUsageEntry, len(ues))
|
||||
for i, ue := range ues {
|
||||
out[i] = ExportUsageEntry{
|
||||
ID: ue.ID,
|
||||
ChannelID: ue.ChannelID,
|
||||
UserID: ue.UserID,
|
||||
ModelID: ue.ModelID,
|
||||
Role: ue.Role,
|
||||
PromptTokens: ue.PromptTokens,
|
||||
CompletionTokens: ue.CompletionTokens,
|
||||
CacheCreationTokens: ue.CacheCreationTokens,
|
||||
CacheReadTokens: ue.CacheReadTokens,
|
||||
CostInput: ue.CostInput,
|
||||
CostOutput: ue.CostOutput,
|
||||
CreatedAt: ue.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Workspace ────────────────────────────────
|
||||
|
||||
// ExportWorkspace strips root_path (json:"-" already) and git credentials.
|
||||
type ExportWorkspace struct {
|
||||
models.Workspace
|
||||
GitCredentialID *string `json:"git_credential_id,omitempty"` // always nil
|
||||
}
|
||||
|
||||
// SanitizeWorkspaces strips git credential refs.
|
||||
func SanitizeWorkspaces(ws []models.Workspace) []ExportWorkspace {
|
||||
out := make([]ExportWorkspace, len(ws))
|
||||
for i := range ws {
|
||||
ws[i].GitCredentialID = nil
|
||||
ws[i].GitLastSync = nil
|
||||
out[i] = ExportWorkspace{Workspace: ws[i]}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Persona ──────────────────────────────────
|
||||
|
||||
// ExportPersona strips provider_config_id.
|
||||
type ExportPersona struct {
|
||||
models.Persona
|
||||
ProviderConfigID *string `json:"provider_config_id,omitempty"` // always nil
|
||||
}
|
||||
|
||||
// SanitizePersonas strips instance-specific provider binding.
|
||||
func SanitizePersonas(ps []models.Persona) []ExportPersona {
|
||||
out := make([]ExportPersona, len(ps))
|
||||
for i := range ps {
|
||||
ps[i].ProviderConfigID = nil
|
||||
out[i] = ExportPersona{Persona: ps[i]}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Workflow ─────────────────────────────────
|
||||
|
||||
// SanitizeWorkflows strips webhook secrets.
|
||||
func SanitizeWorkflows(wfs []models.Workflow) []models.Workflow {
|
||||
out := make([]models.Workflow, len(wfs))
|
||||
for i := range wfs {
|
||||
out[i] = wfs[i]
|
||||
out[i].WebhookSecret = ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Note links use models.ExportNoteLink (includes source_note_id).
|
||||
// See models/models.go for the type definition.
|
||||
|
||||
// Note, Memory, Folder, NotificationPreference,
|
||||
// ChannelParticipant, ChannelCursor, Project, ProjectChannel,
|
||||
// ProjectKB, ProjectNote, WorkspaceFile, PersonaGroup,
|
||||
// PersonaGroupMember, KnowledgeBase, KBDocument, Group,
|
||||
// GroupMember, ResourceGrant, Team, TeamMember, WorkflowVersion,
|
||||
// WorkflowStage — these are exported as-is (no sensitive fields
|
||||
// after query-level exclusion of embeddings).
|
||||
211
server/export/format.go
Normal file
211
server/export/format.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package export
|
||||
|
||||
// format.go — v0.34.0 CS0
|
||||
//
|
||||
// Defines the .switchboard archive format: a zip file containing a
|
||||
// manifest.json and per-entity JSON files under data/. File blobs
|
||||
// live under files/{id}/{filename}.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Constants ────────────────────────────────
|
||||
|
||||
const (
|
||||
FormatVersion = 1
|
||||
MaxExportArchiveSize = 500 * 1024 * 1024 // 500 MB total
|
||||
MaxExportFileSize = 100 * 1024 * 1024 // 100 MB per file blob
|
||||
MaxExportFiles = 10000
|
||||
ExportContentType = "application/zip"
|
||||
ExportExtension = ".switchboard"
|
||||
)
|
||||
|
||||
// ── Manifest ─────────────────────────────────
|
||||
|
||||
// Manifest is the top-level metadata written to manifest.json.
|
||||
type Manifest struct {
|
||||
Version string `json:"version"` // server version, e.g. "0.34.0"
|
||||
FormatVersion int `json:"format_version"` // always 1 for now
|
||||
ExportType string `json:"export_type"` // "user", "team"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExportedBy string `json:"exported_by"` // user ID
|
||||
EntityCounts map[string]int `json:"entity_counts"`
|
||||
Scope *ManifestScope `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
// ManifestScope narrows the export to a user or team.
|
||||
type ManifestScope struct {
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
}
|
||||
|
||||
// ── ArchiveWriter ────────────────────────────
|
||||
|
||||
// ArchiveWriter wraps zip.Writer with size tracking and convenience
|
||||
// methods for writing manifest and entity JSON files.
|
||||
type ArchiveWriter struct {
|
||||
zw *zip.Writer
|
||||
written int64
|
||||
maxBytes int64
|
||||
}
|
||||
|
||||
// NewArchiveWriter creates an ArchiveWriter that streams to w.
|
||||
func NewArchiveWriter(w io.Writer) *ArchiveWriter {
|
||||
return &ArchiveWriter{
|
||||
zw: zip.NewWriter(w),
|
||||
maxBytes: MaxExportArchiveSize,
|
||||
}
|
||||
}
|
||||
|
||||
// WriteManifest serializes m as indented JSON into manifest.json.
|
||||
func (aw *ArchiveWriter) WriteManifest(m *Manifest) error {
|
||||
data, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: marshal manifest: %w", err)
|
||||
}
|
||||
return aw.writeEntry("manifest.json", data)
|
||||
}
|
||||
|
||||
// WriteEntityJSON serializes data as indented JSON into data/{name}.json.
|
||||
func (aw *ArchiveWriter) WriteEntityJSON(name string, data interface{}) (int, error) {
|
||||
buf, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("export: marshal %s: %w", name, err)
|
||||
}
|
||||
if err := aw.writeEntry("data/"+name+".json", buf); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Return entity count for manifest
|
||||
switch v := data.(type) {
|
||||
case []interface{}:
|
||||
return len(v), nil
|
||||
default:
|
||||
// Use json.RawMessage length heuristic — caller tracks count
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
// WriteFile streams a file blob into files/{zipPath}. Returns bytes
|
||||
// written or an error if the file exceeds MaxExportFileSize.
|
||||
func (aw *ArchiveWriter) WriteFile(zipPath string, r io.Reader) (int64, error) {
|
||||
if aw.written+1 > aw.maxBytes {
|
||||
return 0, fmt.Errorf("export: archive size limit exceeded")
|
||||
}
|
||||
f, err := aw.zw.Create("files/" + zipPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("export: create zip entry %s: %w", zipPath, err)
|
||||
}
|
||||
lr := io.LimitReader(r, MaxExportFileSize+1)
|
||||
n, err := io.Copy(f, lr)
|
||||
if err != nil {
|
||||
return n, fmt.Errorf("export: write file %s: %w", zipPath, err)
|
||||
}
|
||||
if n > MaxExportFileSize {
|
||||
return n, fmt.Errorf("export: file %s exceeds %d bytes", zipPath, MaxExportFileSize)
|
||||
}
|
||||
aw.written += n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Close finalizes the zip archive.
|
||||
func (aw *ArchiveWriter) Close() error {
|
||||
return aw.zw.Close()
|
||||
}
|
||||
|
||||
// BytesWritten returns the approximate bytes written so far.
|
||||
func (aw *ArchiveWriter) BytesWritten() int64 {
|
||||
return aw.written
|
||||
}
|
||||
|
||||
// ── ArchiveReader ────────────────────────────
|
||||
|
||||
// ArchiveReader wraps zip.ReadCloser with validation helpers.
|
||||
type ArchiveReader struct {
|
||||
zr *zip.ReadCloser
|
||||
}
|
||||
|
||||
// OpenArchive opens and validates a .switchboard zip archive.
|
||||
func OpenArchive(path string) (*ArchiveReader, error) {
|
||||
zr, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: open archive: %w", err)
|
||||
}
|
||||
return &ArchiveReader{zr: zr}, nil
|
||||
}
|
||||
|
||||
// ReadManifest reads and validates manifest.json from the archive.
|
||||
func (ar *ArchiveReader) ReadManifest() (*Manifest, error) {
|
||||
for _, f := range ar.zr.File {
|
||||
if f.Name == "manifest.json" {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: open manifest: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
var m Manifest
|
||||
if err := json.NewDecoder(rc).Decode(&m); err != nil {
|
||||
return nil, fmt.Errorf("export: decode manifest: %w", err)
|
||||
}
|
||||
if m.FormatVersion > FormatVersion {
|
||||
return nil, fmt.Errorf("export: unsupported format version %d (max %d)", m.FormatVersion, FormatVersion)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("export: manifest.json not found in archive")
|
||||
}
|
||||
|
||||
// ReadEntityJSON reads data/{name}.json into target.
|
||||
func (ar *ArchiveReader) ReadEntityJSON(name string, target interface{}) error {
|
||||
path := "data/" + name + ".json"
|
||||
for _, f := range ar.zr.File {
|
||||
if f.Name == path {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: open %s: %w", path, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
if err := json.NewDecoder(rc).Decode(target); err != nil {
|
||||
return fmt.Errorf("export: decode %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// Entity file not present — not an error (sparse archives are valid)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FileEntries returns zip entries under files/.
|
||||
func (ar *ArchiveReader) FileEntries() []*zip.File {
|
||||
var out []*zip.File
|
||||
for _, f := range ar.zr.File {
|
||||
if len(f.Name) > 6 && f.Name[:6] == "files/" && !f.FileInfo().IsDir() {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Close closes the underlying zip reader.
|
||||
func (ar *ArchiveReader) Close() error {
|
||||
return ar.zr.Close()
|
||||
}
|
||||
|
||||
// writeEntry writes raw bytes as a zip entry, tracking total size.
|
||||
func (aw *ArchiveWriter) writeEntry(name string, data []byte) error {
|
||||
if aw.written+int64(len(data)) > aw.maxBytes {
|
||||
return fmt.Errorf("export: archive size limit exceeded writing %s", name)
|
||||
}
|
||||
f, err := aw.zw.Create(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: create entry %s: %w", name, err)
|
||||
}
|
||||
n, err := f.Write(data)
|
||||
aw.written += int64(n)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user