@@ -17,37 +17,40 @@ struct MergeAction: Action {
17
17
var landingPageCatalog : URL ?
18
18
var outputURL : URL
19
19
var fileManager : FileManagerProtocol
20
-
20
+
21
21
mutating func perform( logHandle: LogHandle ) throws -> ActionResult {
22
22
guard let firstArchive = archives. first else {
23
23
// A validation warning should have already been raised in `Docc/Merge/InputAndOutputOptions/validate()`.
24
24
return ActionResult ( didEncounterError: true , outputs: [ ] )
25
25
}
26
26
27
- try ? fileManager . removeItem ( at : outputURL )
28
- try fileManager . copyItem ( at : firstArchive , to : outputURL )
27
+ try validateThatOutputIsEmpty ( )
28
+ try validateThatArchivesHaveDisjointData ( )
29
29
30
+ let targetURL = try Self . createUniqueDirectory ( inside: fileManager. temporaryDirectory, template: firstArchive, fileManager: fileManager)
31
+ defer {
32
+ try ? fileManager. removeItem ( at: targetURL)
33
+ }
34
+
30
35
// TODO: Merge the LMDB navigator index
31
36
32
- let jsonIndexURL = outputURL . appendingPathComponent ( " index/index.json " )
37
+ let jsonIndexURL = targetURL . appendingPathComponent ( " index/index.json " )
33
38
guard let jsonIndexData = fileManager. contents ( atPath: jsonIndexURL. path) else {
34
- // TODO: Error
35
- return ActionResult ( didEncounterError: true , outputs: [ ] )
39
+ throw CocoaError . error ( . fileReadNoSuchFile, userInfo: [ NSFilePathErrorKey: jsonIndexURL. path] )
36
40
}
37
41
var combinedJSONIndex = try JSONDecoder ( ) . decode ( RenderIndex . self, from: jsonIndexData)
38
42
39
43
for archive in archives. dropFirst ( ) {
40
44
for directoryToCopy in [ " data/documentation " , " data/tutorials " , " documentation " , " tutorials " , " images " , " videos " , " downloads " ] {
41
45
let fromDirectory = archive. appendingPathComponent ( directoryToCopy, isDirectory: true )
42
- let toDirectory = outputURL . appendingPathComponent ( directoryToCopy, isDirectory: true )
46
+ let toDirectory = targetURL . appendingPathComponent ( directoryToCopy, isDirectory: true )
43
47
44
48
for from in ( try ? fileManager. contentsOfDirectory ( at: fromDirectory, includingPropertiesForKeys: nil , options: . skipsHiddenFiles) ) ?? [ ] {
45
49
try fileManager. copyItem ( at: from, to: toDirectory. appendingPathComponent ( from. lastPathComponent) )
46
50
}
47
51
}
48
52
guard let jsonIndexData = fileManager. contents ( atPath: archive. appendingPathComponent ( " index/index.json " ) . path) else {
49
- // TODO: Error
50
- return ActionResult ( didEncounterError: true , outputs: [ ] )
53
+ throw CocoaError . error ( . fileReadNoSuchFile, userInfo: [ NSFilePathErrorKey: archive. appendingPathComponent ( " index/index.json " ) . path] )
51
54
}
52
55
let renderIndex = try JSONDecoder ( ) . decode ( RenderIndex . self, from: jsonIndexData)
53
56
@@ -60,6 +63,102 @@ struct MergeAction: Action {
60
63
61
64
// TODO: Inactivate external links outside the merged archives
62
65
66
+ try Self . moveOutput ( from: targetURL, to: outputURL, fileManager: fileManager)
67
+
63
68
return ActionResult ( didEncounterError: false , outputs: [ outputURL] )
64
69
}
70
+
71
+ private func validateThatArchivesHaveDisjointData( ) throws {
72
+ // Check that the archives don't have overlapping data
73
+ typealias ArchivesByDirectoryName = [ String : Set < String > ]
74
+
75
+ var archivesByTopLevelDocumentationDirectory = ArchivesByDirectoryName ( )
76
+ var archivesByTopLevelTutorialDirectory = ArchivesByDirectoryName ( )
77
+
78
+ // Gather all the top level /data/documentation and /data/tutorials directories to ensure that the different archives don't have overlapping data
79
+ for archive in archives {
80
+ for topLevelDocumentation in ( try ? fileManager. contentsOfDirectory ( at: archive. appendingPathComponent ( " data/documentation " , isDirectory: true ) , includingPropertiesForKeys: nil , options: . skipsHiddenFiles) ) ?? [ ] {
81
+ archivesByTopLevelDocumentationDirectory [ topLevelDocumentation. deletingPathExtension ( ) . lastPathComponent, default: [ ] ] . insert ( archive. lastPathComponent)
82
+ }
83
+ for topLevelDocumentation in ( try ? fileManager. contentsOfDirectory ( at: archive. appendingPathComponent ( " data/tutorials " , isDirectory: true ) , includingPropertiesForKeys: nil , options: . skipsHiddenFiles) ) ?? [ ] {
84
+ archivesByTopLevelTutorialDirectory [ topLevelDocumentation. deletingPathExtension ( ) . lastPathComponent, default: [ ] ] . insert ( archive. lastPathComponent)
85
+ }
86
+ }
87
+
88
+ // Only data directories found in a multiple archives is a problem
89
+ archivesByTopLevelDocumentationDirectory = archivesByTopLevelDocumentationDirectory. filter ( { $0. value. count > 1 } )
90
+ archivesByTopLevelTutorialDirectory = archivesByTopLevelTutorialDirectory. filter ( { $0. value. count > 1 } )
91
+
92
+ guard archivesByTopLevelDocumentationDirectory. isEmpty, archivesByTopLevelTutorialDirectory. isEmpty else {
93
+ struct OverlappingDataError : DescribedError {
94
+ var archivesByDocumentationData : ArchivesByDirectoryName
95
+ var archivesByTutorialData : ArchivesByDirectoryName
96
+
97
+ var errorDescription : String {
98
+ var message = " Input archives contain overlapping data "
99
+ if let overlappingDocumentationDescription = overlapDescription ( archivesByData: archivesByDocumentationData, pathComponentName: " documentation " ) {
100
+ message. append ( overlappingDocumentationDescription)
101
+ }
102
+ if let overlappingDocumentationDescription = overlapDescription ( archivesByData: archivesByTutorialData, pathComponentName: " tutorials " ) {
103
+ message. append ( overlappingDocumentationDescription)
104
+ }
105
+ return message
106
+ }
107
+
108
+ private func overlapDescription( archivesByData: ArchivesByDirectoryName , pathComponentName: String ) -> String ? {
109
+ guard !archivesByData. isEmpty else {
110
+ return nil
111
+ }
112
+
113
+ var description = " \n "
114
+ for (topLevelDirectory, archives) in archivesByData. mapValues ( { $0. sorted ( ) } ) {
115
+ if archives. count == 2 {
116
+ description. append ( " \n ' \( archives. first!) ' and ' \( archives. last!) ' both " )
117
+ } else {
118
+ description. append ( " \n \( archives. dropLast ( ) . map ( { " ' \( $0) ' " } ) . joined ( separator: " , " ) ) , and ' \( archives. last!) ' all " )
119
+ }
120
+ description. append ( " contain '/data/ \( pathComponentName) / \( topLevelDirectory) /' " )
121
+ }
122
+ return description
123
+ }
124
+ }
125
+
126
+ throw OverlappingDataError (
127
+ archivesByDocumentationData: archivesByTopLevelDocumentationDirectory,
128
+ archivesByTutorialData: archivesByTopLevelTutorialDirectory
129
+ )
130
+ }
131
+ }
132
+
133
+ private func validateThatOutputIsEmpty( ) throws {
134
+ guard fileManager. directoryExists ( atPath: outputURL. path) else {
135
+ return
136
+ }
137
+
138
+ let existingContents = ( try ? fileManager. contentsOfDirectory ( at: outputURL, includingPropertiesForKeys: nil , options: . skipsHiddenFiles) ) ?? [ ]
139
+ guard existingContents. isEmpty else {
140
+ struct NonEmptyOutputError : DescribedError {
141
+ var existingContents : [ URL ]
142
+ var fileManager : FileManagerProtocol
143
+
144
+ var errorDescription : String {
145
+ var contentDescriptions = existingContents
146
+ . sorted ( by: { $0. lastPathComponent < $1. lastPathComponent } )
147
+ . prefix ( 6 )
148
+ . map { " - \( $0. lastPathComponent) \( fileManager. directoryExists ( atPath: $0. path) ? " / " : " " ) " }
149
+
150
+ if existingContents. count > 6 {
151
+ contentDescriptions [ 5 ] = " and \( existingContents. count - 5 ) more files and directories "
152
+ }
153
+
154
+ return """
155
+ Output directory is not empty. It contains:
156
+ \( contentDescriptions. joined ( separator: " \n " ) )
157
+ """
158
+ }
159
+ }
160
+
161
+ throw NonEmptyOutputError ( existingContents: existingContents, fileManager: fileManager)
162
+ }
163
+ }
65
164
}
0 commit comments