diff --git a/RELEASENOTES.md b/RELEASENOTES.md index e873541fd..712f3f033 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -3,6 +3,7 @@ ### Enhancements - Improved how log entries are displayed in the server adminstration window: The timestamp now displays the date when the entry is not from the current day and the context object gets expanded initially. ([#1131](https://github.com/realm/realm-studio/issues/1131), since v1.20.0) +- Added a menu item to export data to JSON or a local Realm from the Realm Browser window. ([#1134](https://github.com/realm/realm-studio/pull/1134)) ### Fixed diff --git a/scripts/header.txt b/scripts/header.txt index bbff48200..b14ac9546 100644 --- a/scripts/header.txt +++ b/scripts/header.txt @@ -1,6 +1,6 @@ //////////////////////////////////////////////////////////////////////////// // -// Copyright 2018 Realm Inc. +// Copyright 2019 Realm Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/services/data-exporter/index.ts b/src/services/data-exporter/index.ts new file mode 100644 index 000000000..d500f7b25 --- /dev/null +++ b/src/services/data-exporter/index.ts @@ -0,0 +1,62 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2019 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// + +import * as path from 'path'; + +export interface IExportEngine { + export(realm: Realm, destinationPath: string): void; +} + +export enum DataExportFormat { + JSON = 'json', + LocalRealm = 'local-realm', +} + +export class DataExporter { + private readonly format: DataExportFormat; + + constructor(format: DataExportFormat) { + this.format = format; + } + + public suggestFilename(realm: Realm) { + const basename = path.basename(realm.path, '.realm'); + if (this.format === DataExportFormat.JSON) { + return `${basename}.json`; + } else if (this.format === DataExportFormat.LocalRealm) { + return `${basename}.realm`; + } else { + throw new Error(`Unexpected format ${this.format}`); + } + } + + public export(realm: Realm, destinationPath: string) { + const ExportEngine = engines[this.format]; + const engine = new ExportEngine(); + return engine.export(realm, destinationPath); + } +} + +// Doing this last to prevent circular reference + +import { JSONExportEngine } from './json'; +import { LocalRealmExportEngine } from './local-realm'; +const engines = { + [DataExportFormat.JSON]: JSONExportEngine, + [DataExportFormat.LocalRealm]: LocalRealmExportEngine, +}; diff --git a/src/services/data-exporter/json.ts b/src/services/data-exporter/json.ts new file mode 100644 index 000000000..439895da8 --- /dev/null +++ b/src/services/data-exporter/json.ts @@ -0,0 +1,102 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2019 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// + +import * as fs from 'fs'; +import * as Realm from 'realm'; + +import { IExportEngine } from '.'; + +function serializeObject(object: { [key: string]: any } & Realm.Object) { + // This is an object reference + const objectSchema = object.objectSchema(); + if (objectSchema.primaryKey) { + return object[objectSchema.primaryKey]; + } else { + // Shallow copy the object + return RealmObjectToJSON.call(object); + } +} + +function serializeValue(propertyName: string, value: any) { + if ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value; + } else if (value instanceof Date) { + return value.toISOString(); + } else if (value instanceof ArrayBuffer) { + return Buffer.from(value).toString('base64'); + } else if ( + typeof value === 'object' && + typeof value.objectSchema === 'function' + ) { + return serializeObject(value); + } else if (typeof value === 'object' && typeof value.length === 'number') { + if (value.type === 'object') { + // A list of objects + return value.map((item: any) => { + if (typeof item === 'object') { + return serializeObject(item); + } else { + return item; + } + }); + } else { + // A list of primitives + return [...value]; + } + } else { + throw new Error( + `Failed to serialize '${propertyName}' field of type ${typeof value}`, + ); + } +} + +function RealmObjectToJSON(this: { [key: string]: any } & Realm.Object) { + const values: { [key: string]: any } = {}; + for (const propertyName of Object.getOwnPropertyNames(this)) { + const value = this[propertyName]; + if (propertyName === '_realm' || typeof value === 'function') { + continue; // Skip this property + } else { + values[propertyName] = serializeValue(propertyName, value); + } + } + return values; +} + +export class JSONExportEngine implements IExportEngine { + public export(realm: Realm, destinationPath: string) { + const data: { [objectSchemaName: string]: any[] } = {}; + for (const objectSchema of realm.schema) { + data[objectSchema.name] = [ + ...realm.objects(objectSchema.name).snapshot(), + ].map((object: any) => { + return Object.defineProperty(object, 'toJSON', { + value: RealmObjectToJSON.bind(object), + enumerable: false, + }); + }); + } + // Write the stringified data to a file + fs.writeFileSync(destinationPath, JSON.stringify(data, null, 2)); + } +} diff --git a/src/services/data-exporter/local-realm.ts b/src/services/data-exporter/local-realm.ts new file mode 100644 index 000000000..05755e960 --- /dev/null +++ b/src/services/data-exporter/local-realm.ts @@ -0,0 +1,25 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2019 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// + +import { IExportEngine } from '.'; + +export class LocalRealmExportEngine implements IExportEngine { + public export(realm: Realm, destinationPath: string) { + realm.writeCopyTo(destinationPath); + } +} diff --git a/src/ui/RealmBrowser/index.tsx b/src/ui/RealmBrowser/index.tsx index 8000bbc80..42a6d7972 100644 --- a/src/ui/RealmBrowser/index.tsx +++ b/src/ui/RealmBrowser/index.tsx @@ -1,6 +1,6 @@ //////////////////////////////////////////////////////////////////////////// // -// Copyright 2018 Realm Inc. +// Copyright 2019 Realm Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ import * as path from 'path'; import * as React from 'react'; import * as Realm from 'realm'; +import { DataExporter, DataExportFormat } from '../../services/data-exporter'; import * as dataImporter from '../../services/data-importer'; import { Language, SchemaExporter } from '../../services/schema-export'; import { menu } from '../../utils'; @@ -188,6 +189,20 @@ class RealmBrowserContainer ], }; + const exportDataMenu: MenuItemConstructorOptions = { + label: 'Save data', + submenu: [ + { + label: 'JSON', + click: () => this.onExportData(DataExportFormat.JSON), + }, + { + label: 'Local Realm', + click: () => this.onExportData(DataExportFormat.LocalRealm), + }, + ], + }; + const transactionMenuItems: MenuItemConstructorOptions[] = this.realm && this.realm.isInTransaction ? [ @@ -247,7 +262,7 @@ class RealmBrowserContainer { action: 'prepend', id: 'close', - items: [exportSchemaMenu, { type: 'separator' }], + items: [exportSchemaMenu, exportDataMenu, { type: 'separator' }], }, { action: 'append', @@ -662,6 +677,23 @@ class RealmBrowserContainer ); }; + private onExportData = (format: DataExportFormat) => { + try { + const exporter = new DataExporter(format); + if (this.realm) { + const destinationPath = remote.dialog.showSaveDialog({ + defaultPath: exporter.suggestFilename(this.realm), + message: 'Select a destination for the data', + }); + exporter.export(this.realm, destinationPath); + } else { + throw new Error('Realm is not open'); + } + } catch (err) { + showError('Failed to export data', err); + } + }; + private onImportIntoExistingRealm = ( format: dataImporter.ImportFormat = dataImporter.ImportFormat.CSV, ) => {