diff --git a/src/SenseNet.IO.CLI/AppArgumentModels.cs b/src/SenseNet.IO.CLI/AppArgumentModels.cs index 7ec4318..dbf6636 100644 --- a/src/SenseNet.IO.CLI/AppArgumentModels.cs +++ b/src/SenseNet.IO.CLI/AppArgumentModels.cs @@ -6,7 +6,9 @@ public enum Verb { Export, Import, + ImportXml, Copy, + CopyXml, Sync, Transfer }; @@ -30,6 +32,13 @@ public class ImportArguments : IAppArguments public RepositoryWriterArgs WriterArgs { get; set; } } + public class ImportXmlArguments : IAppArguments + { + public Verb Verb => Verb.ImportXml; + public FsXmlReaderArgs ReaderArgs { get; set; } + public RepositoryWriterArgs WriterArgs { get; set; } + } + public class CopyArguments : IAppArguments { public Verb Verb => Verb.Copy; @@ -37,6 +46,13 @@ public class CopyArguments : IAppArguments public FsWriterArgs WriterArgs { get; set; } } + public class CopyXmlArguments : IAppArguments + { + public Verb Verb => Verb.CopyXml; + public FsXmlReaderArgs ReaderArgs { get; set; } + public FsWriterArgs WriterArgs { get; set; } + } + public class SyncArguments : IAppArguments { public Verb Verb => Verb.Sync; diff --git a/src/SenseNet.IO.CLI/ArgumentParser.cs b/src/SenseNet.IO.CLI/ArgumentParser.cs index c58c2af..df5f933 100644 --- a/src/SenseNet.IO.CLI/ArgumentParser.cs +++ b/src/SenseNet.IO.CLI/ArgumentParser.cs @@ -12,9 +12,9 @@ public class ArgumentParser public IAppArguments Parse(string[] args) { if (args.Length == 0) - throw new ArgumentParserException("Missing verb (EXPORT, IMPORT, COPY, SYNC or TRANSFER)"); + throw new ArgumentParserException("Missing verb (EXPORT, IMPORT, IMPORTXML, COPY, COPYXML, SYNC or TRANSFER)"); if (!Enum.TryParse(args[0], true, out var verb)) - throw new ArgumentParserException($"Invalid verb '{args[0]}'. Valid values are EXPORT, IMPORT, COPY, SYNC or TRANSFER"); + throw new ArgumentParserException($"Invalid verb '{args[0]}'. Valid values are EXPORT, IMPORT, IMPORTXML, COPY, COPYXML, SYNC or TRANSFER"); var sourceSection = args .SkipWhile(x => !x.Equals("-source", Cmp)) @@ -46,12 +46,24 @@ protected virtual IAppArguments ParseByVerb(Verb verb, string[] sourceArgs, stri ReaderArgs = ParseFsReaderArgs(sourceArgs), WriterArgs = ParseRepositoryWriterArgs(targetArgs) }; + case Verb.ImportXml: + return new ImportXmlArguments + { + ReaderArgs = ParseFsXmlReaderArgs(sourceArgs), + WriterArgs = ParseRepositoryWriterArgs(targetArgs) + }; case Verb.Copy: return new CopyArguments { ReaderArgs = ParseFsReaderArgs(sourceArgs), WriterArgs = ParseFsWriterArgs(targetArgs) }; + case Verb.CopyXml: + return new CopyXmlArguments + { + ReaderArgs = ParseFsXmlReaderArgs(sourceArgs), + WriterArgs = ParseFsWriterArgs(targetArgs) + }; case Verb.Sync: return new SyncArguments { @@ -254,6 +266,49 @@ protected virtual RepositoryWriterArgs ParseRepositoryWriterArgs(string[] args) return result; } + protected virtual FsXmlReaderArgs ParseFsXmlReaderArgs(string[] args) + { + // [[-PATH] Path] [[-SKIP] PathList] [[-EXCLUDEFIELDS] FieldList] + var result = new FsXmlReaderArgs(); + var parsedArgs = ParseSequence(args); + + if (parsedArgs.Length > 3) + throw new ArgumentParserException("Too many FsXmlReader arguments."); + + foreach (var arg in parsedArgs) + { + if (arg.Key == "0" || arg.Key.Equals("PATH", Cmp)) + { + if (result.Path != null) + throw new ArgumentParserException("Invalid FsXmlReader arguments."); + if(arg.Value == null) + throw new ArgumentParserException("Invalid FsXmlReader arguments. Missing path."); + result.Path = arg.Value; + } + else if (arg.Key == "1" || arg.Key.Equals("SKIP", Cmp)) + { + if (result.Skip != null) + throw new ArgumentParserException("Invalid FsXmlReader arguments."); + result.Skip = arg.Value + .Split(",;".ToCharArray(), StringSplitOptions.RemoveEmptyEntries) + .Select(x=>x.Trim()) + .ToArray(); + } + else if (arg.Key == "2" || arg.Key.Equals("EXCLUDEFIELDS", Cmp)) + { + if (result.ExcludedFields != null) + throw new ArgumentParserException("Invalid FsXmlReader arguments."); + result.ExcludedFields = arg.Value + .Split(",;".ToCharArray(), StringSplitOptions.RemoveEmptyEntries) + .Select(x=>x.Trim()) + .ToArray(); + } + else + throw new ArgumentParserException("Unknown FsXmlReader argument: " + arg.Key); + } + return result; + } + private string[] _boolSwitches = new[] {"FLATTEN"}; private KeyValuePair[] ParseSequence(string[] args) { diff --git a/src/SenseNet.IO.CLI/Extensions.cs b/src/SenseNet.IO.CLI/Extensions.cs index f02adbe..2158fc4 100644 --- a/src/SenseNet.IO.CLI/Extensions.cs +++ b/src/SenseNet.IO.CLI/Extensions.cs @@ -25,6 +25,16 @@ public static void RewriteSettings(this FsReaderArgs args, FsReaderArgs settings if (args.Skip != null) settings.Skip = args.Skip; } + public static void RewriteSettings(this FsXmlReaderArgs args, FsXmlReaderArgs settings) + { + // rewrite from command line args (they override config file values) + if (args.Path != null) + settings.Path = args.Path; + if (args.Skip != null) + settings.Skip = args.Skip; + if (args.ExcludedFields != null) + settings.ExcludedFields = args.ExcludedFields; + } public static void RewriteSettings(this FsWriterArgs args, FsWriterArgs settings) { // workaround for "Null configuration elements deserialized as empty strings" https://github.com/dotnet/runtime/issues/36510 diff --git a/src/SenseNet.IO.CLI/Program.cs b/src/SenseNet.IO.CLI/Program.cs index 35498a4..570ff28 100644 --- a/src/SenseNet.IO.CLI/Program.cs +++ b/src/SenseNet.IO.CLI/Program.cs @@ -136,6 +136,30 @@ private static IHost CreateHost(string[] args, Stream settingsFile = null) }) ; break; + case Verb.ImportXml: + var importXmlArgs = (ImportXmlArguments) appArguments; + serviceCollection + .Configure(readerArgs => + { + hostBuilderContext.Configuration.GetSection("fsXmlReader") + .Bind(readerArgs); + importXmlArgs.ReaderArgs.RewriteSettings(readerArgs); + }) + .AddTransient() + .AddSenseNetIO(configureRepositoryWriter: writerArgs => + { + hostBuilderContext.Configuration.GetSection("repositoryWriter").Bind(writerArgs); + importXmlArgs.WriterArgs.RewriteSettings(writerArgs); + }) + .AddContentFlow() + .ConfigureSenseNetRepository("target", options => + { + hostBuilderContext.Configuration.GetSection("repositoryWriter").Bind(options); + //update from command line + importXmlArgs.WriterArgs.RewriteSettings(options); + }) + ; + break; case Verb.Copy: var copyArgs = (CopyArguments) appArguments; serviceCollection @@ -153,6 +177,24 @@ private static IHost CreateHost(string[] args, Stream settingsFile = null) .AddContentFlow() ; break; + case Verb.CopyXml: + var copyXmlArgs = (CopyXmlArguments) appArguments; + serviceCollection + .Configure(readerArgs => + { + hostBuilderContext.Configuration.GetSection("fsXmlReader") + .Bind(readerArgs); + copyXmlArgs.ReaderArgs.RewriteSettings(readerArgs); + }) + .AddTransient() + .AddSenseNetIO(configureFilesystemWriter: writerArgs => + { + hostBuilderContext.Configuration.GetSection("fsWriter").Bind(writerArgs); + copyXmlArgs.WriterArgs.RewriteSettings(writerArgs); + }) + .AddContentFlow() + ; + break; case Verb.Sync: var syncArgs = (SyncArguments) appArguments; serviceCollection @@ -218,7 +260,9 @@ private static string GetCustomConfigurationPath(ref string[] args) for (int i = 0; i < args.Length; i++) { if (args[i].Equals("-CONFIGURATION", StringComparison.OrdinalIgnoreCase) || - args[i].Equals("-CONFIG", StringComparison.OrdinalIgnoreCase)) + args[i].Equals("-CONFIG", StringComparison.OrdinalIgnoreCase) || + args[i].Equals("--CONFIGURATION", StringComparison.OrdinalIgnoreCase) || + args[i].Equals("--CONFIG", StringComparison.OrdinalIgnoreCase)) { if (i >= args.Length - 1) throw new ArgumentException("Missing configuration path"); diff --git a/src/SenseNet.IO.CLI/README_ExcludedFields.md b/src/SenseNet.IO.CLI/README_ExcludedFields.md new file mode 100644 index 0000000..92194f4 --- /dev/null +++ b/src/SenseNet.IO.CLI/README_ExcludedFields.md @@ -0,0 +1,288 @@ +# Field Filtering in FsXmlReader - Usage Guide + +## Overview + +The `FsXmlReader` supports the excluded fields feature, which allows you to filter out unnecessary or obsolete fields from old SenseNet 6 XML exports during conversion to the newer format. + +## Usage Methods + +### 1. Command Line Parameter (inline) + +The simplest method is to specify the fields to exclude directly in the command line: + +```bash +SenseNet.IO.CLI.exe COPYXML -source "c:\tmp\export2" -EXCLUDEFIELDS "CreatedBy,ModifiedBy,CreationDate,Actions,Id" -target "c:\tmp\exportuj" +``` + +**Advantages:** +- Fast, simple +- Works well for a few fields +- No separate configuration file needed + +**Disadvantages:** +- Cumbersome for many fields +- Not reusable + +### 2. JSON Configuration File + +For longer lists, it is more practical to use a JSON configuration file: + +**appsettings.fsxmlreader.json:** +```json +{ + "fsXmlReader": { + "Path": "c:\\tmp\\export2", + "Skip": [ + "Root/(apps)", + "Root/Skins", + "Root/Portlets" + ], + "ExcludedFields": [ + "ApprovingMode", + "CreatedBy", + "CreationDate", + "DateTaken", + "GroupAttachments", + "IconName", + "InheritableApprovingMode", + "InheritableVersioningMode", + "IsRateable", + "IsTaggable", + "ModificationDate", + "ModifiedBy", + "NotificationMode", + "Owner", + "PageCount", + "RateAvg", + "RateCount", + "Sharing", + "SeeAlso", + "TrashDisabled", + "VersionCreatedBy", + "VersionCreationDate", + "VersionModificationDate", + "VersionModifiedBy", + "VersioningMode", + "Actions", + "AllFieldSettingContents", + "AllRoles", + "Approvable", + "AvailableContentTypeFields", + "AvailableViews", + "BrowseApplication", + "BrowseUrl", + "CheckInComments", + "CheckedOutTo", + "Children", + "CreatedById", + "Depth", + "DirectRoles", + "EffectiveAllowedChildTypes", + "ExtensionData", + "FieldSettingContents", + "HandlerName", + "Icon", + "Id", + "Image", + "InFolder", + "InTree", + "IsFile", + "IsFolder", + "IsSystemContent", + "Locked", + "ModifiedById", + "Name", + "OwnerId", + "OwnerWhenVisitor", + "ParentId", + "ParentTypeName", + "Path", + "Publishable", + "Rate", + "RateStr", + "RejectReason", + "SavingState", + "SharedBy", + "SharedWith", + "SharingLevel", + "SharingMode", + "Tags", + "Type", + "TypeIs", + "Version", + "VersionId", + "Versions", + "Workspace", + "WorkspaceSkin" + ] + }, + "fsWriter": { + "Path": "c:\\tmp\\exportuj" + } +} +``` + +**Usage:** +```bash +SenseNet.IO.CLI.exe COPYXML --config appsettings.fsxmlreader.json +``` + +**Advantages:** +- Reusable +- Easy to maintain +- Manageable even with many fields +- Shareable with the team + +**Disadvantages:** +- Requires handling a separate file + +### 3. Programmatic Usage + +For usage in code: + +```csharp +var excludedFields = new[] +{ + // System audit fields + "CreatedBy", "CreationDate", "ModificationDate", "ModifiedBy", + "VersionCreatedBy", "VersionCreationDate", "VersionModificationDate", "VersionModifiedBy", + + // Computed/runtime fields + "Actions", "Children", "Path", "Type", "Version", "Id", + + // Deprecated SenseNet 6 fields + "ApprovingMode", "InheritableApprovingMode", "InheritableVersioningMode", + "IsRateable", "IsTaggable", "TrashDisabled" +}; + +var readerArgs = Options.Create(new FsXmlReaderArgs +{ + Path = @"c:\tmp\export2", + Skip = new[] { "Root/(apps)", "Root/Skins", "Root/Portlets" }, + ExcludedFields = excludedFields +}); + +var reader = new FsXmlReader(readerArgs, loggerFactory.CreateLogger()); +``` + +## Recommended Excluded Fields List + +### For SenseNet 6 → 7+ Migration + +#### Import Fields (should not be copied to the target) +``` +ApprovingMode +CreatedBy +CreationDate +DateTaken +GroupAttachments +IconName +InheritableApprovingMode +InheritableVersioningMode +IsRateable +IsTaggable +ModificationDate +ModifiedBy +NotificationMode +Owner +PageCount +RateAvg +RateCount +Sharing +SeeAlso +TrashDisabled +VersionCreatedBy +VersionCreationDate +VersionModificationDate +VersionModifiedBy +VersioningMode +``` + +#### Export Fields (computed/system fields) +``` +Actions +AllFieldSettingContents +AllRoles +Approvable +AvailableContentTypeFields +AvailableViews +BrowseApplication +BrowseUrl +CheckInComments +CheckedOutTo +Children +CreatedById +Depth +DirectRoles +EffectiveAllowedChildTypes +ExtensionData +FieldSettingContents +HandlerName +Icon +Id +Image +InFolder +InTree +IsFile +IsFolder +IsSystemContent +Locked +ModifiedById +Name +OwnerId +OwnerWhenVisitor +ParentId +ParentTypeName +Path +Publishable +Rate +RateStr +RejectReason +SavingState +SharedBy +SharedWith +SharingLevel +SharingMode +Tags +Type +TypeIs +Version +VersionId +Versions +Workspace +WorkspaceSkin +``` + +## Examples + +### 1. Full conversion with the list used in tests + +```bash +SenseNet.IO.CLI.exe COPYXML ^ + -source "c:\tmp\export2" ^ + -SKIP "Root/(apps),Root/Skins,Root/Portlets" ^ + -EXCLUDEFIELDS "ApprovingMode,CreatedBy,CreationDate,ModificationDate,ModifiedBy,Actions,Id,Type,Version,Children" ^ + -target "c:\tmp\exportuj" +``` + +### 2. Excluding only the most important fields + +```bash +SenseNet.IO.CLI.exe COPYXML ^ + -source "c:\tmp\export2" ^ + -EXCLUDEFIELDS "Id,Path,Type,Version,Actions,Children" ^ + -target "c:\tmp\exportuj" +``` + +### 3. With configuration file + +```bash +SenseNet.IO.CLI.exe COPYXML --config appsettings.fsxmlreader.json +``` + +## Notes + +- `ExcludedFields` is case-insensitive +- Fields can be separated by comma or semicolon +- If a field is not present in the XML, it will not cause an error +- Excluded fields are filtered out during reading, saving memory diff --git a/src/SenseNet.IO.Tests/FsXmlReaderTests.cs b/src/SenseNet.IO.Tests/FsXmlReaderTests.cs new file mode 100644 index 0000000..14928af --- /dev/null +++ b/src/SenseNet.IO.Tests/FsXmlReaderTests.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SenseNet.IO.Implementations; +using SenseNet.IO.CLI; + +namespace SenseNet.IO.Tests +{ + [TestClass] + public class FsXmlReaderTests + { + /// + /// Fields that should be excluded during import (not written to target). + /// + private static readonly HashSet ExcludedImportFields = new(StringComparer.OrdinalIgnoreCase) + { + "ApprovingMode", + "CreatedBy", + "CreationDate", + "DateTaken", + "GroupAttachments", + "IconName", + "InheritableApprovingMode", + "InheritableVersioningMode", + "IsRateable", + "IsTaggable", + "ModificationDate", + "ModifiedBy", + "NotificationMode", + "Owner", + "PageCount", + "RateAvg", + "RateCount", + "Sharing", + "SeeAlso", + "TrashDisabled", + "VersionCreatedBy", + "VersionCreationDate", + "VersionModificationDate", + "VersionModifiedBy", + "VersioningMode" + }; + + /// + /// Fields that should be excluded from export (should not appear in source reading). + /// + private static readonly HashSet ExcludedExportFields = new(StringComparer.OrdinalIgnoreCase) + { + "Actions", "AllFieldSettingContents", "AllRoles", "Approvable", "ApprovingMode", + "AvailableContentTypeFields", "AvailableViews", "BrowseApplication", "BrowseUrl", + "CheckInComments", "CheckedOutTo", "Children", "CreatedById", "Depth", "DirectRoles", + "EffectiveAllowedChildTypes", "ExtensionData", "FieldSettingContents", "HandlerName", + "Icon", "Id", "Image", "InFolder", "InTree", "InheritableApprovingMode", + "InheritableVersioningMode", "IsFile", "IsFolder", "IsSystemContent", "Locked", + "ModifiedById", "Name", "OwnerId", "OwnerWhenVisitor", "ParentId", "ParentTypeName", + "Path", "Publishable", "Rate", "RateStr", "RejectReason", "SavingState", "SharedBy", + "SharedWith", "Sharing", "SharingLevel", "SharingMode", "Tags", "Type", "TypeIs", + "Version", "VersionCreatedBy", "VersionCreationDate", "VersionId", + "VersionModificationDate", "VersionModifiedBy", "VersioningMode", "Versions", + "Workspace", "WorkspaceSkin" + }; + + /// + /// Path mappings from SenseNet 6 structure to SenseNet 7+ structure. + /// + private static readonly Dictionary PathMappings = new(StringComparer.OrdinalIgnoreCase) + { + { "Root/Sites", "Root/Content" }, + }; + + /// + /// Subtrees to skip during conversion (obsolete in SenseNet 7+). + /// + private static readonly string[] SkippedSubtrees = + { + "Root/(apps)", + "Root/Skins", + "Root/Portlets", + }; + + /// + /// Applies SenseNet 6 → 7+ path transformations. + /// + private static string TransformPath(string relativePath) + { + foreach (var mapping in PathMappings) + { + if (relativePath.Equals(mapping.Key, StringComparison.OrdinalIgnoreCase)) + return mapping.Value; + if (relativePath.StartsWith(mapping.Key + "/", StringComparison.OrdinalIgnoreCase)) + return mapping.Value + relativePath.Substring(mapping.Key.Length); + } + return relativePath; + } + + /// + /// Converts old SenseNet 6 XML export from c:\tmp\export2\ to new JSON format in c:\tmp\exportuj\ + /// using FsXmlReader as source and FsWriter as output. + /// Applies field filtering and path transformations for SenseNet 6 → 7+ migration. + /// + [TestMethod] + public async Task ConvertOldXmlExportToNewJsonFormat() + { + var sourcePath = @"c:\tmp\export2"; + var targetPath = @"c:\tmp\exportuj"; + + var loggerFactory = LoggerFactory.Create(builder => builder.AddDebug()); + + // Combine all excluded fields + var allExcludedFields = ExcludedImportFields.Union(ExcludedExportFields).ToArray(); + + var readerArgs = Options.Create(new FsXmlReaderArgs + { + Path = sourcePath, + Skip = SkippedSubtrees, + ExcludedFields = allExcludedFields + }); + var reader = new FsXmlReader(readerArgs, loggerFactory.CreateLogger()); + + var writerArgs = Options.Create(new FsWriterArgs { Path = targetPath }); + var writer = new FsWriter(writerArgs, loggerFactory.CreateLogger()); + + await reader.InitializeAsync(); + await writer.InitializeAsync(); + + var cancel = CancellationToken.None; + var count = 0; + + while (await reader.ReadAllAsync(Array.Empty(), cancel)) + { + var content = reader.Content; + var relativePath = reader.RelativePath; + + if (string.IsNullOrEmpty(relativePath)) + relativePath = content.Name; + + // Apply path transformation (e.g., Root/Sites → Root/Content) + var targetRelativePath = TransformPath(relativePath); + + var result = await writer.WriteAsync(targetRelativePath, content, cancel); + count++; + } + + Assert.IsTrue(count > 0, $"No content was read from the source directory."); + } + + /// + /// Tests the CLI COPYXML verb with FsXmlReader and FsWriter using command line arguments. + /// + /// Command line usage: + /// SenseNet.IO.CLI.exe COPYXML -source "c:\tmp\export2" -SKIP "Root/(apps),Root/Skins,Root/Portlets" -target "c:\tmp\exportuj" + /// + /// This converts old SenseNet 6 XML exports to new JSON format on the filesystem. + /// + [TestMethod] + public void Test_CLI_CopyXmlToJson() + { + var commandLineArgs = new[] + { + "COPYXML", + "-source", "c:\\tmp\\export2", + "-SKIP", "Root/(apps),Root/Skins,Root/Portlets", + "-target", "c:\\tmp\\exportuj" + }; + + var parser = new ArgumentParser(); + var arguments = parser.Parse(commandLineArgs); + + Assert.IsNotNull(arguments); + Assert.AreEqual(Verb.CopyXml, arguments.Verb); + + var copyXmlArgs = (CopyXmlArguments)arguments; + Assert.AreEqual("c:\\tmp\\export2", copyXmlArgs.ReaderArgs.Path); + Assert.AreEqual(3, copyXmlArgs.ReaderArgs.Skip.Length); + Assert.AreEqual("c:\\tmp\\exportuj", copyXmlArgs.WriterArgs.Path); + } + + /// + /// Tests the CLI COPYXML verb with excluded fields parameter. + /// + /// Command line usage: + /// SenseNet.IO.CLI.exe COPYXML -source "c:\tmp\export2" -EXCLUDEFIELDS "CreatedBy,ModifiedBy,Actions,Id" -target "c:\tmp\exportuj" + /// + [TestMethod] + public void Test_CLI_CopyXmlToJson_WithExcludedFields() + { + var commandLineArgs = new[] + { + "COPYXML", + "-source", "c:\\tmp\\export2", + "-EXCLUDEFIELDS", "CreatedBy,ModifiedBy,Actions,Id,Type,Version", + "-target", "c:\\tmp\\exportuj" + }; + + var parser = new ArgumentParser(); + var arguments = parser.Parse(commandLineArgs); + + Assert.IsNotNull(arguments); + Assert.AreEqual(Verb.CopyXml, arguments.Verb); + + var copyXmlArgs = (CopyXmlArguments)arguments; + Assert.AreEqual("c:\\tmp\\export2", copyXmlArgs.ReaderArgs.Path); + Assert.IsNotNull(copyXmlArgs.ReaderArgs.ExcludedFields); + Assert.AreEqual(6, copyXmlArgs.ReaderArgs.ExcludedFields.Length); + Assert.IsTrue(copyXmlArgs.ReaderArgs.ExcludedFields.Contains("CreatedBy")); + Assert.IsTrue(copyXmlArgs.ReaderArgs.ExcludedFields.Contains("Version")); + Assert.AreEqual("c:\\tmp\\exportuj", copyXmlArgs.WriterArgs.Path); + } + } +} diff --git a/src/SenseNet.IO/Implementations/FsXmlContent.cs b/src/SenseNet.IO/Implementations/FsXmlContent.cs new file mode 100644 index 0000000..77249b9 --- /dev/null +++ b/src/SenseNet.IO/Implementations/FsXmlContent.cs @@ -0,0 +1,296 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.Extensions.Logging; + +namespace SenseNet.IO.Implementations +{ + /// + /// Represents a content read from an old SenseNet 6 XML export format. + /// + public class FsXmlContent : IContent + { + private string[] _fieldNames; + private Dictionary _fields; + + public string[] FieldNames => _fieldNames ?? Array.Empty(); + + public object this[string fieldName] + { + get => _fields.TryGetValue(fieldName, out var value) ? value : null; + set => _fields[fieldName] = value; + } + + public string Name { get; set; } + public string Path { get; set; } + public bool CutOff { get; set; } + + private string _type; + public string Type + { + get + { + if (_type != null) + return _type; + return IsDirectory ? "Folder" : "File"; + } + } + + public PermissionInfo Permissions { get; set; } + public bool IsFolder => IsDirectory; + public bool HasData => _metaFilePath != null; + + public bool IsDirectory { get; } + + private readonly string _metaFilePath; + private readonly string _defaultAttachmentPath; + private Dictionary _attachmentNames; + private readonly ILogger _logger; + + public FsXmlContent(string name, string relativePath, string metaFilePath, bool isDirectory, bool cutOff, ILogger logger, string defaultAttachmentPath = null) + { + Name = name; + IsDirectory = isDirectory; + Path = relativePath; + _metaFilePath = metaFilePath; + CutOff = cutOff; + _logger = logger; + _defaultAttachmentPath = defaultAttachmentPath; + } + + public Task GetAttachmentsAsync(CancellationToken cancel) + { + if (_defaultAttachmentPath != null) + { + return Task.FromResult(new[] + { + new Attachment + { + FieldName = "Binary", + FileName = System.IO.Path.GetFileName(_defaultAttachmentPath), + Stream = File.Exists(_defaultAttachmentPath) ? new FileStream(_defaultAttachmentPath, FileMode.Open, FileAccess.Read) : null + } + }); + } + + if (_attachmentNames == null || _attachmentNames.Count == 0) + return Task.FromResult(Array.Empty()); + + var directory = System.IO.Path.GetDirectoryName(_metaFilePath); + var attachments = new List(); + foreach (var attachmentItem in _attachmentNames) + { + var attachmentPath = System.IO.Path.Combine(directory!, attachmentItem.Value); + // Binary field keeps the file extension, other fields (like ImageData) do not + var fileName = attachmentItem.Key.Equals("Binary", StringComparison.OrdinalIgnoreCase) + ? attachmentItem.Value + : System.IO.Path.GetFileNameWithoutExtension(attachmentItem.Value); + attachments.Add(new Attachment + { + FieldName = attachmentItem.Key, + FileName = fileName, + Stream = File.Exists(attachmentPath) ? new FileStream(attachmentPath, FileMode.Open, FileAccess.Read) : null + }); + } + + return Task.FromResult(attachments.ToArray()); + } + + /// + /// Returns attachment file names referenced in the XML metadata file. + /// + public string[] GetPreprocessedAttachmentNames() + { + if (_metaFilePath == null) + return Array.Empty(); + + try + { + var doc = XDocument.Load(_metaFilePath); + var root = doc.Root; + if (root == null) + return Array.Empty(); + + var names = new Dictionary(); + var fieldsElement = root.Element("Fields"); + if (fieldsElement != null) + { + foreach (var field in fieldsElement.Elements()) + { + var attachment = field.Attribute("attachment")?.Value; + if (!string.IsNullOrEmpty(attachment)) + { + names[field.Name.LocalName] = attachment; + } + } + } + + _attachmentNames = names; + return _attachmentNames.Values.ToArray(); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Cannot preprocess attachments from XML: {Path}", _metaFilePath); + _attachmentNames = new Dictionary(); + return Array.Empty(); + } + } + + /// + /// Parses the XML metadata file and initializes fields, type, permissions. + /// + /// Optional list of field names to exclude from reading. + /// If true, permissions are not parsed. + public void InitializeMetadata(string[] excludedFields = null, bool? withoutPermissions = false) + { + if (_metaFilePath == null) + { + _fields = new Dictionary(); + _fieldNames = Array.Empty(); + return; + } + + try + { + var doc = XDocument.Load(_metaFilePath); + var root = doc.Root; + if (root == null) + throw new Exception("Empty XML metafile: " + _metaFilePath); + + _type = root.Element("ContentType")?.Value; + if (_type == null) + throw new Exception("Cannot parse the \"ContentType\" element: " + _metaFilePath); + + var contentName = root.Element("ContentName")?.Value; + if (!string.IsNullOrEmpty(contentName)) + Name = contentName; + + _fields = new Dictionary(); + var fieldsElement = root.Element("Fields"); + if (fieldsElement != null) + { + foreach (var field in fieldsElement.Elements()) + { + var fieldName = field.Name.LocalName; + + // Skip excluded fields + if (excludedFields != null && excludedFields.Contains(fieldName, StringComparer.OrdinalIgnoreCase)) + continue; + + // Handle attachment-only fields (they have no text value, only an attachment attribute) + var attachment = field.Attribute("attachment")?.Value; + if (!string.IsNullOrEmpty(attachment) && string.IsNullOrEmpty(field.Value)) + { + // Store attachment-only field as object with Attachment property + // Binary field keeps the file extension, other fields (like ImageData) do not + var attachmentName = fieldName.Equals("Binary", StringComparison.OrdinalIgnoreCase) + ? attachment + : System.IO.Path.GetFileNameWithoutExtension(attachment); + _fields[fieldName] = new { Attachment = attachmentName }; + continue; + } + + // If field has child elements, store as a complex structure + if (field.HasElements) + { + // For multi-value fields like References, store as string array + var values = field.Elements().Select(e => e.Value).ToArray(); + if (values.Length > 0) + _fields[fieldName] = values; + } + else + { + var value = field.Value; + if (!string.IsNullOrEmpty(value)) + { + // Convert space-separated values to arrays for specific fields + if (ShouldConvertToArray(fieldName)) + { + _fields[fieldName] = value.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + } + else + { + _fields[fieldName] = value; + } + } + } + } + } + + _fieldNames = _fields.Keys.ToArray(); + + if (withoutPermissions != true) + { + var permsElement = root.Element("Permissions"); + if (permsElement != null) + { + var parsed = ParsePermissions(permsElement); + if (parsed.Entries.Length > 0) + Permissions = parsed; + } + } + } + catch (Exception ex) when (ex.Message.StartsWith("Cannot parse") || ex.Message.StartsWith("Empty XML")) + { + throw; + } + catch (Exception ex) + { + throw new Exception("Cannot parse the XML metafile: " + _metaFilePath, ex); + } + } + + /// + /// Determines if a field should be converted from space-separated string to an array. + /// + private static bool ShouldConvertToArray(string fieldName) + { + // Fields that are stored as space-separated strings in SenseNet 6 XML + // but should be arrays in modern JSON format + return fieldName.Equals("AllowedChildTypes", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Parses the XML Permissions element into a PermissionInfo object. + /// + private static PermissionInfo ParsePermissions(XElement permsElement) + { + // means permissions are not inherited (IsInherited = false) + var isInherited = permsElement.Element("Clear") == null && permsElement.Element("Break") == null; + + var entries = new List(); + foreach (var identity in permsElement.Elements("Identity")) + { + var path = identity.Attribute("path")?.Value; + if (string.IsNullOrEmpty(path)) + continue; + + var propagation = identity.Attribute("propagation")?.Value; + var localOnly = string.Equals(propagation, "LocalOnly", StringComparison.OrdinalIgnoreCase); + + var permissions = new Dictionary(); + foreach (var perm in identity.Elements()) + { + var permValue = perm.Value?.Trim(); + if (string.Equals(permValue, "Allowed", StringComparison.OrdinalIgnoreCase)) + permissions[perm.Name.LocalName] = "allow"; + else if (string.Equals(permValue, "Denied", StringComparison.OrdinalIgnoreCase)) + permissions[perm.Name.LocalName] = "deny"; + } + + if (permissions.Count > 0) + entries.Add(new AceInfo { Identity = path, LocalOnly = localOnly, Permissions = permissions }); + } + + return new PermissionInfo + { + IsInherited = isInherited, + Entries = entries.ToArray() + }; + } + } +} diff --git a/src/SenseNet.IO/Implementations/FsXmlReader.cs b/src/SenseNet.IO/Implementations/FsXmlReader.cs new file mode 100644 index 0000000..587c461 --- /dev/null +++ b/src/SenseNet.IO/Implementations/FsXmlReader.cs @@ -0,0 +1,389 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SenseNet.Tools.Configuration; + +namespace SenseNet.IO.Implementations +{ + [OptionsClass(sectionName: "fsXmlReader")] + public class FsXmlReaderArgs + { + /// + /// Source file system folder path containing old SenseNet 6 XML exports. + /// + public string Path { get; set; } + /// + /// A string array containing relative paths of the skipped subtrees. + /// + public string[] Skip { get; set; } + /// + /// A string array containing field names to exclude during reading. + /// + public string[] ExcludedFields { get; set; } + + internal FsXmlReaderArgs Clone() + { + return new FsXmlReaderArgs + { + Path = Path, + Skip = Skip, + ExcludedFields = ExcludedFields, + }; + } + } + + /// + /// Reads old SenseNet 6 XML-based file system exports. + /// The XML metafiles have a .Content extension with XML content in the format: + /// <ContentMetaData> + /// <ContentType>...</ContentType> + /// <ContentName>...</ContentName> + /// <Fields>...</Fields> + /// </ContentMetaData> + /// + public class FsXmlReader : IFilesystemReader + { + // IFilesystemReader requires FsReaderArgs, so we adapt our args + public FsReaderArgs ReaderOptions { get; } + + private readonly FsXmlReaderArgs _args; + private FsXmlContent _content; + + public string RootName => ContentPath.GetName(_args.Path); + public int EstimatedCount { get; private set; } + public IContent Content => _content; + public string RelativePath => _content?.Path; + + private readonly ILogger _logger; + + public FsXmlReader(IOptions args, ILogger logger) + { + if (args?.Value == null) + throw new ArgumentNullException(nameof(args)); + _args = args.Value.Clone(); + + if (!string.IsNullOrEmpty(_args.Path)) + _args.Path = Path.GetFullPath(_args.Path); + + _logger = logger; + + // Adapt to FsReaderArgs for IFilesystemReader compatibility + ReaderOptions = new FsReaderArgs { Path = _args.Path, Skip = _args.Skip }; + } + + private bool _initialized; + public virtual Task InitializeAsync() + { + if (_initialized) + return Task.CompletedTask; + _initialized = true; + + if (_args.Path == null) + throw new ArgumentException("FsXmlReader: empty root path."); + + if (_args.Skip != null) + foreach (var item in _args.Skip) + SkipSubtree(item); + + EstimatedCount = Directory.Exists(_args.Path) || File.Exists(_args.Path) || File.Exists(_args.Path + ".Content") ? 1 : 0; + Task.Run(() => GetContentCount(_args.Path)); + + return Task.CompletedTask; + } + + private TreeState _mainState; + private readonly Dictionary _treeStates = new(); + + public async Task ReadSubTreeAsync(string relativePath, CancellationToken cancel = default) + { + await InitializeAsync().ConfigureAwait(false); + + if (!_treeStates.TryGetValue(relativePath, out var treeState)) + { + var absPath = Path.GetFullPath(Path.Combine(_args.Path, relativePath)); + treeState = new TreeState { FsRootPath = absPath }; + _treeStates.Add(relativePath, treeState); + } + + return ReadTree(treeState); + } + + public async Task ReadAllAsync(string[] contentsWithoutChildren, CancellationToken cancel = default) + { + if (_mainState == null) + { + await InitializeAsync().ConfigureAwait(false); + _mainState = new TreeState + { + FsRootPath = _args.Path, + ContentsWithoutChildren = contentsWithoutChildren ?? Array.Empty() + }; + } + + return ReadTree(_mainState); + } + + private IEnumerator _referenceUpdateTasksEnumerator; + public void SetReferenceUpdateTasks(IEnumerable tasks, int taskCount) + { + _referenceUpdateTasksEnumerator = tasks.GetEnumerator(); + } + + public Task ReadByReferenceUpdateTasksAsync(CancellationToken cancel) + { + if (!_referenceUpdateTasksEnumerator.MoveNext()) + return Task.FromResult(false); + + var task = _referenceUpdateTasksEnumerator.Current; + var relativePath = task.ReaderPath; + var metaFilePath = Path.GetFullPath(Path.Combine(_args.Path, relativePath)) + ".Content"; + var name = ContentPath.GetName("/" + relativePath); + var content = new FsXmlContent(name, relativePath, metaFilePath, false, false, _logger); + content.InitializeMetadata(task.BrokenReferences, !task.RetryPermissions); + _content = content; + + return Task.FromResult(true); + } + + private readonly List _cutoffs = new(); + public void SkipSubtree(string relativePath) + { + _cutoffs.Add(relativePath); + } + + private bool NeedToSkip(string path) + { + var relativePath = ContentPath.GetRelativePath(path, _args.Path); + return _cutoffs.Any(relativePath.StartsWith); + } + + // ============================== Tree traversal (same pattern as FsReader) + + private class TreeState + { + public string FsRootPath { get; set; } + public string[] ContentsWithoutChildren { get; set; } = Array.Empty(); + public Stack Levels { get; } = new(); + } + + private class Level + { + public FsXmlContent CurrentContent => Contents[Index]; + public int Index { get; set; } + public FsXmlContent[] Contents { get; set; } + } + + private bool ReadTree(TreeState state) + { + if (state.Levels.Count == 0) + return MoveToFirst(state); + + if (!NeedToSkip(Content.Path)) + { + if (MoveToFirstChild(state)) + return true; + } + + if (MoveToNextSibling(state)) + return true; + + while (true) + { + if (MoveToParent(state)) + if (MoveToNextSibling(state)) + return true; + if (state.Levels.Count == 0) + break; + } + return false; + } + + private void SetCurrentContent(Stack levels) + { + var level = levels.Peek(); + _content = level.Contents[level.Index]; + } + + private bool MoveToFirst(TreeState state) + { + var rootContent = GetRootContent(state.FsRootPath); + if (rootContent == null) + return false; + var level = new Level { Contents = new[] { rootContent } }; + state.Levels.Push(level); + SetCurrentContent(state.Levels); + return true; + } + + private bool MoveToParent(TreeState state) + { + if (state.Levels.Count == 0) + return false; + state.Levels.Pop(); + if (state.Levels.Count == 0) + return false; + SetCurrentContent(state.Levels); + return true; + } + + private bool MoveToNextSibling(TreeState state) + { + var level = state.Levels.Peek(); + var index = level.Index + 1; + if (index >= level.Contents.Length) + return false; + level.Index = index; + SetCurrentContent(state.Levels); + return true; + } + + private bool MoveToFirstChild(TreeState state) + { + var currentContent = state.Levels.Peek().CurrentContent; + if (state.ContentsWithoutChildren.Contains(currentContent.Path, StringComparer.OrdinalIgnoreCase)) + return false; + var contents = ReadChildren(currentContent); + if (contents.Length == 0) + return false; + var level = new Level { Contents = contents }; + state.Levels.Push(level); + SetCurrentContent(state.Levels); + return true; + } + + private FsXmlContent GetRootContent(string fsRootPath) + { + var contentName = Path.GetFileName(fsRootPath); + var metaFilePath = fsRootPath + ".Content"; + if (!File.Exists(metaFilePath)) + metaFilePath = null; + var contentIsDirectory = Directory.Exists(fsRootPath); + if (!contentIsDirectory && metaFilePath == null) + return null; + var relativePath = fsRootPath.Length > _args.Path.Length + ? fsRootPath.Remove(0, _args.Path.Length).Replace('\\', '/').TrimStart('/') + : string.Empty; + var content = new FsXmlContent(contentName, relativePath, metaFilePath, contentIsDirectory, _cutoffs.Contains(relativePath), _logger); + content.GetPreprocessedAttachmentNames(); + content.InitializeMetadata(_args.ExcludedFields); + return content; + } + + private FsXmlContent[] ReadChildren(FsXmlContent parentContent) + { + string GetPath(string name) => ContentPath.Combine(parentContent.Path, name); + + if (parentContent.CutOff) + return Array.Empty(); + if (!parentContent.IsDirectory) + return Array.Empty(); + + var fsPath = string.IsNullOrEmpty(parentContent.Path) + ? _args.Path + : Path.GetFullPath(Path.Combine(_args.Path, parentContent.Path)); + + var dirs = Directory.Exists(fsPath) ? Directory.GetDirectories(fsPath).OrderBy(x => x).ToList() : new List(); + var filePaths = Directory.Exists(fsPath) ? Directory.GetFiles(fsPath).OrderBy(x => x).ToList() : new List(); + var localContents = new List(); + var container = new List(); + + foreach (var directoryPath in dirs) + { + var metaFilePath = directoryPath + ".Content"; + if (!File.Exists(metaFilePath)) + metaFilePath = null; + else + filePaths.Remove(metaFilePath); + + var name = Path.GetFileName(directoryPath); + var content = new FsXmlContent(name, GetPath(name), metaFilePath, true, _cutoffs.Contains(GetPath(name)), _logger); + content.InitializeMetadata(_args.ExcludedFields); + localContents.Add(content); + container.Add(content); + } + + var metaFilePaths = filePaths + .Where(p => p.EndsWith(".Content", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + foreach (var metaFilePath in metaFilePaths) + { + var name = Path.GetFileNameWithoutExtension(metaFilePath); + var content = new FsXmlContent(name, GetPath(name), metaFilePath, false, false, _logger); + content.InitializeMetadata(_args.ExcludedFields); + localContents.Add(content); + container.Add(content); + } + + var attachmentPaths = filePaths.Except(metaFilePaths).Select(x => x.ToLowerInvariant()).ToList(); + var attachmentNames = localContents.SelectMany(x => x.GetPreprocessedAttachmentNames()); + foreach (var attachmentName in attachmentNames) + attachmentPaths.Remove(Path.Combine(fsPath, attachmentName).ToLowerInvariant()); + + foreach (var attachmentPath in attachmentPaths) + { + var name = Path.GetFileName(attachmentPath); + var content = new FsXmlContent(name, GetPath(name), null, false, false, _logger, attachmentPath); + content.InitializeMetadata(_args.ExcludedFields); + localContents.Add(content); + container.Add(content); + } + + return container.ToArray(); + } + + private void GetContentCount(string fsPath) + { + if (!Directory.Exists(fsPath)) + return; + + var dirs = Directory.GetDirectories(fsPath).OrderBy(x => x).ToList(); + var filePaths = Directory.GetFiles(fsPath).OrderBy(x => x).ToList(); + var localContents = new List(); + + foreach (var directoryPath in dirs) + { + var metaFilePath = directoryPath + ".Content"; + if (!File.Exists(metaFilePath)) + metaFilePath = null; + else + filePaths.Remove(metaFilePath); + + var name = Path.GetFileName(directoryPath); + var content = new FsXmlContent(name, string.Empty, metaFilePath, true, false, _logger); + localContents.Add(content); + } + + var metaFilePaths = filePaths + .Where(p => p.EndsWith(".Content", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + foreach (var metaFilePath in metaFilePaths) + { + var name = Path.GetFileNameWithoutExtension(metaFilePath); + var content = new FsXmlContent(name, string.Empty, metaFilePath, false, false, _logger); + localContents.Add(content); + } + + var attachmentPaths = filePaths.Except(metaFilePaths).Select(x => x.ToLowerInvariant()).ToList(); + var attachmentNames = localContents.SelectMany(x => x.GetPreprocessedAttachmentNames()); + foreach (var attachmentName in attachmentNames) + attachmentPaths.Remove(Path.Combine(fsPath, attachmentName).ToLowerInvariant()); + + foreach (var attachmentPath in attachmentPaths) + { + localContents.Add(new FsXmlContent(Path.GetFileName(attachmentPath), string.Empty, null, false, false, _logger)); + } + + EstimatedCount += localContents.Count; + + foreach (var subDir in dirs) + GetContentCount(subDir); + } + } +} diff --git a/src/SenseNet.IO/Implementations/README_FsXmlReader.md b/src/SenseNet.IO/Implementations/README_FsXmlReader.md new file mode 100644 index 0000000..0d45e4a --- /dev/null +++ b/src/SenseNet.IO/Implementations/README_FsXmlReader.md @@ -0,0 +1,153 @@ +# FsXmlReader - SenseNet 6 XML Export Reader + +## Overview + +`FsXmlReader` is a filesystem reader implementation that reads old **SenseNet 6 XML-based exports** and converts them to the modern SenseNet format. + +### SenseNet 6 vs. 7+ Structure Differences + +| Feature | SenseNet 6 | SenseNet 7+ | +|---------|------------|-------------| +| **Export Format** | XML (`.Content` files) | JSON (`.Content` files) | +| **Root Structure** | `Root/Sites` | `Root/Content` | +| **System Folders** | `Root/(apps)`, `Root/Portlets`, `Root/Skins` | Removed or restructured | + +## Usage + +### Command Line Interface (CLI) + +#### Converting XML Export to JSON (Filesystem to Filesystem) + +**Basic usage:** +```bash +SenseNet.IO.CLI.exe COPYXML -source "c:\tmp\export2" -target "c:\tmp\exportuj" +``` + +**With skipped subtrees:** +```bash +SenseNet.IO.CLI.exe COPYXML -source "c:\tmp\export2" -SKIP "Root/(apps),Root/Skins,Root/Portlets" -target "c:\tmp\exportuj" +``` + +**With excluded fields (inline):** +```bash +SenseNet.IO.CLI.exe COPYXML -source "c:\tmp\export2" -EXCLUDEFIELDS "CreatedBy,ModifiedBy,CreationDate,ModificationDate" -target "c:\tmp\exportuj" +``` + +**With configuration file:** +```bash +SenseNet.IO.CLI.exe COPYXML --config appsettings.fsxmlreader.json +``` + +Example `appsettings.fsxmlreader.json`: +```json +{ + "fsXmlReader": { + "Path": "c:\\tmp\\export2", + "Skip": ["Root/(apps)", "Root/Skins", "Root/Portlets"], + "ExcludedFields": [ + "ApprovingMode", "CreatedBy", "CreationDate", "ModificationDate", + "ModifiedBy", "Actions", "Id", "Path", "Type", "Version" + ] + }, + "fsWriter": { + "Path": "c:\\tmp\\exportuj" + } +} +``` + +#### Importing XML Export to Repository + +```bash +SenseNet.IO.CLI.exe IMPORTXML -source "c:\tmp\export2" -target "https://mysite.sensenet.cloud" -APIKEY "your-api-key" +``` + +### Programmatic Usage + +```csharp +var loggerFactory = LoggerFactory.Create(builder => builder.AddDebug()); + +var excludedFields = new[] +{ + "ApprovingMode", "CreatedBy", "CreationDate", "ModificationDate", "ModifiedBy", + "Actions", "Id", "Path", "Type", "Version", "Children" +}; + +var readerArgs = Options.Create(new FsXmlReaderArgs +{ + Path = @"c:\tmp\export2", + Skip = new[] { "Root/(apps)", "Root/Skins", "Root/Portlets" }, + ExcludedFields = excludedFields +}); +var reader = new FsXmlReader(readerArgs, loggerFactory.CreateLogger()); + +var writerArgs = Options.Create(new FsWriterArgs { Path = @"c:\tmp\exportuj" }); +var writer = new FsWriter(writerArgs, loggerFactory.CreateLogger()); + +await reader.InitializeAsync(); +await writer.InitializeAsync(); + +var cancel = CancellationToken.None; +while (await reader.ReadAllAsync(Array.Empty(), cancel)) +{ + var content = reader.Content; + var relativePath = reader.RelativePath; + + if (string.IsNullOrEmpty(relativePath)) + relativePath = content.Name; + + await writer.WriteAsync(relativePath, content, cancel); +} +``` + +## Field Filtering + +When migrating from SenseNet 6 to 7+, certain fields should be excluded: + +### Excluded Import Fields +- System audit fields: `CreatedBy`, `CreationDate`, `ModificationDate`, `ModifiedBy` +- Versioning fields: `VersionCreatedBy`, `VersionCreationDate`, `VersionModificationDate`, `VersionModifiedBy` +- Deprecated fields: `ApprovingMode`, `InheritableApprovingMode`, `InheritableVersioningMode`, `IsRateable`, `IsTaggable`, `TrashDisabled` + +### Excluded Export Fields +- Computed/runtime fields: `Actions`, `Children`, `Path`, `Type`, `Version`, `Locked`, `Icon`, etc. +- Internal IDs: `Id`, `ParentId`, `OwnerId`, `CreatedById`, `ModifiedById` + +## Path Transformations + +The reader automatically handles path transformations: + +- `Root/Sites` → `Root/Content` +- Other mappings can be added as needed + +## XML Format + +Old SenseNet 6 `.Content` files use this XML structure: + +```xml + + + GenericContent + myfile + + My File + This is a description + + + + + + +``` + +## Implementation Details + +- **FsXmlContent**: Parses XML `.Content` files and implements `IContent` interface +- **FsXmlReader**: Traverses filesystem and reads XML metadata, implements `IFilesystemReader` +- **FsXmlReaderArgs**: Configuration options (path, skip list) + +## Testing + +See `FsXmlReaderTests.cs` for comprehensive test examples including: +- Programmatic conversion +- CLI argument parsing +- Field filtering and path transformation