diff --git a/docs/features.md b/docs/features.md index 2f20a32..1f624ac 100644 --- a/docs/features.md +++ b/docs/features.md @@ -77,7 +77,7 @@ Export content from a Plone site to a directory in the file system. Usage: - - `plone-exporter` + - `plone-exporter` [--include-revisions] Example: @@ -85,6 +85,9 @@ Example: plone-exporter instance/etc/zope.conf Plone /tmp/plone_data/ ``` +By default, the revisions history (older versions of each content item) are not exported. +If you do want them, add `--include-revisions` on the command line. + ## `plone-importer` Import content from a file system directory into an existing Plone site. diff --git a/news/39.feature.4 b/news/39.feature.4 new file mode 100644 index 0000000..0c2a4f6 --- /dev/null +++ b/news/39.feature.4 @@ -0,0 +1 @@ +Include revisions only when passing `--include-revisions`. @mauritsvanrees diff --git a/src/plone/exportimport/cli/__init__.py b/src/plone/exportimport/cli/__init__.py index cb55943..a71f976 100644 --- a/src/plone/exportimport/cli/__init__.py +++ b/src/plone/exportimport/cli/__init__.py @@ -15,6 +15,7 @@ "zopeconf": "Path to zope.conf", "site": "Plone site ID to export the content from", "path": "Path to export the content", + "--include-revisions": "Include revision history", }, }, "importer": { @@ -31,7 +32,10 @@ def _parse_args(description: str, options: dict, args: list): parser = argparse.ArgumentParser(description=description) for key, help in options.items(): - parser.add_argument(key, help=help) + if key.startswith("-"): + parser.add_argument(key, action="store_true", help=help) + else: + parser.add_argument(key, help=help) namespace, _ = parser.parse_known_args(args[1:]) return namespace @@ -40,6 +44,7 @@ def exporter_cli(args=sys.argv): """Export a Plone site.""" logger = cli_helpers.get_logger("Exporter") exporter_cli = CLI_SPEC["exporter"] + # We get an argparse.Namespace instance. namespace = _parse_args(exporter_cli["description"], exporter_cli["options"], args) app = cli_helpers.get_app(namespace.zopeconf) path = cli_helpers._process_path(namespace.path) @@ -48,7 +53,7 @@ def exporter_cli(args=sys.argv): sys.exit(1) site = cli_helpers.get_site(app, namespace.site, logger) with api.env.adopt_roles(["Manager"]): - results = get_exporter(site).export_site(path) + results = get_exporter(site).export_site(path, options=namespace) logger.info(f" Using path {path} to export content from Plone site at /{site.id}") for item in results[1:]: logger.info(f" Wrote {item}") diff --git a/src/plone/exportimport/exporters/__init__.py b/src/plone/exportimport/exporters/__init__.py index 90b80eb..eff4ad7 100644 --- a/src/plone/exportimport/exporters/__init__.py +++ b/src/plone/exportimport/exporters/__init__.py @@ -14,6 +14,8 @@ from zope.component import queryAdapter from zope.interface import implementer +import argparse + EXPORTER_NAMES = [ "plone.exporter.content", @@ -62,14 +64,16 @@ def _prepare_path(path: Optional[Path] = None) -> Path: path = Path(mkdtemp(prefix=PACKAGE_NAME)) return path - def export_site(self, path: Optional[Path] = None) -> List[Path]: + def export_site( + self, path: Optional[Path] = None, options: Optional[argparse.Namespace] = None + ) -> List[Path]: """Export the given site to the filesystem.""" path = self._prepare_path(path) paths: List[Path] = [path] with hooks.site(self.site): for exporter_name, exporter in self.exporters.items(): logger.debug(f"Exporting {self.site} with {exporter_name} to {path}") - new_paths = exporter.export_data(path) + new_paths = exporter.export_data(path, options=options) paths.extend(new_paths) return paths diff --git a/src/plone/exportimport/exporters/base.py b/src/plone/exportimport/exporters/base.py index 92299c3..8d6072c 100644 --- a/src/plone/exportimport/exporters/base.py +++ b/src/plone/exportimport/exporters/base.py @@ -6,8 +6,10 @@ from typing import Any from typing import Callable from typing import List +from typing import Optional from zope.globalrequest import getRequest +import argparse import json @@ -18,6 +20,7 @@ class BaseExporter: request: types.HTTPRequest = None data_hooks: List[Callable] = None obj_hooks: List[Callable] = None + options: Optional[argparse.Namespace] = None def __init__( self, @@ -27,6 +30,9 @@ def __init__( self.errors = [] self.request = getRequest() + def get_option(self, name, default=None): + return getattr(self.options, name, default) + @property def filepath(self) -> Path: """Filepath to be used during export.""" @@ -61,6 +67,7 @@ def export_data( base_path: Path, data_hooks: List[Callable] = None, obj_hooks: List[Callable] = None, + options: Optional[argparse.Namespace] = None, ) -> List[Path]: """Write data to filesystem.""" if not base_path.exists(): @@ -68,5 +75,6 @@ def export_data( self.base_path = base_path self.data_hooks = self.data_hooks or data_hooks or [] self.obj_hooks = self.obj_hooks or obj_hooks or [] + self.options = options paths = self.dump() return paths diff --git a/src/plone/exportimport/exporters/content.py b/src/plone/exportimport/exporters/content.py index 862e63f..21823aa 100644 --- a/src/plone/exportimport/exporters/content.py +++ b/src/plone/exportimport/exporters/content.py @@ -16,6 +16,8 @@ from typing import Optional from zope.interface import implementer +import argparse + @implementer(interfaces.INamedExporter) class ContentExporter(BaseExporter): @@ -87,8 +89,11 @@ def serialize(self, obj: DexterityContent) -> dict: f"{config.logger_prefix} Running {fixer.name} on serialized data" ) data = fixer.func(data, obj, config) + # Enrich - for enricher in content_utils.enrichers(): + for enricher in content_utils.enrichers( + include_revisions=self.get_option("include_revisions") + ): logger.debug(f"{config.logger_prefix} Running {enricher.name}") additional = enricher.func(obj, config) if additional: @@ -145,6 +150,7 @@ def export_data( data_hooks: List[Callable] = None, obj_hooks: List[Callable] = None, query: Optional[dict] = None, + options: Optional[argparse.Namespace] = None, ) -> List[Path]: # Content in a subpath of base_path base_path = base_path / self.name @@ -156,4 +162,4 @@ def export_data( self.request[settings.EXPORT_CONTENT_METADATA_KEY] = metadata self.request[settings.EXPORT_PATH_KEY] = base_path self.default_site_language = site.language - return super().export_data(base_path, data_hooks, obj_hooks) + return super().export_data(base_path, data_hooks, obj_hooks, options=options) diff --git a/src/plone/exportimport/utils/content/export_helpers.py b/src/plone/exportimport/utils/content/export_helpers.py index 527d4d4..109a56c 100644 --- a/src/plone/exportimport/utils/content/export_helpers.py +++ b/src/plone/exportimport/utils/content/export_helpers.py @@ -308,13 +308,14 @@ def fixers() -> List[types.ExportImportHelper]: return fixers -def enrichers() -> List[types.ExportImportHelper]: +def enrichers(include_revisions: bool = False) -> List[types.ExportImportHelper]: enrichers = [] funcs = [ add_constraints_info, add_workflow_history, - add_revisions_history, ] + if include_revisions: + funcs.append(add_revisions_history) if IConversation is not None: funcs.append(add_conversation) for func in funcs: diff --git a/src/plone/exportimport/utils/content/import_helpers.py b/src/plone/exportimport/utils/content/import_helpers.py index 037bb69..de81a0a 100644 --- a/src/plone/exportimport/utils/content/import_helpers.py +++ b/src/plone/exportimport/utils/content/import_helpers.py @@ -17,8 +17,12 @@ set_local_permissions as _set_local_permissions, ) from plone.restapi.interfaces import IDeserializeFromJson +from Products.CMFEditions.CopyModifyMergeRepositoryTool import ( + CopyModifyMergeRepositoryTool, +) from typing import Callable from typing import List +from unittest.mock import patch from urllib.parse import unquote from zope.component import getMultiAdapter @@ -107,6 +111,10 @@ def processors() -> List[types.ExportImportHelper]: return processors +def _mock_isVersionable(*args, **kwargs): + return False + + def get_obj_instance(item: dict, config: types.ImporterConfig) -> DexterityContent: # Get container container = get_parent_from_item(item) @@ -117,9 +125,14 @@ def get_obj_instance(item: dict, config: types.ImporterConfig) -> DexterityConte logger.debug(f"{config.logger_prefix} Will update {new}") else: factory_kwargs = item.get("factory_kwargs", {}) - new = unrestricted_construct_instance( - item["@type"], container, item["id"], **factory_kwargs - ) + # Temporarily disable versioning, otherwise the first version + # is basically nothing, it does not even have a title. + with patch.object( + CopyModifyMergeRepositoryTool, "isVersionable", _mock_isVersionable + ): + new = unrestricted_construct_instance( + item["@type"], container, item["id"], **factory_kwargs + ) logger.debug(f"{config.logger_prefix} Created {new}") return new