diff --git a/server/e2e/gql_import_export_test.go b/server/e2e/gql_import_export_test.go index 3631dded3d..fd08c8bf1e 100644 --- a/server/e2e/gql_import_export_test.go +++ b/server/e2e/gql_import_export_test.go @@ -1,14 +1,10 @@ package e2e import ( - "encoding/json" - "fmt" "net/http" - "os" "testing" "github.com/reearth/reearth/server/internal/app/config" - "github.com/stretchr/testify/assert" ) // go test -v -run TestCallExportProject ./e2e/... @@ -30,13 +26,13 @@ func TestCallExportProject(t *testing.T) { requestBody := GraphQLRequest{ OperationName: "ExportProject", - Query: "mutation ExportProject($projectId: ID!) { exportProject(input: {projectId: $projectId}) { projectData __typename } }", + Query: "mutation ExportProject($projectId: ID!) { exportProject(input: {projectId: $projectId}) { projectDataPath __typename } }", Variables: map[string]any{ "projectId": pID, }, } - res := e.POST("/api/graphql"). + e.POST("/api/graphql"). WithHeader("Origin", "https://example.com"). WithHeader("authorization", "Bearer test"). WithHeader("X-Reearth-Debug-User", uID.String()). @@ -48,424 +44,11 @@ func TestCallExportProject(t *testing.T) { Object(). Value("data").Object(). Value("exportProject").Object(). - Value("projectData").Object() + Value("projectDataPath").String().Raw() - res.Value("scene").Object() - res.Value("project").Object() - res.Value("plugins").Array() - -} - -// go test -v -run TestCallImportProject ./e2e/... - -func TestCallImportProject(t *testing.T) { - - e := StartServer(t, &config.Config{ - Origins: []string{"https://example.com"}, - AuthSrv: config.AuthSrvConfig{ - Disabled: true, - }, - }, true, baseSeeder) - - pID := createProject(e, "test") - - _, _, sID := createScene(e, pID) - - _, _, storyID := createStory(e, sID, "test", 0) - - testFilePath := "test_project.json" - err := writeTestJSONFile(testFilePath, pID, wID.String(), sID, storyID) - assert.NoError(t, err) - defer func() { - if err := os.Remove(testFilePath); err != nil { - t.Fatalf("failed to delete test file: %v", err) - } - }() - - res := e.POST("/api/graphql"). - WithHeader("Origin", "https://example.com"). - WithHeader("authorization", "Bearer test"). - WithHeader("X-Reearth-Debug-User", uID.String()). - WithMultipart(). - WithFormField("operations", `{ - "operationName": "ImportProject", - "variables": {"file": null}, - "query": "mutation ImportProject($file: Upload!) { importProject(input: {file: $file}) { projectData __typename }}" - }`). - WithFormField("map", `{"1":["variables.file"]}`). - WithFile("1", testFilePath). - Expect(). - Status(http.StatusOK). - JSON(). - Object(). - Value("data").Object(). - Value("importProject").Object(). - Value("projectData").Object() - - res.Value("scene").Object() - res.Value("project").Object() - res.Value("plugins").Array() - -} - -func writeTestJSONFile(filePath string, pID string, wID string, sID string, storyID string) error { - - var data map[string]any - err := json.Unmarshal([]byte(fmt.Sprintf(`{ - "plugins": [ - { - "id": "%s~reearth-plugin-communication-demo-beta~1.0.0", - "sceneId": "%s", - "name": "Plugin Communication Demo for Beta", - "version": "1.0.0", - "description": "", - "author": "", - "repositoryUrl": "", - "extensions": [ - { - "extensionId": "widgetcomm", - "pluginId": "%s~reearth-plugin-communication-demo-beta~1.0.0", - "type": "WIDGET", - "name": "Communication Demo Widget", - "description": "", - "icon": "", - "singleOnly": false, - "propertySchemaId": "%s~reearth-plugin-communication-demo-beta~1.0.0/widgetcomm", - "allTranslatedName": { - "en": "Communication Demo Widget" - }, - "translatedName": "", - "translatedDescription": "" - }, - { - "extensionId": "storyblockcomm", - "pluginId": "%s~reearth-plugin-communication-demo-beta~1.0.0", - "type": "StoryBlock", - "name": "Communication Demo Story Block", - "description": "", - "icon": "", - "singleOnly": false, - "propertySchemaId": "%s~reearth-plugin-communication-demo-beta~1.0.0/storyblockcomm", - "allTranslatedName": { - "en": "Communication Demo Story Block" - }, - "translatedName": "", - "translatedDescription": "" - }, - { - "extensionId": "infoboxblockcomm", - "pluginId": "%s~reearth-plugin-communication-demo-beta~1.0.0", - "type": "InfoboxBlock", - "name": "Communication Demo Infobox Block", - "description": "", - "icon": "", - "singleOnly": false, - "propertySchemaId": "%s~reearth-plugin-communication-demo-beta~1.0.0/infoboxblockcomm", - "allTranslatedName": { - "en": "Communication Demo Infobox Block" - }, - "translatedName": "", - "translatedDescription": "" - } - ], - "allTranslatedName": { - "en": "Plugin Communication Demo for Beta" - }, - "translatedName": "", - "translatedDescription": "" - } - ], - "project": { - "id": "%s", - "isArchived": false, - "isBasicAuthActive": false, - "basicAuthUsername": "", - "basicAuthPassword": "", - "createdAt": "2024-09-11T19:17:39.418+09:00", - "updatedAt": "2024-09-12T02:07:39.09Z", - "name": "ProjectName1", - "description": "ProjectOverview1", - "alias": "", - "publicTitle": "", - "publicDescription": "", - "publicImage": "", - "publicNoIndex": false, - "imageUrl": { - "Scheme": "http", - "Opaque": "", - "User": null, - "Host": "localhost:8080", - "Path": "/assets/01j7g9d988ct8hajjxfsb6e1n6.jpeg", - "RawPath": "", - "OmitHost": false, - "ForceQuery": false, - "RawQuery": "", - "Fragment": "", - "RawFragment": "" - }, - "teamId": "%s", - "visualizer": "cesium", - "publishmentStatus": "PRIVATE", - "coreSupport": true, - "enableGa": false, - "trackingId": "", - "starred": false - }, - "scene": { - "schemaVersion": 1, - "id": "%s", - "publishedAt": "2024-09-12T11:07:46.117668+09:00", - "property": { - "default": { - "ion": { - "type": "string", - "value": "" - } - }, - "tiles": [ - { - "id": "01j7g9ddv4sbf8tgt5cbjxrksh", - "tile_opacity": { - "type": "number", - "value": 1 - } - }, - { - "id": "01j7hzp3akr64nz033582braxa", - "tile_type": { - "type": "string", - "value": "default_label" - }, - "tile_zoomLevel": { - "type": "array", - "value": [ - null, - null - ] - } - } - ] - }, - "plugins": {}, - "layers": null, - "widgets": [ - { - "id": "01j7g9h4f1k93vspn3gdtz67az", - "pluginId": "reearth", - "extensionId": "button", - "property": { - "default": { - "buttonBgcolor": { - "type": "string", - "value": "#79b4beff" - }, - "buttonColor": { - "type": "string", - "value": "#171289ff" - }, - "buttonTitle": { - "type": "string", - "value": "TestButton1" - } - } - }, - "enabled": false, - "extended": false - }, - { - "id": "01j7g9jckefd0zxyy34bbygmhy", - "pluginId": "reearth", - "extensionId": "navigator", - "property": { - "default": { - "visible": { - "type": "string", - "value": "desktop" - } - } - }, - "enabled": false, - "extended": false - } - ], - "widgetAlignSystem": { - "inner": null, - "outer": { - "left": { - "top": { - "widgetIds": [ - "01j7g9h4f1k93vspn3gdtz67az", - "01j7g9jr89rjq1egrb1hhcd8jy" - ], - "align": "start", - "padding": { - "top": 0, - "bottom": 0, - "left": 0, - "right": 0 - }, - "gap": null, - "centered": false, - "background": null - }, - "middle": null, - "bottom": null - }, - "center": null, - "right": { - "top": { - "widgetIds": [ - "01j7g9jckefd0zxyy34bbygmhy" - ], - "align": "start", - "padding": { - "top": 0, - "bottom": 0, - "left": 0, - "right": 0 - }, - "gap": null, - "centered": false, - "background": null - }, - "middle": null, - "bottom": null - } - } - }, - "tags": [], - "clusters": [], - "story": { - "id": "%s", - "property": {}, - "pages": [ - { - "id": "01j7g9ddwk4a12x1t8wm865s6h", - "property": { - "title": { - "color": { - "type": "string", - "value": "#9a19bfff" - }, - "title": { - "type": "string", - "value": "Title1" - } - } - }, - "title": "Untitled", - "blocks": [ - { - "id": "01j7g9mdnjk1jafw592btqx6t7", - "property": { - "default": { - "text": { - "type": "string", - "value": "{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":1,\"mode\":\"normal\",\"style\":\"\",\"text\":\"Block1\",\"type\":\"text\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"paragraph\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}" - } - } - }, - "plugins": null, - "extensionId": "textStoryBlock", - "pluginId": "reearth" - }, - { - "id": "01j7g9n3x4yqae71crdjcpeyc0", - "property": { - "default": { - "text": { - "type": "string", - "value": "## MarkDown1" - } - } - }, - "plugins": null, - "extensionId": "mdTextStoryBlock", - "pluginId": "reearth" - }, - { - "id": "01j7g9nnnap0cwa1farwd841xc", - "property": { - "default": { - "src": { - "type": "url", - "value": "http://localhost:8080/assets/01j7g9nwtq1zqc7ex5gfvd1mbe.jpeg" - } - } - }, - "plugins": null, - "extensionId": "imageStoryBlock", - "pluginId": "reearth" - } - ], - "swipeable": false, - "swipeableLayers": null, - "layers": [] - } - ], - "position": "right", - "bgColor": "#b2efd8ff" - }, - "nlsLayers": [ - { - "id": "01j7g9gwj6qbv286pcwwmwq5ds", - "title": "japan_architecture (2).csv", - "layerType": "simple", - "config": { - "data": { - "csv": { - "latColumn": "lat", - "lngColumn": "lng" - }, - "type": "csv", - "url": "http://localhost:8080/assets/01j7g9gpba44e0nxwc727nax0q.csv" - }, - "layerStyleId": "" - }, - "isVisible": true, - "nlsInfobox": { - "id": "01j7hzs4e48p8s1cw7thep56b4", - "property": {}, - "blocks": [] - }, - "isSketch": false - } - ], - "layerStyles": [ - { - "id": "01j7hzqgycv76hxsygmcrb47m6", - "name": "スタイル_0", - "value": { - "color": "red" - } - }, - { - "id": "01j7hzrgc3ag8m1ftzye05csgx", - "name": "スタイル_1", - "value": { - "font": "bold" - } - } - ], - "coreSupport": true, - "enableGa": false, - "trackingId": "" - } -}`, sID, sID, sID, sID, sID, sID, sID, sID, pID, wID, sID, storyID)), &data) - if err != nil { - return err - } - file, err := os.Create(filePath) - if err != nil { - return err - } - defer func() { - if cerr := file.Close(); cerr != nil { - err = cerr - } - }() - - encoder := json.NewEncoder(file) - encoder.SetIndent("", " ") - return encoder.Encode(data) + // downloadResponse := e.GET(fmt.Sprintf("http://localhost:8080%s", projectDataPath)). + // Expect(). + // Status(http.StatusOK). + // Body() + // fmt.Println(downloadResponse) } diff --git a/server/gql/project.graphql b/server/gql/project.graphql index 448131b39b..9ce328c2fd 100644 --- a/server/gql/project.graphql +++ b/server/gql/project.graphql @@ -116,7 +116,7 @@ type DeleteProjectPayload { } type ExportProjectPayload { - projectData: JSON! + projectDataPath: String! } type ImportProjectPayload { diff --git a/server/internal/adapter/gql/generated.go b/server/internal/adapter/gql/generated.go index f1e90fb50c..6b88fddd69 100644 --- a/server/internal/adapter/gql/generated.go +++ b/server/internal/adapter/gql/generated.go @@ -339,7 +339,7 @@ type ComplexityRoot struct { } ExportProjectPayload struct { - ProjectData func(childComplexity int) int + ProjectDataPath func(childComplexity int) int } Feature struct { @@ -2588,12 +2588,12 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.DuplicateStylePayload.Style(childComplexity), true - case "ExportProjectPayload.projectData": - if e.complexity.ExportProjectPayload.ProjectData == nil { + case "ExportProjectPayload.projectDataPath": + if e.complexity.ExportProjectPayload.ProjectDataPath == nil { break } - return e.complexity.ExportProjectPayload.ProjectData(childComplexity), true + return e.complexity.ExportProjectPayload.ProjectDataPath(childComplexity), true case "Feature.geometry": if e.complexity.Feature.Geometry == nil { @@ -9611,7 +9611,7 @@ type DeleteProjectPayload { } type ExportProjectPayload { - projectData: JSON! + projectDataPath: String! } type ImportProjectPayload { @@ -19090,8 +19090,8 @@ func (ec *executionContext) fieldContext_DuplicateStylePayload_style(ctx context return fc, nil } -func (ec *executionContext) _ExportProjectPayload_projectData(ctx context.Context, field graphql.CollectedField, obj *gqlmodel.ExportProjectPayload) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_ExportProjectPayload_projectData(ctx, field) +func (ec *executionContext) _ExportProjectPayload_projectDataPath(ctx context.Context, field graphql.CollectedField, obj *gqlmodel.ExportProjectPayload) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_ExportProjectPayload_projectDataPath(ctx, field) if err != nil { return graphql.Null } @@ -19104,7 +19104,7 @@ func (ec *executionContext) _ExportProjectPayload_projectData(ctx context.Contex }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return obj.ProjectData, nil + return obj.ProjectDataPath, nil }) if err != nil { ec.Error(ctx, err) @@ -19116,19 +19116,19 @@ func (ec *executionContext) _ExportProjectPayload_projectData(ctx context.Contex } return graphql.Null } - res := resTmp.(gqlmodel.JSON) + res := resTmp.(string) fc.Result = res - return ec.marshalNJSON2githubᚗcomᚋreearthᚋreearthᚋserverᚋinternalᚋadapterᚋgqlᚋgqlmodelᚐJSON(ctx, field.Selections, res) + return ec.marshalNString2string(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_ExportProjectPayload_projectData(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_ExportProjectPayload_projectDataPath(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "ExportProjectPayload", Field: field, IsMethod: false, IsResolver: false, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type JSON does not have child fields") + return nil, errors.New("field of type String does not have child fields") }, } return fc, nil @@ -31819,8 +31819,8 @@ func (ec *executionContext) fieldContext_Mutation_exportProject(ctx context.Cont IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "projectData": - return ec.fieldContext_ExportProjectPayload_projectData(ctx, field) + case "projectDataPath": + return ec.fieldContext_ExportProjectPayload_projectDataPath(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type ExportProjectPayload", field.Name) }, @@ -65746,8 +65746,8 @@ func (ec *executionContext) _ExportProjectPayload(ctx context.Context, sel ast.S switch field.Name { case "__typename": out.Values[i] = graphql.MarshalString("ExportProjectPayload") - case "projectData": - out.Values[i] = ec._ExportProjectPayload_projectData(ctx, field, obj) + case "projectDataPath": + out.Values[i] = ec._ExportProjectPayload_projectDataPath(ctx, field, obj) if out.Values[i] == graphql.Null { out.Invalids++ } diff --git a/server/internal/adapter/gql/gqlmodel/models_gen.go b/server/internal/adapter/gql/gqlmodel/models_gen.go index 84b30131b9..44d7f54624 100644 --- a/server/internal/adapter/gql/gqlmodel/models_gen.go +++ b/server/internal/adapter/gql/gqlmodel/models_gen.go @@ -563,7 +563,7 @@ type ExportProjectInput struct { } type ExportProjectPayload struct { - ProjectData JSON `json:"projectData"` + ProjectDataPath string `json:"projectDataPath"` } type Feature struct { diff --git a/server/internal/adapter/gql/resolver_mutation_project.go b/server/internal/adapter/gql/resolver_mutation_project.go index bb82526629..1c472954be 100644 --- a/server/internal/adapter/gql/resolver_mutation_project.go +++ b/server/internal/adapter/gql/resolver_mutation_project.go @@ -1,15 +1,23 @@ package gql import ( + "archive/zip" + "bytes" "context" "encoding/json" + "fmt" "io" + "os" + "strings" + "time" + "github.com/oklog/ulid" "github.com/reearth/reearth/server/internal/adapter/gql/gqlmodel" "github.com/reearth/reearth/server/internal/usecase/interfaces" "github.com/reearth/reearth/server/pkg/id" "github.com/reearth/reearth/server/pkg/visualizer" "github.com/reearth/reearthx/account/accountdomain" + "golang.org/x/exp/rand" ) func (r *mutationResolver) CreateProject(ctx context.Context, input gqlmodel.CreateProjectInput) (*gqlmodel.ProjectPayload, error) { @@ -112,22 +120,61 @@ func (r *mutationResolver) DeleteProject(ctx context.Context, input gqlmodel.Del func (r *mutationResolver) ExportProject(ctx context.Context, input gqlmodel.ExportProjectInput) (*gqlmodel.ExportProjectPayload, error) { + // create zip file instance + t := time.Now().UTC() + entropy := ulid.Monotonic(rand.New(rand.NewSource(uint64(t.UnixNano()))), 0) + name := ulid.MustNew(ulid.Timestamp(t), entropy) + zipFile, err := os.Create(fmt.Sprintf("%s.reearth", name.String())) + if err != nil { + return nil, err + } + defer func() { + if cerr := zipFile.Close(); cerr != nil && err == nil { + err = cerr + } + if cerr := os.Remove(zipFile.Name()); cerr != nil && err == nil { + err = cerr + } + }() + zipWriter := zip.NewWriter(zipFile) + defer func() { + if cerr := zipWriter.Close(); cerr != nil && err == nil { + err = cerr + } + }() + + // export project pid, err := gqlmodel.ToID[id.Project](input.ProjectID) if err != nil { return nil, err } + prj, err := usecases(ctx).Project.ExportProject(ctx, pid, zipWriter, getOperator(ctx)) + if err != nil { + return nil, err + } + + // export scene + sce, data, err := usecases(ctx).Scene.ExportScene(ctx, prj, zipWriter) + if err != nil { + return nil, err + } + data["project"] = gqlmodel.ToProject(prj) + + // export plugins + plgs, err := usecases(ctx).Plugin.ExportPlugins(ctx, sce, zipWriter) + if err != nil { + return nil, err + } + data["plugins"] = gqlmodel.ToPlugins(plgs) - prj, res, plgs, err := usecases(ctx).Project.ExportProject(ctx, pid, getOperator(ctx)) + err = usecases(ctx).Project.UploadExportProjectZip(ctx, zipWriter, zipFile, data, prj) if err != nil { return nil, err } - res["project"] = gqlmodel.ToProject(prj) - res["plugins"] = gqlmodel.ToPlugins(plgs) return &gqlmodel.ExportProjectPayload{ - ProjectData: res, + ProjectDataPath: "/export/" + zipFile.Name(), }, nil - } func (r *mutationResolver) ImportProject(ctx context.Context, input gqlmodel.ImportProjectInput) (*gqlmodel.ImportProjectPayload, error) { @@ -137,8 +184,70 @@ func (r *mutationResolver) ImportProject(ctx context.Context, input gqlmodel.Imp return nil, err } + reader, err := zip.NewReader(bytes.NewReader(fileBytes), int64(len(fileBytes))) + if err != nil { + return nil, err + } + + var fileContent []byte + changedFileName := make(map[string]string) + + for _, file := range reader.File { + + if strings.HasPrefix(file.Name, "assets/") { + // Assets file import + + trimmedName := strings.TrimPrefix(file.Name, "assets/") + parts1 := strings.Split(trimmedName, "/") + beforeName := parts1[0] + url, _, err := usecases(ctx).Asset.UploadAssetFile(ctx, beforeName, file) + if err != nil { + return nil, err + } + parts2 := strings.Split(url.Path, "/") + afterName := parts2[len(parts2)-1] + + changedFileName[beforeName] = afterName + + } else if strings.HasPrefix(file.Name, "plugins/") { + // Plugin file import + + trimmedName := strings.TrimPrefix(file.Name, "plugins/") + parts := strings.Split(trimmedName, "/") + pid, err := id.PluginIDFrom(parts[0]) + if err != nil { + return nil, err + } + if err := usecases(ctx).Plugin.ImporPluginFile(ctx, pid, parts[1], file); err != nil { + return nil, err + } + } else if file.Name == "project.json" { + // Data import + + rc, err := file.Open() + if err != nil { + return nil, err + } + defer func(rc io.ReadCloser) { + if cerr := rc.Close(); cerr != nil { + fmt.Printf("Error closing file: %v\n", cerr) + } + }(rc) + fileContent, err = io.ReadAll(rc) + if err != nil { + return nil, err + } + + } + + } + + for beforeName, afterName := range changedFileName { + fileContent = bytes.Replace(fileContent, []byte(beforeName), []byte(afterName), -1) + } + var jsonData map[string]interface{} - if err := json.Unmarshal(fileBytes, &jsonData); err != nil { + if err := json.Unmarshal(fileContent, &jsonData); err != nil { return nil, err } @@ -154,43 +263,43 @@ func (r *mutationResolver) ImportProject(ctx context.Context, input gqlmodel.Imp }() pluginsData, _ := jsonData["plugins"].([]interface{}) - _, err = usecases(ctx).Plugin.ImportPlugins(ctx, pluginsData) + plgs, err := usecases(ctx).Plugin.ImportPlugins(ctx, pluginsData) if err != nil { return nil, err } sceneData, _ := jsonData["scene"].(map[string]interface{}) - _, err = usecases(ctx).Scene.ImportScene(ctx, prj, sceneData) + sce, err := usecases(ctx).Scene.ImportScene(ctx, prj, sceneData) if err != nil { return nil, err } - _, err = usecases(ctx).NLSLayer.ImportNLSLayers(ctx, sceneData) + nlayers, err := usecases(ctx).NLSLayer.ImportNLSLayers(ctx, sceneData) if err != nil { return nil, err } - _, err = usecases(ctx).Style.ImportStyles(ctx, sceneData) + styleList, err := usecases(ctx).Style.ImportStyles(ctx, sceneData) if err != nil { return nil, err } - _, err = usecases(ctx).StoryTelling.ImportStory(ctx, sceneData) + st, err := usecases(ctx).StoryTelling.ImportStory(ctx, sceneData) if err != nil { return nil, err } tx.Commit() - prj, res, plgs, err := usecases(ctx).Project.ExportProject(ctx, prj.ID(), getOperator(ctx)) - if err != nil { - return nil, err - } - res["project"] = gqlmodel.ToProject(prj) - res["plugins"] = gqlmodel.ToPlugins(plgs) - return &gqlmodel.ImportProjectPayload{ - ProjectData: res, + ProjectData: map[string]any{ + "project": gqlmodel.ToProject(prj), + "plugins": gqlmodel.ToPlugins(plgs), + "scene": gqlmodel.ToScene(sce), + "nlsLayer": gqlmodel.ToNLSLayers(nlayers, nil), + "style": gqlmodel.ToStyles(styleList), + "story": gqlmodel.ToStory(st), + }, }, nil } diff --git a/server/internal/app/file.go b/server/internal/app/file.go index 1d29993a68..943f531f1a 100644 --- a/server/internal/app/file.go +++ b/server/internal/app/file.go @@ -46,6 +46,20 @@ func serveFiles( }), ) + ec.GET( + "/export/:filename", + fileHandler(func(ctx echo.Context) (io.Reader, string, error) { + filename := ctx.Param("filename") + r, err := repo.ReadExportProjectZip(ctx.Request().Context(), filename) + if err != nil { + return nil, "", rerror.ErrNotFound + } + // download and then delete + err = repo.RemoveExportProjectZip(ctx.Request().Context(), filename) + return r, filename, err + }), + ) + ec.GET( "/plugins/:plugin/:filename", fileHandler(func(ctx echo.Context) (io.Reader, string, error) { diff --git a/server/internal/infrastructure/fs/common.go b/server/internal/infrastructure/fs/common.go index d49c68223a..d066b1919f 100644 --- a/server/internal/infrastructure/fs/common.go +++ b/server/internal/infrastructure/fs/common.go @@ -5,5 +5,6 @@ const ( pluginDir = "plugins" publishedDir = "published" storyDir = "stories" + exportDir = "export" manifestFilePath = "reearth.yml" ) diff --git a/server/internal/infrastructure/fs/file.go b/server/internal/infrastructure/fs/file.go index 95f0ee4014..3c9614cb72 100644 --- a/server/internal/infrastructure/fs/file.go +++ b/server/internal/infrastructure/fs/file.go @@ -123,6 +123,21 @@ func (f *fileRepo) RemoveStory(ctx context.Context, name string) error { return f.delete(ctx, filepath.Join(storyDir, sanitize.Path(name+".json"))) } +// export + +func (f *fileRepo) ReadExportProjectZip(ctx context.Context, filename string) (io.ReadCloser, error) { + return f.read(ctx, filepath.Join(exportDir, sanitize.Path(filename))) +} + +func (f *fileRepo) UploadExportProjectZip(ctx context.Context, zipFile *os.File) error { + _, err := f.upload(ctx, path.Join(exportDir, zipFile.Name()), zipFile) + return err +} + +func (f *fileRepo) RemoveExportProjectZip(ctx context.Context, filename string) error { + return f.delete(ctx, filepath.Join(exportDir, filename)) +} + // helpers func (f *fileRepo) read(ctx context.Context, filename string) (io.ReadCloser, error) { diff --git a/server/internal/infrastructure/gcs/file.go b/server/internal/infrastructure/gcs/file.go index 288b8228bb..966cfafbd2 100644 --- a/server/internal/infrastructure/gcs/file.go +++ b/server/internal/infrastructure/gcs/file.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/url" + "os" "path" "strings" @@ -24,6 +25,7 @@ const ( gcsPluginBasePath string = "plugins" gcsMapBasePath string = "maps" gcsStoryBasePath string = "stories" + gcsExportBasePath string = "export" fileSizeLimit int64 = 1024 * 1024 * 100 // about 100MB ) @@ -56,6 +58,8 @@ func NewFile(bucketName, base string, cacheControl string) (gateway.File, error) }, nil } +// asset + func (f *fileRepo) ReadAsset(ctx context.Context, name string) (io.ReadCloser, error) { sn := sanitize.Path(name) if sn == "" { @@ -199,6 +203,25 @@ func (f *fileRepo) RemoveStory(ctx context.Context, name string) error { return f.delete(ctx, path.Join(gcsStoryBasePath, sn)) } +// export + +func (f *fileRepo) ReadExportProjectZip(ctx context.Context, name string) (io.ReadCloser, error) { + sn := sanitize.Path(name) + if sn == "" { + return nil, rerror.ErrNotFound + } + return f.read(ctx, path.Join(gcsExportBasePath, sn)) +} + +func (f *fileRepo) UploadExportProjectZip(ctx context.Context, zipFile *os.File) error { + _, err := f.upload(ctx, path.Join(gcsExportBasePath, zipFile.Name()), zipFile) + return err +} + +func (f *fileRepo) RemoveExportProjectZip(ctx context.Context, filename string) error { + return f.delete(ctx, path.Join(gcsExportBasePath, filename)) +} + // helpers func (f *fileRepo) bucket(ctx context.Context) (*storage.BucketHandle, error) { diff --git a/server/internal/infrastructure/s3/s3.go b/server/internal/infrastructure/s3/s3.go index d84422dca5..deb8e21945 100644 --- a/server/internal/infrastructure/s3/s3.go +++ b/server/internal/infrastructure/s3/s3.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/url" + "os" "path" "strings" @@ -28,6 +29,7 @@ const ( pluginBasePath string = "plugins" mapBasePath string = "maps" storyBasePath string = "stories" + exportBasePath string = "export" fileSizeLimit int64 = 1024 * 1024 * 100 // about 100MB ) @@ -65,6 +67,8 @@ func NewS3(ctx context.Context, bucketName, baseURL, cacheControl string) (gatew }, nil } +// asset + func (f *fileRepo) ReadAsset(ctx context.Context, name string) (io.ReadCloser, error) { sn := sanitize.Path(name) if sn == "" { @@ -208,6 +212,25 @@ func (f *fileRepo) RemoveStory(ctx context.Context, name string) error { return f.delete(ctx, path.Join(storyBasePath, sn)) } +// export + +func (f *fileRepo) ReadExportProjectZip(ctx context.Context, name string) (io.ReadCloser, error) { + sn := sanitize.Path(name) + if sn == "" { + return nil, rerror.ErrNotFound + } + return f.read(ctx, path.Join(exportBasePath, sn)) +} + +func (f *fileRepo) UploadExportProjectZip(ctx context.Context, zipFile *os.File) error { + _, err := f.upload(ctx, path.Join(exportBasePath, zipFile.Name()), zipFile) + return err +} + +func (f *fileRepo) RemoveExportProjectZip(ctx context.Context, filename string) error { + return f.delete(ctx, path.Join(exportBasePath, filename)) +} + // helpers func (f *fileRepo) read(ctx context.Context, filename string) (io.ReadCloser, error) { diff --git a/server/internal/usecase/gateway/file.go b/server/internal/usecase/gateway/file.go index a92705d287..cf7e6e9393 100644 --- a/server/internal/usecase/gateway/file.go +++ b/server/internal/usecase/gateway/file.go @@ -5,6 +5,7 @@ import ( "errors" "io" "net/url" + "os" "github.com/reearth/reearth/server/pkg/file" "github.com/reearth/reearth/server/pkg/id" @@ -35,4 +36,8 @@ type File interface { ReadStoryFile(context.Context, string) (io.ReadCloser, error) MoveStory(context.Context, string, string) error RemoveStory(context.Context, string) error + + ReadExportProjectZip(context.Context, string) (io.ReadCloser, error) + UploadExportProjectZip(context.Context, *os.File) error + RemoveExportProjectZip(context.Context, string) error } diff --git a/server/internal/usecase/interactor/asset.go b/server/internal/usecase/interactor/asset.go index d99a890c97..d5da92073a 100644 --- a/server/internal/usecase/interactor/asset.go +++ b/server/internal/usecase/interactor/asset.go @@ -1,7 +1,10 @@ package interactor import ( + "archive/zip" "context" + "fmt" + "net/http" "net/url" "path" @@ -10,6 +13,7 @@ import ( "github.com/reearth/reearth/server/internal/usecase/interfaces" "github.com/reearth/reearth/server/internal/usecase/repo" "github.com/reearth/reearth/server/pkg/asset" + "github.com/reearth/reearth/server/pkg/file" "github.com/reearth/reearth/server/pkg/id" "github.com/reearth/reearthx/account/accountdomain" "github.com/reearth/reearthx/usecasex" @@ -122,3 +126,27 @@ func (i *Asset) Remove(ctx context.Context, aid id.AssetID, operator *usecase.Op }, ) } + +func (i *Asset) UploadAssetFile(ctx context.Context, name string, zipFile *zip.File) (*url.URL, int64, error) { + + readCloser, err := zipFile.Open() + if err != nil { + return nil, 0, fmt.Errorf("error opening zip file entry: %w", err) + } + defer func() { + if cerr := readCloser.Close(); cerr != nil { + fmt.Printf("Error closing file: %v\n", cerr) + } + }() + + contentType := http.DetectContentType([]byte(zipFile.Name)) + + file := &file.File{ + Content: readCloser, + Path: name, + Size: int64(zipFile.UncompressedSize64), + ContentType: contentType, + } + + return i.gateways.File.UploadAsset(ctx, file) +} diff --git a/server/internal/usecase/interactor/nlslayer.go b/server/internal/usecase/interactor/nlslayer.go index d0ecdbdbd6..657008c50a 100644 --- a/server/internal/usecase/interactor/nlslayer.go +++ b/server/internal/usecase/interactor/nlslayer.go @@ -15,6 +15,7 @@ import ( "github.com/reearth/reearth/server/pkg/property" "github.com/reearth/reearth/server/pkg/scene/builder" "github.com/reearth/reearthx/account/accountusecase/accountrepo" + "github.com/reearth/reearthx/idx" "github.com/reearth/reearthx/rerror" "github.com/reearth/reearthx/usecasex" ) @@ -842,12 +843,14 @@ func (i *NLSLayer) ImportNLSLayers(ctx context.Context, sceneData map[string]int return nil, err } + nlayerIDs := idx.List[id.NLSLayer]{} nlayers := []nlslayer.NLSLayer{} for _, nlsLayerJSON := range sceneJSON.NLSLayers { nlsLayerID, err := id.NLSLayerIDFrom(nlsLayerJSON.ID) if err != nil { return nil, err } + nlayerIDs = append(nlayerIDs, nlsLayerID) nlayer, err := nlslayer.New(). ID(nlsLayerID). Simple(). @@ -873,5 +876,9 @@ func (i *NLSLayer) ImportNLSLayers(ctx context.Context, sceneData map[string]int return nil, err } - return nlsLayerList, nil + nlayer, err := i.nlslayerRepo.FindByIDs(ctx, nlayerIDs) + if err != nil { + return nil, err + } + return nlayer, nil } diff --git a/server/internal/usecase/interactor/plugin.go b/server/internal/usecase/interactor/plugin.go index d5cb74255a..884fbb340d 100644 --- a/server/internal/usecase/interactor/plugin.go +++ b/server/internal/usecase/interactor/plugin.go @@ -1,7 +1,11 @@ package interactor import ( + "archive/zip" "context" + "fmt" + "io" + "net/http" "github.com/reearth/reearth/server/internal/adapter/gql/gqlmodel" jsonmodel "github.com/reearth/reearth/server/internal/adapter/gql/gqlmodel" @@ -9,9 +13,11 @@ import ( "github.com/reearth/reearth/server/internal/usecase/gateway" "github.com/reearth/reearth/server/internal/usecase/interfaces" "github.com/reearth/reearth/server/internal/usecase/repo" + "github.com/reearth/reearth/server/pkg/file" "github.com/reearth/reearth/server/pkg/i18n" "github.com/reearth/reearth/server/pkg/id" "github.com/reearth/reearth/server/pkg/plugin" + "github.com/reearth/reearth/server/pkg/scene" "github.com/reearth/reearthx/usecasex" ) @@ -53,15 +59,64 @@ func (i *Plugin) Fetch(ctx context.Context, ids []id.PluginID, operator *usecase return i.pluginRepo.FindByIDs(ctx, ids) } +func (i *Plugin) ExportPlugins(ctx context.Context, sce *scene.Scene, zipWriter *zip.Writer) ([]*plugin.Plugin, error) { + + pluginIDs := sce.PluginIds() + var filteredPluginIDs []id.PluginID + for _, pid := range pluginIDs { + // exclude official plugin + if pid.String() != "reearth" { + filteredPluginIDs = append(filteredPluginIDs, pid) + } + } + + plgs, err := i.pluginRepo.FindByIDs(ctx, filteredPluginIDs) + if err != nil { + return nil, err + } + + for _, plg := range plgs { + for _, extension := range plg.Extensions() { + extensionFileName := fmt.Sprintf("%s.js", extension.ID().String()) + zipEntryPath := fmt.Sprintf("plugins/%s/%s", plg.ID().String(), extensionFileName) + zipEntry, err := zipWriter.Create(zipEntryPath) + if err != nil { + return nil, err + } + stream, err := i.file.ReadPluginFile(ctx, plg.ID(), extensionFileName) + if err != nil { + if stream != nil { + _ = stream.Close() + } + return nil, err + } + + _, err = io.Copy(zipEntry, stream) + if err != nil { + _ = stream.Close() + return nil, err + } + + if err := stream.Close(); err != nil { + return nil, err + } + } + } + + return plgs, nil +} + func (i *Plugin) ImportPlugins(ctx context.Context, pluginsData []interface{}) ([]*plugin.Plugin, error) { var pluginsJSON = jsonmodel.ToPluginsFromJSON(pluginsData) - importedPlugins := []*plugin.Plugin{} + var pluginIDs []id.PluginID for _, pluginJSON := range pluginsJSON { pid, err := jsonmodel.ToPluginID(pluginJSON.ID) if err != nil { return nil, err } + pluginIDs = append(pluginIDs, pid) + var extensions []*plugin.Extension for _, pluginJSONextension := range pluginJSON.Extensions { psid, err := jsonmodel.ToPropertySchemaID(pluginJSONextension.PropertySchemaID) @@ -97,8 +152,40 @@ func (i *Plugin) ImportPlugins(ctx context.Context, pluginsData []interface{}) ( if err := i.pluginRepo.Save(ctx, p); err != nil { return nil, err } - importedPlugins = append(importedPlugins, p) } } - return importedPlugins, nil + plgs, err := i.pluginRepo.FindByIDs(ctx, pluginIDs) + if err != nil { + return nil, err + } + + return plgs, nil +} + +func (i *Plugin) ImporPluginFile(ctx context.Context, pid id.PluginID, name string, zipFile *zip.File) error { + + readCloser, err := zipFile.Open() + if err != nil { + return fmt.Errorf("error opening zip file entry: %w", err) + } + defer func() { + if cerr := readCloser.Close(); cerr != nil { + fmt.Printf("Error closing file: %v\n", cerr) + } + }() + + contentType := http.DetectContentType([]byte(zipFile.Name)) + + file := &file.File{ + Content: readCloser, + Path: name, + Size: int64(zipFile.UncompressedSize64), + ContentType: contentType, + } + + if err := i.file.UploadPluginFile(ctx, pid, file); err != nil { + return fmt.Errorf("error uploading plugin file: %w", err) + } + + return nil } diff --git a/server/internal/usecase/interactor/project.go b/server/internal/usecase/interactor/project.go index 5f2aae2dd3..44e04132f8 100644 --- a/server/internal/usecase/interactor/project.go +++ b/server/internal/usecase/interactor/project.go @@ -1,9 +1,14 @@ package interactor import ( + "archive/zip" "context" + "encoding/json" "errors" + "fmt" "io" + "os" + "strings" "time" "github.com/reearth/reearth/server/internal/adapter/gql/gqlmodel" @@ -13,7 +18,6 @@ import ( "github.com/reearth/reearth/server/internal/usecase/interfaces" "github.com/reearth/reearth/server/internal/usecase/repo" "github.com/reearth/reearth/server/pkg/id" - "github.com/reearth/reearth/server/pkg/plugin" "github.com/reearth/reearth/server/pkg/project" "github.com/reearth/reearth/server/pkg/scene" "github.com/reearth/reearth/server/pkg/scene/builder" @@ -493,64 +497,68 @@ func (i *Project) Delete(ctx context.Context, projectID id.ProjectID, operator * return nil } -func (i *Project) ExportProject(ctx context.Context, projectID id.ProjectID, operator *usecase.Operator) (*project.Project, map[string]interface{}, []*plugin.Plugin, error) { +func (i *Project) ExportProject(ctx context.Context, projectID id.ProjectID, zipWriter *zip.Writer, operator *usecase.Operator) (*project.Project, error) { prj, err := i.projectRepo.FindByID(ctx, projectID) if err != nil { - return nil, nil, nil, err - } - sce, err := i.sceneRepo.FindByProject(ctx, prj.ID()) - if err != nil { - return nil, nil, nil, err + return nil, err } - pluginIDs := sce.PluginIds() - var filteredPluginIDs []id.PluginID - for _, pid := range pluginIDs { - if pid.String() != "reearth" { - filteredPluginIDs = append(filteredPluginIDs, pid) + // project image + if prj.ImageURL() != nil { + trimmedName := strings.TrimPrefix(prj.ImageURL().Path, "/assets/") + stream, err := i.file.ReadAsset(ctx, trimmedName) + if err != nil { + return nil, err + } + zipEntryPath := fmt.Sprintf("assets/%s", trimmedName) + zipEntry, err := zipWriter.Create(zipEntryPath) + if err != nil { + return nil, err + } + _, err = io.Copy(zipEntry, stream) + if err != nil { + _ = stream.Close() + return nil, err + } + if err := stream.Close(); err != nil { + return nil, err } - } - plgs, err := i.pluginRepo.FindByIDs(ctx, filteredPluginIDs) - if err != nil { - return nil, nil, nil, err } - sceneID := sce.ID() - nlsLayers, err := i.nlsLayerRepo.FindByScene(ctx, sceneID) + return prj, nil +} + +func (i *Project) UploadExportProjectZip(ctx context.Context, zipWriter *zip.Writer, zipFile *os.File, data map[string]interface{}, prj *project.Project) error { + fileWriter, err := zipWriter.Create("project.json") if err != nil { - return nil, nil, nil, err + return err } - layerStyles, err := i.layerStyles.FindByScene(ctx, sceneID) + jsonData, err := json.Marshal(data) if err != nil { - return nil, nil, nil, err + return err } - storyList, err := i.storytellingRepo.FindByScene(ctx, sceneID) - if err != nil { - return nil, nil, nil, err - } - sceneJSON, err := builder.New( - repo.LayerLoaderFrom(i.layerRepo), - repo.PropertyLoaderFrom(i.propertyRepo), - repo.DatasetGraphLoaderFrom(i.datasetRepo), - repo.TagLoaderFrom(i.tagRepo), - repo.TagSceneLoaderFrom(i.tagRepo, []id.SceneID{sceneID}), - repo.NLSLayerLoaderFrom(i.nlsLayerRepo), - true, - ).ForScene(sce).WithNLSLayers(&nlsLayers).WithLayerStyle(layerStyles).WithStory((*storyList)[0]).BuildResult( - ctx, - time.Now(), - prj.CoreSupport(), - prj.EnableGA(), - prj.TrackingID(), - ) + if _, err = fileWriter.Write(jsonData); err != nil { + return err + } + + if err := zipWriter.Close(); err != nil { + return err + } + + // flush once + if err := zipFile.Close(); err != nil { + return err + } + zipFile, err = os.Open(zipFile.Name()) if err != nil { - return nil, nil, nil, err + return err } - res := make(map[string]interface{}) - res["scene"] = sceneJSON - return prj, res, plgs, nil + if err := i.file.UploadExportProjectZip(ctx, zipFile); err != nil { + return err + } + return nil } func (i *Project) ImportProject(ctx context.Context, projectData map[string]interface{}) (*project.Project, usecasex.Tx, error) { @@ -611,6 +619,10 @@ func (i *Project) ImportProject(ctx context.Context, projectData map[string]inte if err := i.projectRepo.Save(ctx, prj); err != nil { return nil, nil, err } + prj, err = i.projectRepo.FindByID(ctx, prj.ID()) + if err != nil { + return nil, nil, err + } return prj, tx, nil } diff --git a/server/internal/usecase/interactor/scene.go b/server/internal/usecase/interactor/scene.go index b453784fbd..93ddf92367 100644 --- a/server/internal/usecase/interactor/scene.go +++ b/server/internal/usecase/interactor/scene.go @@ -1,8 +1,13 @@ package interactor import ( + "archive/zip" "context" "errors" + "fmt" + "io" + "net/url" + "strings" "time" "github.com/reearth/reearth/server/internal/usecase" @@ -18,6 +23,7 @@ import ( "github.com/reearth/reearth/server/pkg/scene" "github.com/reearth/reearth/server/pkg/scene/builder" "github.com/reearth/reearth/server/pkg/visualizer" + "github.com/reearth/reearthx/idx" "github.com/reearth/reearthx/rerror" "github.com/reearth/reearthx/usecasex" "github.com/samber/lo" @@ -36,6 +42,10 @@ type Scene struct { file gateway.File pluginRegistry gateway.PluginRegistry extensions []plugin.ID + nlsLayerRepo repo.NLSLayer + layerStyles repo.Style + storytellingRepo repo.Storytelling + tagRepo repo.Tag } func NewScene(r *repo.Container, g *gateway.Container) interfaces.Scene { @@ -51,6 +61,10 @@ func NewScene(r *repo.Container, g *gateway.Container) interfaces.Scene { file: g.File, pluginRegistry: g.PluginRegistry, extensions: r.Extensions, + nlsLayerRepo: r.NLSLayer, + layerStyles: r.Style, + storytellingRepo: r.Storytelling, + tagRepo: r.Tag, } } @@ -581,6 +595,126 @@ func (i *Scene) RemoveCluster(ctx context.Context, sceneID id.SceneID, clusterID return s, nil } +func (i *Scene) ExportScene(ctx context.Context, prj *project.Project, zipWriter *zip.Writer) (*scene.Scene, map[string]interface{}, error) { + + sce, err := i.sceneRepo.FindByProject(ctx, prj.ID()) + if err != nil { + return nil, nil, err + } + + sceneID := sce.ID() + nlsLayers, err := i.nlsLayerRepo.FindByScene(ctx, sceneID) + if err != nil { + return nil, nil, err + } + layerStyles, err := i.layerStyles.FindByScene(ctx, sceneID) + if err != nil { + return nil, nil, err + } + storyList, err := i.storytellingRepo.FindByScene(ctx, sceneID) + if err != nil { + return nil, nil, err + } + story := (*storyList)[0] + sceneJSON, err := builder.New( + repo.LayerLoaderFrom(i.layerRepo), + repo.PropertyLoaderFrom(i.propertyRepo), + repo.DatasetGraphLoaderFrom(i.datasetRepo), + repo.TagLoaderFrom(i.tagRepo), + repo.TagSceneLoaderFrom(i.tagRepo, []id.SceneID{sceneID}), + repo.NLSLayerLoaderFrom(i.nlsLayerRepo), + true, + ).ForScene(sce).WithNLSLayers(&nlsLayers).WithLayerStyle(layerStyles).WithStory(story).BuildResult( + ctx, + time.Now(), + prj.CoreSupport(), + prj.EnableGA(), + prj.TrackingID(), + ) + if err != nil { + return nil, nil, err + } + + // nlsLayer file resources + for _, nLayer := range nlsLayers { + actualLayer := *nLayer + c := actualLayer.Config() + if c != nil { + actualConfig := *c + data, ok := actualConfig["data"].(map[string]any) + if ok { + url, ok := data["url"].(string) + if ok { + err := i.addZipAsset(ctx, zipWriter, url) + if err != nil { + return nil, nil, err + } + } + } + } + } + + var widgetPropertyIDs []idx.ID[id.Property] + for _, widget := range sce.Widgets().Widgets() { + widgetPropertyIDs = append(widgetPropertyIDs, widget.Property()) + } + widgetProperties, err := i.propertyRepo.FindByIDs(ctx, widgetPropertyIDs) + if err != nil { + return nil, nil, err + } + + // widget button icon + for _, property := range widgetProperties { + for _, item := range property.Items() { + for _, field := range item.Fields(nil) { + if field.GuessSchema().ID().String() == "buttonIcon" { + u, ok := field.Value().Value().(*url.URL) + if !ok { + continue + } + err := i.addZipAsset(ctx, zipWriter, u.Path) + if err != nil { + return nil, nil, err + } + } + } + } + } + + var pagePropertyIDs []idx.ID[id.Property] + for _, page := range story.Pages().Pages() { + for _, block := range page.Blocks() { + pagePropertyIDs = append(pagePropertyIDs, block.Property()) + } + } + pageProperties, err := i.propertyRepo.FindByIDs(ctx, pagePropertyIDs) + if err != nil { + return nil, nil, err + } + // page block src + for _, property := range pageProperties { + for _, item := range property.Items() { + for _, field := range item.Fields(nil) { + if field.GuessSchema().ID().String() == "src" { + u, ok := field.Value().Value().(*url.URL) + if !ok { + continue + } + err := i.addZipAsset(ctx, zipWriter, u.Path) + if err != nil { + return nil, nil, err + } + } + } + } + } + + res := make(map[string]interface{}) + res["scene"] = sceneJSON + + return sce, res, nil +} + func (i *Scene) ImportScene(ctx context.Context, prj *project.Project, sceneData map[string]interface{}) (*scene.Scene, error) { sceneJSON, err := builder.ParseSceneJSON(ctx, sceneData) if err != nil { @@ -689,6 +823,10 @@ func (i *Scene) ImportScene(ctx context.Context, prj *project.Project, sceneData return nil, err } // operator.AddNewScene(prj.Workspace(), sceneID) + scene, err = i.sceneRepo.FindByID(ctx, sceneID) + if err != nil { + return nil, err + } return scene, nil } @@ -715,3 +853,28 @@ func (i *Scene) getWidgePlugin(ctx context.Context, pid id.PluginID, eid id.Plug } return extension, nil } + +func (i *Scene) addZipAsset(ctx context.Context, zipWriter *zip.Writer, url string) error { + + parts := strings.Split(url, "/") + fileName := parts[len(parts)-1] + + stream, err := i.file.ReadAsset(ctx, fileName) + if err != nil { + return err + } + zipEntryPath := fmt.Sprintf("assets/%s", fileName) + zipEntry, err := zipWriter.Create(zipEntryPath) + if err != nil { + return err + } + _, err = io.Copy(zipEntry, stream) + if err != nil { + _ = stream.Close() + return err + } + if err := stream.Close(); err != nil { + return err + } + return nil +} diff --git a/server/internal/usecase/interactor/storytelling.go b/server/internal/usecase/interactor/storytelling.go index 2ac541f9f4..6f8e16b7f3 100644 --- a/server/internal/usecase/interactor/storytelling.go +++ b/server/internal/usecase/interactor/storytelling.go @@ -1124,7 +1124,10 @@ func (i *Storytelling) ImportStory(ctx context.Context, sceneData map[string]int if err := i.storytellingRepo.Save(ctx, *story); err != nil { return nil, err } - + story, err = i.storytellingRepo.FindByID(ctx, story.Id()) + if err != nil { + return nil, err + } return story, nil } diff --git a/server/internal/usecase/interactor/style.go b/server/internal/usecase/interactor/style.go index 51b402db9c..a1fc9c92d7 100644 --- a/server/internal/usecase/interactor/style.go +++ b/server/internal/usecase/interactor/style.go @@ -10,6 +10,7 @@ import ( "github.com/reearth/reearth/server/pkg/scene" "github.com/reearth/reearth/server/pkg/scene/builder" "github.com/reearth/reearth/server/pkg/scene/sceneops" + "github.com/reearth/reearthx/idx" "github.com/reearth/reearthx/usecasex" ) @@ -210,12 +211,14 @@ func (i *Style) ImportStyles(ctx context.Context, sceneData map[string]interface return nil, err } + styleIDs := idx.List[id.Style]{} styles := []*scene.Style{} for _, layerStyleJson := range sceneJSON.LayerStyles { styleID, err := id.StyleIDFrom(layerStyleJson.ID) if err != nil { return nil, err } + styleIDs = append(styleIDs, styleID) style, err := scene.NewStyle(). ID(styleID). Name(layerStyleJson.Name). @@ -228,10 +231,15 @@ func (i *Style) ImportStyles(ctx context.Context, sceneData map[string]interface styles = append(styles, style) } + // save styleList := scene.StyleList(styles) if err := i.styleRepo.SaveAll(ctx, styleList); err != nil { return nil, err } - return styleList, nil + styles2, err := i.styleRepo.FindByIDs(ctx, styleIDs) + if err != nil { + return nil, err + } + return *styles2, nil } diff --git a/server/internal/usecase/interfaces/asset.go b/server/internal/usecase/interfaces/asset.go index fb854e0a65..e0769b165b 100644 --- a/server/internal/usecase/interfaces/asset.go +++ b/server/internal/usecase/interfaces/asset.go @@ -1,8 +1,10 @@ package interfaces import ( + "archive/zip" "context" "errors" + "net/url" "github.com/reearth/reearth/server/internal/usecase" "github.com/reearth/reearth/server/pkg/asset" @@ -34,4 +36,5 @@ type Asset interface { FindByWorkspace(context.Context, accountdomain.WorkspaceID, *string, *asset.SortType, *usecasex.Pagination, *usecase.Operator) ([]*asset.Asset, *usecasex.PageInfo, error) Create(context.Context, CreateAssetParam, *usecase.Operator) (*asset.Asset, error) Remove(context.Context, id.AssetID, *usecase.Operator) (id.AssetID, error) + UploadAssetFile(context.Context, string, *zip.File) (*url.URL, int64, error) } diff --git a/server/internal/usecase/interfaces/plugin.go b/server/internal/usecase/interfaces/plugin.go index 6a7690fbbd..33f67e9742 100644 --- a/server/internal/usecase/interfaces/plugin.go +++ b/server/internal/usecase/interfaces/plugin.go @@ -1,6 +1,7 @@ package interfaces import ( + "archive/zip" "context" "errors" "io" @@ -21,5 +22,7 @@ type Plugin interface { Fetch(context.Context, []id.PluginID, *usecase.Operator) ([]*plugin.Plugin, error) Upload(context.Context, io.Reader, id.SceneID, *usecase.Operator) (*plugin.Plugin, *scene.Scene, error) UploadFromRemote(context.Context, *url.URL, id.SceneID, *usecase.Operator) (*plugin.Plugin, *scene.Scene, error) + ExportPlugins(context.Context, *scene.Scene, *zip.Writer) ([]*plugin.Plugin, error) ImportPlugins(context.Context, []interface{}) ([]*plugin.Plugin, error) + ImporPluginFile(context.Context, id.PluginID, string, *zip.File) error } diff --git a/server/internal/usecase/interfaces/project.go b/server/internal/usecase/interfaces/project.go index 1a529b89fb..42c7d48de2 100644 --- a/server/internal/usecase/interfaces/project.go +++ b/server/internal/usecase/interfaces/project.go @@ -1,13 +1,14 @@ package interfaces import ( + "archive/zip" "context" "errors" "net/url" + "os" "github.com/reearth/reearth/server/internal/usecase" "github.com/reearth/reearth/server/pkg/id" - "github.com/reearth/reearth/server/pkg/plugin" "github.com/reearth/reearth/server/pkg/project" "github.com/reearth/reearth/server/pkg/visualizer" "github.com/reearth/reearthx/account/accountdomain" @@ -67,6 +68,7 @@ type Project interface { Publish(context.Context, PublishProjectParam, *usecase.Operator) (*project.Project, error) CheckAlias(context.Context, string) (bool, error) Delete(context.Context, id.ProjectID, *usecase.Operator) error - ExportProject(context.Context, id.ProjectID, *usecase.Operator) (*project.Project, map[string]interface{}, []*plugin.Plugin, error) + ExportProject(context.Context, id.ProjectID, *zip.Writer, *usecase.Operator) (*project.Project, error) ImportProject(context.Context, map[string]interface{}) (*project.Project, usecasex.Tx, error) + UploadExportProjectZip(context.Context, *zip.Writer, *os.File, map[string]interface{}, *project.Project) error } diff --git a/server/internal/usecase/interfaces/scene.go b/server/internal/usecase/interfaces/scene.go index 9570407216..cfcd52b5b0 100644 --- a/server/internal/usecase/interfaces/scene.go +++ b/server/internal/usecase/interfaces/scene.go @@ -1,6 +1,7 @@ package interfaces import ( + "archive/zip" "context" "errors" @@ -31,6 +32,7 @@ type Scene interface { AddCluster(context.Context, id.SceneID, string, *usecase.Operator) (*scene.Scene, *scene.Cluster, error) UpdateCluster(context.Context, UpdateClusterParam, *usecase.Operator) (*scene.Scene, *scene.Cluster, error) RemoveCluster(context.Context, id.SceneID, id.ClusterID, *usecase.Operator) (*scene.Scene, error) + ExportScene(context.Context, *project.Project, *zip.Writer) (*scene.Scene, map[string]interface{}, error) ImportScene(context.Context, *project.Project, map[string]interface{}) (*scene.Scene, error) } diff --git a/web/src/services/gql/__gen__/graphql.ts b/web/src/services/gql/__gen__/graphql.ts index 4678eab41f..a42bd5bc0d 100644 --- a/web/src/services/gql/__gen__/graphql.ts +++ b/web/src/services/gql/__gen__/graphql.ts @@ -556,6 +556,15 @@ export type DuplicateStylePayload = { style: Style; }; +export type ExportProjectInput = { + projectId: Scalars['ID']['input']; +}; + +export type ExportProjectPayload = { + __typename?: 'ExportProjectPayload'; + projectDataPath: Scalars['String']['output']; +}; + export type Feature = { __typename?: 'Feature'; geometry: Geometry; @@ -609,6 +618,15 @@ export type ImportLayerPayload = { parentLayer: LayerGroup; }; +export type ImportProjectInput = { + file: Scalars['Upload']['input']; +}; + +export type ImportProjectPayload = { + __typename?: 'ImportProjectPayload'; + projectData: Scalars['JSON']['output']; +}; + export type Infobox = { __typename?: 'Infobox'; fields: Array; @@ -1019,9 +1037,11 @@ export type Mutation = { duplicateNLSLayer: DuplicateNlsLayerPayload; duplicateStoryPage: StoryPagePayload; duplicateStyle?: Maybe; + exportProject?: Maybe; importDataset?: Maybe; importDatasetFromGoogleSheet?: Maybe; importLayer?: Maybe; + importProject?: Maybe; installPlugin?: Maybe; linkDatasetToPropertyValue?: Maybe; moveInfoboxField?: Maybe; @@ -1265,6 +1285,11 @@ export type MutationDuplicateStyleArgs = { }; +export type MutationExportProjectArgs = { + input: ExportProjectInput; +}; + + export type MutationImportDatasetArgs = { input: ImportDatasetInput; }; @@ -1280,6 +1305,11 @@ export type MutationImportLayerArgs = { }; +export type MutationImportProjectArgs = { + input: ImportProjectInput; +}; + + export type MutationInstallPluginArgs = { input: InstallPluginInput; };