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..6012efd375 100644 --- a/server/internal/adapter/gql/resolver_mutation_project.go +++ b/server/internal/adapter/gql/resolver_mutation_project.go @@ -1,15 +1,24 @@ package gql import ( + "archive/zip" + "bytes" "context" "encoding/json" - "io" + "fmt" + "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/file" "github.com/reearth/reearth/server/pkg/id" "github.com/reearth/reearth/server/pkg/visualizer" "github.com/reearth/reearthx/account/accountdomain" + "github.com/spf13/afero" + "golang.org/x/exp/rand" ) func (r *mutationResolver) CreateProject(ctx context.Context, input gqlmodel.CreateProjectInput) (*gqlmodel.ProjectPayload, error) { @@ -111,34 +120,104 @@ func (r *mutationResolver) DeleteProject(ctx context.Context, input gqlmodel.Del } func (r *mutationResolver) ExportProject(ctx context.Context, input gqlmodel.ExportProjectInput) (*gqlmodel.ExportProjectPayload, error) { + fs := afero.NewOsFs() + t := time.Now().UTC() + entropy := ulid.Monotonic(rand.New(rand.NewSource(uint64(t.UnixNano()))), 0) + name := ulid.MustNew(ulid.Timestamp(t), entropy) + zipFile, err := fs.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 + } + // delete after saving to storage + 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 + } + + sce, data, err := usecases(ctx).Scene.ExportScene(ctx, prj, zipWriter) + if err != nil { + return nil, err + } + data["project"] = gqlmodel.ToProject(prj) + + 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) { - fileBytes, err := io.ReadAll(input.File.File) + data, assets, plugins, err := file.UncompressExportZip(input.File.File) if err != nil { return nil, err } + // Assets file import + changedFileName := make(map[string]string) + for fileName, file := range assets { + parts1 := strings.Split(fileName, "/") + 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 + } + + // Plugin file import + for fileName, file := range plugins { + parts := strings.Split(fileName, "/") + 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 + } + } + + for beforeName, afterName := range changedFileName { + data = bytes.Replace(data, []byte(beforeName), []byte(afterName), -1) + } + var jsonData map[string]interface{} - if err := json.Unmarshal(fileBytes, &jsonData); err != nil { + if err := json.Unmarshal(data, &jsonData); err != nil { return nil, err } @@ -154,43 +233,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, plgs, 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..684850b3fa 100644 --- a/server/internal/infrastructure/fs/file.go +++ b/server/internal/infrastructure/fs/file.go @@ -123,6 +123,26 @@ 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 afero.File) error { + + file, ok := zipFile.(*os.File) + if !ok { + return errors.New("invalid file type: expected *os.File") + } + _, err := f.upload(ctx, path.Join(exportDir, sanitize.Path(file.Name())), file) + return err +} + +func (f *fileRepo) RemoveExportProjectZip(ctx context.Context, filename string) error { + return f.delete(ctx, filepath.Join(exportDir, sanitize.Path(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..32dbddff97 100644 --- a/server/internal/infrastructure/gcs/file.go +++ b/server/internal/infrastructure/gcs/file.go @@ -16,6 +16,7 @@ import ( "github.com/reearth/reearth/server/pkg/id" "github.com/reearth/reearthx/log" "github.com/reearth/reearthx/rerror" + "github.com/spf13/afero" "google.golang.org/api/iterator" ) @@ -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 afero.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..d3429e74e6 100644 --- a/server/internal/infrastructure/s3/s3.go +++ b/server/internal/infrastructure/s3/s3.go @@ -21,6 +21,7 @@ import ( "github.com/reearth/reearthx/log" "github.com/reearth/reearthx/rerror" "github.com/samber/lo" + "github.com/spf13/afero" ) const ( @@ -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,27 @@ 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 afero.File) error { + sanitizedName := sanitize.Path(zipFile.Name()) + _, err := f.upload(ctx, path.Join(exportBasePath, sanitizedName), zipFile) + return err +} + +func (f *fileRepo) RemoveExportProjectZip(ctx context.Context, filename string) error { + sanitizedFilename := sanitize.Path(filename) + return f.delete(ctx, path.Join(exportBasePath, sanitizedFilename)) +} + // 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..f0bd4b77c0 100644 --- a/server/internal/usecase/gateway/file.go +++ b/server/internal/usecase/gateway/file.go @@ -8,6 +8,7 @@ import ( "github.com/reearth/reearth/server/pkg/file" "github.com/reearth/reearth/server/pkg/id" + "github.com/spf13/afero" ) var ( @@ -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, afero.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..23743bcc0a 100644 --- a/server/internal/usecase/interactor/project.go +++ b/server/internal/usecase/interactor/project.go @@ -1,9 +1,13 @@ package interactor import ( + "archive/zip" "context" + "encoding/json" "errors" + "fmt" "io" + "strings" "time" "github.com/reearth/reearth/server/internal/adapter/gql/gqlmodel" @@ -13,7 +17,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" @@ -22,6 +25,7 @@ import ( "github.com/reearth/reearthx/account/accountusecase/accountrepo" "github.com/reearth/reearthx/rerror" "github.com/reearth/reearthx/usecasex" + "github.com/spf13/afero" ) type Project struct { @@ -493,64 +497,69 @@ 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 + } + defer func() { + if cerr := stream.Close(); cerr != nil { + fmt.Printf("Error closing file: %v\n", cerr) + } + }() + 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 } - } - 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 afero.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 != nil { - return nil, nil, nil, err + if _, err = fileWriter.Write(jsonData); err != nil { + return err } - res := make(map[string]interface{}) - res["scene"] = sceneJSON - return prj, res, plgs, nil + if err := zipWriter.Close(); err != nil { + return err + } + + if _, err := zipFile.Seek(0, 0); err != nil { + return err + } + defer func() { + if err := zipFile.Close(); err != nil { + fmt.Println("Failed to close zip file:", err) + } + }() + 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 +620,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..e6a4c420f3 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,7 +595,133 @@ func (i *Scene) RemoveCluster(ctx context.Context, sceneID id.SceneID, clusterID return s, nil } -func (i *Scene) ImportScene(ctx context.Context, prj *project.Project, sceneData map[string]interface{}) (*scene.Scene, error) { +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() { + if item == nil { + continue + } + for _, field := range item.Fields(nil) { + if field == nil || field.Value() == nil || field.Value().Value() == nil { + continue + } + 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, plgs []*plugin.Plugin, sceneData map[string]interface{}) (*scene.Scene, error) { sceneJSON, err := builder.ParseSceneJSON(ctx, sceneData) if err != nil { return nil, err @@ -590,6 +730,16 @@ func (i *Scene) ImportScene(ctx context.Context, prj *project.Project, sceneData if err != nil { return nil, err } + + plugins := scene.NewPlugins([]*scene.Plugin{ + scene.NewPlugin(id.OfficialPluginID, nil), + }) + for _, plg := range plgs { + if plg.ID().String() != "reearth" { + plugins.Add(scene.NewPlugin(plg.ID(), nil)) + } + } + widgets := []*scene.Widget{} for _, widgetJSON := range sceneJSON.Widgets { widgetID, err := id.WidgetIDFrom(widgetJSON.ID) @@ -656,9 +806,10 @@ func (i *Scene) ImportScene(ctx context.Context, prj *project.Project, sceneData if err != nil { return nil, err } - tiles := id.PropertySchemaGroupID("tiles") - g := prop.GetOrCreateGroupList(schema, property.PointItemBySchema(tiles)) - g.Add(property.NewGroup().NewID().SchemaGroup(tiles).MustBuild(), -1) + prop, err = builder.AddItemFromPropertyJSON(prop, schema, sceneJSON.Property) + if err != nil { + return nil, err + } rootLayer, err := layer.NewGroup().NewID().Scene(sceneID).Root(true).Build() if err != nil { return nil, err @@ -678,6 +829,7 @@ func (i *Scene) ImportScene(ctx context.Context, prj *project.Project, sceneData UpdatedAt(time.Now()). Property(prop.ID()). Clusters(clusterList). + Plugins(plugins). Build() if err != nil { return nil, err @@ -689,6 +841,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 +871,34 @@ 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, "/") + if len(parts) == 0 { + return errors.New("invalid URL format") + } + fileName := parts[len(parts)-1] + if fileName == "" { + return errors.New("empty filename extracted from URL") + } + + 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/scene_test.go b/server/internal/usecase/interactor/scene_test.go index 7bb8fee4aa..31eb8ee993 100644 --- a/server/internal/usecase/interactor/scene_test.go +++ b/server/internal/usecase/interactor/scene_test.go @@ -10,6 +10,7 @@ import ( "github.com/reearth/reearth/server/internal/infrastructure/fs" "github.com/reearth/reearth/server/internal/infrastructure/memory" "github.com/reearth/reearth/server/internal/usecase/gateway" + "github.com/reearth/reearth/server/pkg/plugin" "github.com/reearth/reearth/server/pkg/policy" "github.com/reearth/reearth/server/pkg/project" "github.com/reearth/reearthx/account/accountdomain/workspace" @@ -140,7 +141,7 @@ func TestImportScene(t *testing.T) { assert.NoError(t, err) // invoke the target function - result, err := ifs.ImportScene(ctx, prj, sceneData) + result, err := ifs.ImportScene(ctx, prj, []*plugin.Plugin{}, sceneData) assert.NoError(t, err) assert.NotNil(t, result) @@ -176,7 +177,11 @@ func TestImportScene(t *testing.T) { "datasetSchemas": null, "id": "01j7g9ddv4sbf8tgt5c6xxj5xc", "newLayers": null, - "plugins": [], + "plugins": [ + { + "pluginId": "reearth" + } + ], "projectId": "%s", "stories": null, "styles": null, 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..433e90779d 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,17 @@ 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 + if len(styleIDs) == 0 { + return nil, 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..069e132846 100644 --- a/server/internal/usecase/interfaces/project.go +++ b/server/internal/usecase/interfaces/project.go @@ -1,17 +1,18 @@ package interfaces import ( + "archive/zip" "context" "errors" "net/url" "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" "github.com/reearth/reearthx/usecasex" + "github.com/spf13/afero" ) type CreateProjectParam struct { @@ -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, afero.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..79da387f33 100644 --- a/server/internal/usecase/interfaces/scene.go +++ b/server/internal/usecase/interfaces/scene.go @@ -1,11 +1,13 @@ package interfaces import ( + "archive/zip" "context" "errors" "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/scene" ) @@ -31,7 +33,8 @@ 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) - ImportScene(context.Context, *project.Project, map[string]interface{}) (*scene.Scene, error) + ExportScene(context.Context, *project.Project, *zip.Writer) (*scene.Scene, map[string]interface{}, error) + ImportScene(context.Context, *project.Project, []*plugin.Plugin, map[string]interface{}) (*scene.Scene, error) } type UpdateWidgetParam struct { diff --git a/server/pkg/file/zip.go b/server/pkg/file/zip.go index 8a6a71b6c9..60c86775ca 100644 --- a/server/pkg/file/zip.go +++ b/server/pkg/file/zip.go @@ -3,6 +3,7 @@ package file import ( "archive/zip" "bytes" + "fmt" "io" "strings" ) @@ -85,3 +86,46 @@ func ZipBasePath(zr *zip.Reader) (b string) { } return } + +func UncompressExportZip(file io.ReadSeeker) ([]byte, map[string]*zip.File, map[string]*zip.File, error) { + fileBytes, err := io.ReadAll(file) + if err != nil { + return nil, nil, nil, err + } + reader, err := zip.NewReader(bytes.NewReader(fileBytes), int64(len(fileBytes))) + if err != nil { + return nil, nil, nil, err + } + var data []byte + assets := make(map[string]*zip.File) + plugins := make(map[string]*zip.File) + for _, file := range reader.File { + if file.Name == "project.json" { + rc, err := file.Open() + if err != nil { + return nil, nil, nil, err + } + defer func(rc io.ReadCloser) { + if cerr := rc.Close(); cerr != nil { + fmt.Printf("Error closing file: %v\n", cerr) + } + }(rc) + data, err = io.ReadAll(rc) + if err != nil { + return nil, nil, nil, err + } + } else if strings.HasPrefix(file.Name, "assets/") { + trimmedName := strings.TrimPrefix(file.Name, "assets/") + assets[trimmedName] = file + } else if strings.HasPrefix(file.Name, "plugins/") { + trimmedName := strings.TrimPrefix(file.Name, "plugins/") + plugins[trimmedName] = file + } else { + return nil, nil, nil, fmt.Errorf("invalid file in zip: %s", file.Name) + } + } + if len(data) == 0 { + return nil, nil, nil, fmt.Errorf("project.json not found in the zip file") + } + return data, assets, plugins, nil +} diff --git a/server/pkg/scene/builder/decoder.go b/server/pkg/scene/builder/decoder.go index d6adb61efc..72cfe4d2ac 100644 --- a/server/pkg/scene/builder/decoder.go +++ b/server/pkg/scene/builder/decoder.go @@ -3,6 +3,7 @@ package builder import ( "context" "encoding/json" + "fmt" "github.com/reearth/reearth/server/internal/adapter/gql/gqlmodel" "github.com/reearth/reearth/server/pkg/id" @@ -109,20 +110,55 @@ func parseWidgetAreaPadding(paddingJSON *widgetAreaPaddingJSON) *scene.WidgetAre } func AddItemFromPropertyJSON(prop *property.Property, ps *property.Schema, pj propertyJSON) (*property.Property, error) { - for sgKey, value1 := range pj { - schemaGroupID := id.PropertySchemaGroupIDFromRef(&sgKey) - if iVal, ok := value1.(map[string]interface{}); ok { - for fKey, value2 := range iVal { - fieldID := id.PropertyFieldIDFromRef(&fKey) - ptr := property.NewPointer(schemaGroupID, nil, fieldID) - v, ok := parsePropertyValue(value2) - if ok { - _, _, _, err := prop.UpdateValue(ps, ptr, v) + for sgKey, value := range pj { + + if items, ok := value.(map[string]interface{}); ok { + // simple property + + sgID := id.PropertySchemaGroupIDFromRef(&sgKey) + + for fieldKey, value := range items { + + fieldID := id.PropertyFieldIDFromRef(&fieldKey) + ptr := property.NewPointer(sgID, nil, fieldID) + pv, ok := parsePropertyValue(value) + + if ok && ps != nil { + _, _, _, err := prop.UpdateValue(ps, ptr, pv) if err != nil { return nil, err } } } + + } else if arrayProperty, ok := value.([]interface{}); ok { + // group property + + for _, groupProperty := range arrayProperty { + + sg := id.PropertySchemaGroupID(sgKey) + gl := prop.GetOrCreateGroupList(ps, property.PointItemBySchema(sg)) + g := property.NewGroup().NewID().SchemaGroup(sg).MustBuild() + gl.Add(g, -1) + + if items, ok := groupProperty.(map[string]interface{}); ok { + + for fieldKey, value := range items { + if fieldKey == "id" { + continue + } + ov, ok := parsePropertyOptionalValue(value) + if ok { + fieldID := id.PropertyFieldIDFromRef(&fieldKey) + field := property.NewField(*fieldID). + Value(ov). + // Links(flinks). + Build() + g.AddFields(field) + } + } + } + } } } return prop, nil @@ -136,5 +172,15 @@ func parsePropertyValue(value interface{}) (*property.Value, bool) { return property.ValueType(fieldType).ValueFrom(fieldVal), ok } } + fmt.Printf("property is unreadable %v\n", value) + return nil, false +} + +func parsePropertyOptionalValue(value interface{}) (*property.OptionalValue, bool) { + pv, ok := parsePropertyValue(value) + if ok { + ov := property.NewOptionalValue(pv.Type(), pv) + return ov, true + } return nil, false } 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; };