init commit
This commit is contained in:
427
server/router/api/v1/memo_export_import.go
Normal file
427
server/router/api/v1/memo_export_import.go
Normal file
@@ -0,0 +1,427 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/server/runner/memopayload"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
// ExportFormat represents the format for export/import operations
|
||||
type ExportFormat string
|
||||
|
||||
const (
|
||||
FormatJSON ExportFormat = "json"
|
||||
)
|
||||
|
||||
// ExportData represents the structure of exported data
|
||||
type ExportData struct {
|
||||
Version string `json:"version"`
|
||||
ExportedAt time.Time `json:"exported_at"`
|
||||
Memos []ExportMemo `json:"memos"`
|
||||
}
|
||||
|
||||
// ExportMemo represents a memo in the export format
|
||||
type ExportMemo struct {
|
||||
UID string `json:"uid"`
|
||||
Content string `json:"content"`
|
||||
Visibility string `json:"visibility"`
|
||||
Pinned bool `json:"pinned"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DisplayTime *time.Time `json:"display_time,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Location *ExportLocation `json:"location,omitempty"`
|
||||
Attachments []ExportAttachment `json:"attachments,omitempty"`
|
||||
Relations []ExportMemoRelation `json:"relations,omitempty"`
|
||||
}
|
||||
|
||||
// ExportLocation represents location data in export format
|
||||
type ExportLocation struct {
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Latitude float64 `json:"latitude,omitempty"`
|
||||
Longitude float64 `json:"longitude,omitempty"`
|
||||
}
|
||||
|
||||
// ExportAttachment represents attachment data in export format
|
||||
type ExportAttachment struct {
|
||||
UID string `json:"uid"`
|
||||
Filename string `json:"filename"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// ExportMemoRelation represents memo relations in export format
|
||||
type ExportMemoRelation struct {
|
||||
RelatedMemoUID string `json:"related_memo_uid"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// ExportMemos exports memos for the current user in JSON format
|
||||
func (s *APIV1Service) ExportMemos(ctx context.Context, request *v1pb.ExportMemosRequest) (*v1pb.ExportMemosResponse, error) {
|
||||
user, err := s.GetCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user")
|
||||
}
|
||||
|
||||
// Validate format (default to JSON)
|
||||
format := request.Format
|
||||
if format == "" {
|
||||
format = string(FormatJSON)
|
||||
}
|
||||
if format != string(FormatJSON) {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "unsupported export format: %s", format)
|
||||
}
|
||||
|
||||
// Get all memos for the user
|
||||
memoFind := &store.FindMemo{
|
||||
CreatorID: &user.ID,
|
||||
ExcludeComments: true,
|
||||
}
|
||||
|
||||
// Apply filters if specified
|
||||
if request.Filter != "" {
|
||||
// Use existing filter validation from shortcut service
|
||||
memoFind.Filter = &request.Filter
|
||||
}
|
||||
|
||||
// Include archived memos if requested
|
||||
if request.ExcludeArchived {
|
||||
normalStatus := store.Normal
|
||||
memoFind.RowStatus = &normalStatus
|
||||
}
|
||||
|
||||
memos, err := s.Store.ListMemos(ctx, memoFind)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list memos: %v", err)
|
||||
}
|
||||
|
||||
// Convert memos to export format
|
||||
exportMemos := make([]ExportMemo, 0, len(memos))
|
||||
for _, memo := range memos {
|
||||
exportMemo, err := s.convertMemoToExport(ctx, memo, request.IncludeAttachments, request.IncludeRelations)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to convert memo to export format", slog.Any("memo_id", memo.ID), slog.Any("error", err))
|
||||
continue
|
||||
}
|
||||
exportMemos = append(exportMemos, *exportMemo)
|
||||
}
|
||||
|
||||
// Create export data structure
|
||||
exportData := &ExportData{
|
||||
Version: "1.0",
|
||||
ExportedAt: time.Now(),
|
||||
Memos: exportMemos,
|
||||
}
|
||||
|
||||
// Serialize to JSON
|
||||
jsonData, err := json.MarshalIndent(exportData, "", " ")
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to marshal export data: %v", err)
|
||||
}
|
||||
|
||||
return &v1pb.ExportMemosResponse{
|
||||
Data: jsonData,
|
||||
Format: format,
|
||||
Filename: fmt.Sprintf("memos_export_%s.json", time.Now().Format("20060102_150405")),
|
||||
MemoCount: int32(len(exportMemos)),
|
||||
SizeBytes: int64(len(jsonData)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ImportMemos imports memos from JSON data
|
||||
func (s *APIV1Service) ImportMemos(ctx context.Context, request *v1pb.ImportMemosRequest) (*v1pb.ImportMemosResponse, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
user, err := s.GetCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user")
|
||||
}
|
||||
|
||||
// Validate format (default to JSON)
|
||||
format := request.Format
|
||||
if format == "" {
|
||||
format = string(FormatJSON)
|
||||
}
|
||||
if format != string(FormatJSON) {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "unsupported import format: %s", format)
|
||||
}
|
||||
|
||||
// Parse the JSON data
|
||||
var importData ExportData
|
||||
if err := json.Unmarshal(request.Data, &importData); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "failed to parse import data: %v", err)
|
||||
}
|
||||
|
||||
// Validate import data version
|
||||
if importData.Version != "1.0" {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "unsupported import data version: %s", importData.Version)
|
||||
}
|
||||
|
||||
var importedCount int32
|
||||
var skippedCount int32
|
||||
var createdCount int32
|
||||
var updatedCount int32
|
||||
var validationErrors int32
|
||||
var attachmentsImported int32
|
||||
var relationsImported int32
|
||||
var errors []string
|
||||
var warnings []string
|
||||
|
||||
// Import each memo
|
||||
for _, exportMemo := range importData.Memos {
|
||||
result, err := s.importSingleMemo(ctx, user.ID, &exportMemo, request)
|
||||
if err != nil {
|
||||
errorMsg := fmt.Sprintf("Failed to import memo %s: %v", exportMemo.UID, err)
|
||||
errors = append(errors, errorMsg)
|
||||
skippedCount++
|
||||
if request.ValidateOnly {
|
||||
validationErrors++
|
||||
}
|
||||
slog.Warn("Failed to import memo", slog.String("uid", exportMemo.UID), slog.Any("error", err))
|
||||
continue
|
||||
}
|
||||
|
||||
importedCount++
|
||||
if result.Created {
|
||||
createdCount++
|
||||
} else {
|
||||
updatedCount++
|
||||
}
|
||||
attachmentsImported += result.AttachmentsImported
|
||||
relationsImported += result.RelationsImported
|
||||
|
||||
if len(result.Warnings) > 0 {
|
||||
warnings = append(warnings, result.Warnings...)
|
||||
}
|
||||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
|
||||
summary := &v1pb.ImportSummary{
|
||||
TotalMemos: int32(len(importData.Memos)),
|
||||
CreatedCount: createdCount,
|
||||
UpdatedCount: updatedCount,
|
||||
AttachmentsImported: attachmentsImported,
|
||||
RelationsImported: relationsImported,
|
||||
DurationMs: duration.Milliseconds(),
|
||||
}
|
||||
|
||||
return &v1pb.ImportMemosResponse{
|
||||
ImportedCount: importedCount,
|
||||
SkippedCount: skippedCount,
|
||||
ValidationErrors: validationErrors,
|
||||
Errors: errors,
|
||||
Warnings: warnings,
|
||||
Summary: summary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// convertMemoToExport converts a store memo to export format
|
||||
func (s *APIV1Service) convertMemoToExport(ctx context.Context, memo *store.Memo, includeAttachments, includeRelations bool) (*ExportMemo, error) {
|
||||
exportMemo := &ExportMemo{
|
||||
UID: memo.UID,
|
||||
Content: memo.Content,
|
||||
Visibility: memo.Visibility.String(),
|
||||
Pinned: memo.Pinned,
|
||||
CreatedAt: time.Unix(memo.CreatedTs, 0),
|
||||
UpdatedAt: time.Unix(memo.UpdatedTs, 0),
|
||||
}
|
||||
|
||||
// Extract tags from payload
|
||||
if memo.Payload != nil && len(memo.Payload.Tags) > 0 {
|
||||
exportMemo.Tags = memo.Payload.Tags
|
||||
}
|
||||
|
||||
// Add location if present
|
||||
if memo.Payload != nil && memo.Payload.Location != nil {
|
||||
exportMemo.Location = &ExportLocation{
|
||||
Placeholder: memo.Payload.Location.Placeholder,
|
||||
Latitude: memo.Payload.Location.Latitude,
|
||||
Longitude: memo.Payload.Location.Longitude,
|
||||
}
|
||||
}
|
||||
|
||||
// Add attachments if requested
|
||||
if includeAttachments {
|
||||
attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{MemoID: &memo.ID})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to list attachments")
|
||||
}
|
||||
|
||||
for _, attachment := range attachments {
|
||||
exportMemo.Attachments = append(exportMemo.Attachments, ExportAttachment{
|
||||
UID: attachment.UID,
|
||||
Filename: attachment.Filename,
|
||||
Type: attachment.Type,
|
||||
Size: attachment.Size,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add relations if requested
|
||||
if includeRelations {
|
||||
relations, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{MemoID: &memo.ID})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to list memo relations")
|
||||
}
|
||||
|
||||
for _, relation := range relations {
|
||||
relatedMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{ID: &relation.RelatedMemoID})
|
||||
if err != nil || relatedMemo == nil {
|
||||
continue // Skip if related memo not found
|
||||
}
|
||||
|
||||
exportMemo.Relations = append(exportMemo.Relations, ExportMemoRelation{
|
||||
RelatedMemoUID: relatedMemo.UID,
|
||||
Type: string(relation.Type),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return exportMemo, nil
|
||||
}
|
||||
|
||||
// ImportResult represents the result of importing a single memo
|
||||
type ImportResult struct {
|
||||
Created bool
|
||||
AttachmentsImported int32
|
||||
RelationsImported int32
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
// importSingleMemo imports a single memo
|
||||
func (s *APIV1Service) importSingleMemo(ctx context.Context, userID int32, exportMemo *ExportMemo, request *v1pb.ImportMemosRequest) (*ImportResult, error) {
|
||||
result := &ImportResult{
|
||||
Warnings: []string{},
|
||||
}
|
||||
|
||||
// Check if memo with this UID already exists
|
||||
existingMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &exportMemo.UID})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to check for existing memo")
|
||||
}
|
||||
|
||||
if existingMemo != nil && !request.OverwriteExisting {
|
||||
return nil, fmt.Errorf("memo with UID %s already exists", exportMemo.UID)
|
||||
}
|
||||
|
||||
// Validate memo content length
|
||||
contentLengthLimit, err := s.getContentLengthLimit(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get content length limit")
|
||||
}
|
||||
if len(exportMemo.Content) > contentLengthLimit {
|
||||
return nil, fmt.Errorf("content too long (max %d characters)", contentLengthLimit)
|
||||
}
|
||||
|
||||
// Parse visibility
|
||||
visibility := store.Private
|
||||
switch exportMemo.Visibility {
|
||||
case "PUBLIC":
|
||||
visibility = store.Public
|
||||
case "PROTECTED":
|
||||
visibility = store.Protected
|
||||
case "PRIVATE":
|
||||
visibility = store.Private
|
||||
default:
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("Unknown visibility %s for memo %s, defaulting to PRIVATE", exportMemo.Visibility, exportMemo.UID))
|
||||
}
|
||||
|
||||
// Create memo payload
|
||||
payload := &storepb.MemoPayload{
|
||||
Tags: exportMemo.Tags,
|
||||
}
|
||||
|
||||
if exportMemo.Location != nil {
|
||||
payload.Location = &storepb.MemoPayload_Location{
|
||||
Placeholder: exportMemo.Location.Placeholder,
|
||||
Latitude: exportMemo.Location.Latitude,
|
||||
Longitude: exportMemo.Location.Longitude,
|
||||
}
|
||||
}
|
||||
|
||||
// Set timestamps
|
||||
createdTs := exportMemo.CreatedAt.Unix()
|
||||
updatedTs := exportMemo.UpdatedAt.Unix()
|
||||
if !request.PreserveTimestamps {
|
||||
now := time.Now().Unix()
|
||||
createdTs = now
|
||||
updatedTs = now
|
||||
}
|
||||
|
||||
if request.ValidateOnly {
|
||||
// Just validate, don't actually create/update
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if existingMemo != nil {
|
||||
// Update existing memo
|
||||
update := &store.UpdateMemo{
|
||||
ID: existingMemo.ID,
|
||||
Content: &exportMemo.Content,
|
||||
Visibility: &visibility,
|
||||
Pinned: &exportMemo.Pinned,
|
||||
Payload: payload,
|
||||
}
|
||||
|
||||
if request.PreserveTimestamps {
|
||||
update.CreatedTs = &createdTs
|
||||
update.UpdatedTs = &updatedTs
|
||||
}
|
||||
|
||||
if err := s.Store.UpdateMemo(ctx, update); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to update existing memo")
|
||||
}
|
||||
result.Created = false
|
||||
} else {
|
||||
// Create new memo
|
||||
create := &store.Memo{
|
||||
UID: exportMemo.UID,
|
||||
CreatorID: userID,
|
||||
CreatedTs: createdTs,
|
||||
UpdatedTs: updatedTs,
|
||||
Content: exportMemo.Content,
|
||||
Visibility: visibility,
|
||||
Pinned: exportMemo.Pinned,
|
||||
Payload: payload,
|
||||
}
|
||||
|
||||
// Rebuild memo payload to extract tags and other metadata
|
||||
if err := memopayload.RebuildMemoPayload(create); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to rebuild memo payload")
|
||||
}
|
||||
|
||||
_, err := s.Store.CreateMemo(ctx, create)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to create memo")
|
||||
}
|
||||
result.Created = true
|
||||
}
|
||||
|
||||
// Import attachments if not skipped
|
||||
if !request.SkipAttachments && len(exportMemo.Attachments) > 0 {
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("Attachments for memo %s were skipped (attachment import not yet implemented)", exportMemo.UID))
|
||||
// TODO: Implement attachment import
|
||||
// This would require handling file uploads and storage
|
||||
}
|
||||
|
||||
// Import relations if not skipped
|
||||
if !request.SkipRelations && len(exportMemo.Relations) > 0 {
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("Relations for memo %s were skipped (relation import not yet implemented)", exportMemo.UID))
|
||||
// TODO: Implement relation import
|
||||
// This would require resolving related memo UIDs and creating relations
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user