├── LICENSE
├── LICENSE-swift-uniyaml
├── PhotosExporter.xcodeproj
├── project.pbxproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ ├── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
│ └── xcuserdata
│ │ └── andreas.xcuserdatad
│ │ ├── IDEFindNavigatorScopes.plist
│ │ ├── UserInterfaceState.xcuserstate
│ │ └── xcdebugger
│ │ └── Expressions.xcexplist
└── xcuserdata
│ └── andreas.xcuserdatad
│ ├── xcdebugger
│ └── Breakpoints_v2.xcbkptlist
│ └── xcschemes
│ ├── PhotosExporter.xcscheme
│ └── xcschememanagement.plist
├── PhotosExporter
├── AppDelegate.swift
├── Info.plist
├── PhotosExporter.entitlements
├── exporter
│ ├── FlatFolderDescriptor.swift
│ ├── IncrementalPhotosExporter.swift
│ ├── MediaLibUtil.swift
│ ├── PhotosExporter.swift
│ ├── PhotosExporterFactory.swift
│ └── SnapshotPhotosExporter.swift
├── photolibrary-access
│ ├── PhotoLibraryUtil.swift
│ ├── PhotosMetadataReader.swift
│ └── PhotosSqliteDAO.swift
├── photosmodel
│ ├── MediaObject.swift
│ ├── PhotoCollection.swift
│ ├── PhotoObject.swift
│ └── PhotosMetadata.swift
├── preferences
│ ├── Decoder.swift
│ ├── Encoder.swift
│ ├── PreferencesReader.swift
│ ├── YAML.swift
│ └── model
│ │ ├── FileSystemExportPlan.swift
│ │ ├── GooglePhotosExportPlan.swift
│ │ ├── MediaObjectFilter.swift
│ │ ├── Plan.swift
│ │ └── Preferences.swift
├── ui
│ ├── DataModel.xcdatamodeld
│ │ └── PreferencesDataModel.xcdatamodel
│ │ │ └── contents
│ ├── MainMenu.xib
│ ├── StatusMenuController.swift
│ ├── assets
│ │ └── Assets.xcassets
│ │ │ ├── Contents.json
│ │ │ └── statusBarIcon.imageset
│ │ │ ├── Contents.json
│ │ │ ├── sync_black.png
│ │ │ └── sync_black@2x.png
│ └── preferences
│ │ ├── GeneralSettingsView.xib
│ │ ├── GeneralSettingsViewController.swift
│ │ ├── PlansView.xib
│ │ ├── PlansViewController.swift
│ │ ├── PreferencesWindow.xib
│ │ └── PreferencesWindowController.swift
└── util
│ ├── Errors.swift
│ ├── Logger.swift
│ ├── StopWatch.swift
│ └── String+extensions.swift
├── PreferencesReaderTest
├── Info.plist
└── PreferencesReaderTest.swift
├── Readme.md
└── doc
└── filesystem-structure.png
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Andreas Bentele
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/LICENSE-swift-uniyaml:
--------------------------------------------------------------------------------
1 | swift-uniyaml used for decoding YAML, licensed under the terms of the
2 | Apache License 2.0.
3 |
4 | https://github.com/seznam/swift-uniyaml
5 |
6 | ---------------------------------------------------------------------
7 |
8 |
9 | Apache License
10 | Version 2.0, January 2004
11 | http://www.apache.org/licenses/
12 |
13 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
14 |
15 | 1. Definitions.
16 |
17 | "License" shall mean the terms and conditions for use, reproduction,
18 | and distribution as defined by Sections 1 through 9 of this document.
19 |
20 | "Licensor" shall mean the copyright owner or entity authorized by
21 | the copyright owner that is granting the License.
22 |
23 | "Legal Entity" shall mean the union of the acting entity and all
24 | other entities that control, are controlled by, or are under common
25 | control with that entity. For the purposes of this definition,
26 | "control" means (i) the power, direct or indirect, to cause the
27 | direction or management of such entity, whether by contract or
28 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
29 | outstanding shares, or (iii) beneficial ownership of such entity.
30 |
31 | "You" (or "Your") shall mean an individual or Legal Entity
32 | exercising permissions granted by this License.
33 |
34 | "Source" form shall mean the preferred form for making modifications,
35 | including but not limited to software source code, documentation
36 | source, and configuration files.
37 |
38 | "Object" form shall mean any form resulting from mechanical
39 | transformation or translation of a Source form, including but
40 | not limited to compiled object code, generated documentation,
41 | and conversions to other media types.
42 |
43 | "Work" shall mean the work of authorship, whether in Source or
44 | Object form, made available under the License, as indicated by a
45 | copyright notice that is included in or attached to the work
46 | (an example is provided in the Appendix below).
47 |
48 | "Derivative Works" shall mean any work, whether in Source or Object
49 | form, that is based on (or derived from) the Work and for which the
50 | editorial revisions, annotations, elaborations, or other modifications
51 | represent, as a whole, an original work of authorship. For the purposes
52 | of this License, Derivative Works shall not include works that remain
53 | separable from, or merely link (or bind by name) to the interfaces of,
54 | the Work and Derivative Works thereof.
55 |
56 | "Contribution" shall mean any work of authorship, including
57 | the original version of the Work and any modifications or additions
58 | to that Work or Derivative Works thereof, that is intentionally
59 | submitted to Licensor for inclusion in the Work by the copyright owner
60 | or by an individual or Legal Entity authorized to submit on behalf of
61 | the copyright owner. For the purposes of this definition, "submitted"
62 | means any form of electronic, verbal, or written communication sent
63 | to the Licensor or its representatives, including but not limited to
64 | communication on electronic mailing lists, source code control systems,
65 | and issue tracking systems that are managed by, or on behalf of, the
66 | Licensor for the purpose of discussing and improving the Work, but
67 | excluding communication that is conspicuously marked or otherwise
68 | designated in writing by the copyright owner as "Not a Contribution."
69 |
70 | "Contributor" shall mean Licensor and any individual or Legal Entity
71 | on behalf of whom a Contribution has been received by Licensor and
72 | subsequently incorporated within the Work.
73 |
74 | 2. Grant of Copyright License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | copyright license to reproduce, prepare Derivative Works of,
78 | publicly display, publicly perform, sublicense, and distribute the
79 | Work and such Derivative Works in Source or Object form.
80 |
81 | 3. Grant of Patent License. Subject to the terms and conditions of
82 | this License, each Contributor hereby grants to You a perpetual,
83 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
84 | (except as stated in this section) patent license to make, have made,
85 | use, offer to sell, sell, import, and otherwise transfer the Work,
86 | where such license applies only to those patent claims licensable
87 | by such Contributor that are necessarily infringed by their
88 | Contribution(s) alone or by combination of their Contribution(s)
89 | with the Work to which such Contribution(s) was submitted. If You
90 | institute patent litigation against any entity (including a
91 | cross-claim or counterclaim in a lawsuit) alleging that the Work
92 | or a Contribution incorporated within the Work constitutes direct
93 | or contributory patent infringement, then any patent licenses
94 | granted to You under this License for that Work shall terminate
95 | as of the date such litigation is filed.
96 |
97 | 4. Redistribution. You may reproduce and distribute copies of the
98 | Work or Derivative Works thereof in any medium, with or without
99 | modifications, and in Source or Object form, provided that You
100 | meet the following conditions:
101 |
102 | (a) You must give any other recipients of the Work or
103 | Derivative Works a copy of this License; and
104 |
105 | (b) You must cause any modified files to carry prominent notices
106 | stating that You changed the files; and
107 |
108 | (c) You must retain, in the Source form of any Derivative Works
109 | that You distribute, all copyright, patent, trademark, and
110 | attribution notices from the Source form of the Work,
111 | excluding those notices that do not pertain to any part of
112 | the Derivative Works; and
113 |
114 | (d) If the Work includes a "NOTICE" text file as part of its
115 | distribution, then any Derivative Works that You distribute must
116 | include a readable copy of the attribution notices contained
117 | within such NOTICE file, excluding those notices that do not
118 | pertain to any part of the Derivative Works, in at least one
119 | of the following places: within a NOTICE text file distributed
120 | as part of the Derivative Works; within the Source form or
121 | documentation, if provided along with the Derivative Works; or,
122 | within a display generated by the Derivative Works, if and
123 | wherever such third-party notices normally appear. The contents
124 | of the NOTICE file are for informational purposes only and
125 | do not modify the License. You may add Your own attribution
126 | notices within Derivative Works that You distribute, alongside
127 | or as an addendum to the NOTICE text from the Work, provided
128 | that such additional attribution notices cannot be construed
129 | as modifying the License.
130 |
131 | You may add Your own copyright statement to Your modifications and
132 | may provide additional or different license terms and conditions
133 | for use, reproduction, or distribution of Your modifications, or
134 | for any such Derivative Works as a whole, provided Your use,
135 | reproduction, and distribution of the Work otherwise complies with
136 | the conditions stated in this License.
137 |
138 | 5. Submission of Contributions. Unless You explicitly state otherwise,
139 | any Contribution intentionally submitted for inclusion in the Work
140 | by You to the Licensor shall be under the terms and conditions of
141 | this License, without any additional terms or conditions.
142 | Notwithstanding the above, nothing herein shall supersede or modify
143 | the terms of any separate license agreement you may have executed
144 | with Licensor regarding such Contributions.
145 |
146 | 6. Trademarks. This License does not grant permission to use the trade
147 | names, trademarks, service marks, or product names of the Licensor,
148 | except as required for reasonable and customary use in describing the
149 | origin of the Work and reproducing the content of the NOTICE file.
150 |
151 | 7. Disclaimer of Warranty. Unless required by applicable law or
152 | agreed to in writing, Licensor provides the Work (and each
153 | Contributor provides its Contributions) on an "AS IS" BASIS,
154 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
155 | implied, including, without limitation, any warranties or conditions
156 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
157 | PARTICULAR PURPOSE. You are solely responsible for determining the
158 | appropriateness of using or redistributing the Work and assume any
159 | risks associated with Your exercise of permissions under this License.
160 |
161 | 8. Limitation of Liability. In no event and under no legal theory,
162 | whether in tort (including negligence), contract, or otherwise,
163 | unless required by applicable law (such as deliberate and grossly
164 | negligent acts) or agreed to in writing, shall any Contributor be
165 | liable to You for damages, including any direct, indirect, special,
166 | incidental, or consequential damages of any character arising as a
167 | result of this License or out of the use or inability to use the
168 | Work (including but not limited to damages for loss of goodwill,
169 | work stoppage, computer failure or malfunction, or any and all
170 | other commercial damages or losses), even if such Contributor
171 | has been advised of the possibility of such damages.
172 |
173 | 9. Accepting Warranty or Additional Liability. While redistributing
174 | the Work or Derivative Works thereof, You may choose to offer,
175 | and charge a fee for, acceptance of support, warranty, indemnity,
176 | or other liability obligations and/or rights consistent with this
177 | License. However, in accepting such obligations, You may act only
178 | on Your own behalf and on Your sole responsibility, not on behalf
179 | of any other Contributor, and only if You agree to indemnify,
180 | defend, and hold each Contributor harmless for any liability
181 | incurred by, or claims asserted against, such Contributor by reason
182 | of your accepting any such warranty or additional liability.
183 |
184 | END OF TERMS AND CONDITIONS
185 |
186 | APPENDIX: How to apply the Apache License to your work.
187 |
188 | To apply the Apache License to your work, attach the following
189 | boilerplate notice, with the fields enclosed by brackets "[]"
190 | replaced with your own identifying information. (Don't include
191 | the brackets!) The text should be enclosed in the appropriate
192 | comment syntax for the file format. We also recommend that a
193 | file or class name and description of purpose be included on the
194 | same "printed page" as the copyright notice for easier
195 | identification within third-party archives.
196 |
197 | Copyright [yyyy] [name of copyright owner]
198 |
199 | Licensed under the Apache License, Version 2.0 (the "License");
200 | you may not use this file except in compliance with the License.
201 | You may obtain a copy of the License at
202 |
203 | http://www.apache.org/licenses/LICENSE-2.0
204 |
205 | Unless required by applicable law or agreed to in writing, software
206 | distributed under the License is distributed on an "AS IS" BASIS,
207 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
208 | See the License for the specific language governing permissions and
209 | limitations under the License.
210 |
211 |
--------------------------------------------------------------------------------
/PhotosExporter.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/PhotosExporter.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/PhotosExporter.xcodeproj/project.xcworkspace/xcuserdata/andreas.xcuserdatad/IDEFindNavigatorScopes.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/PhotosExporter.xcodeproj/project.xcworkspace/xcuserdata/andreas.xcuserdatad/UserInterfaceState.xcuserstate:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/abentele/PhotosExporter/3722dfdea4dc8d1427b7901a096a5508de7881ad/PhotosExporter.xcodeproj/project.xcworkspace/xcuserdata/andreas.xcuserdatad/UserInterfaceState.xcuserstate
--------------------------------------------------------------------------------
/PhotosExporter.xcodeproj/project.xcworkspace/xcuserdata/andreas.xcuserdatad/xcdebugger/Expressions.xcexplist:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
7 |
8 |
10 |
11 |
12 |
13 |
15 |
16 |
18 |
19 |
21 |
22 |
23 |
24 |
26 |
27 |
29 |
30 |
31 |
32 |
34 |
35 |
37 |
38 |
40 |
41 |
43 |
44 |
45 |
46 |
48 |
49 |
51 |
52 |
54 |
55 |
57 |
58 |
60 |
61 |
62 |
63 |
65 |
66 |
68 |
69 |
70 |
71 |
73 |
74 |
76 |
77 |
79 |
80 |
82 |
83 |
84 |
85 |
87 |
88 |
90 |
91 |
92 |
93 |
95 |
96 |
98 |
99 |
100 |
101 |
103 |
104 |
106 |
107 |
109 |
110 |
111 |
112 |
114 |
115 |
117 |
118 |
120 |
121 |
122 |
123 |
125 |
126 |
127 |
128 |
--------------------------------------------------------------------------------
/PhotosExporter.xcodeproj/xcuserdata/andreas.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
9 |
21 |
22 |
23 |
25 |
37 |
38 |
39 |
41 |
53 |
54 |
55 |
57 |
69 |
70 |
71 |
73 |
85 |
86 |
87 |
89 |
101 |
102 |
103 |
105 |
117 |
118 |
119 |
121 |
133 |
134 |
135 |
137 |
149 |
150 |
151 |
153 |
165 |
166 |
167 |
169 |
181 |
182 |
183 |
184 |
185 |
--------------------------------------------------------------------------------
/PhotosExporter.xcodeproj/xcuserdata/andreas.xcuserdatad/xcschemes/PhotosExporter.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
37 |
38 |
39 |
40 |
42 |
48 |
49 |
50 |
51 |
52 |
62 |
64 |
70 |
71 |
72 |
73 |
79 |
81 |
87 |
88 |
89 |
90 |
92 |
93 |
96 |
97 |
98 |
--------------------------------------------------------------------------------
/PhotosExporter.xcodeproj/xcuserdata/andreas.xcuserdatad/xcschemes/xcschememanagement.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SchemeUserState
6 |
7 | PhotosExporter.xcscheme
8 |
9 | orderHint
10 | 0
11 |
12 |
13 | SuppressBuildableAutocreation
14 |
15 | E1E43AF1217CDABA000E76DF
16 |
17 | primary
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/PhotosExporter/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 21.10.18.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Cocoa
10 | import Photos
11 |
12 | @NSApplicationMain
13 | class AppDelegate: NSObject, NSApplicationDelegate {
14 |
15 | private let logger = Logger(loggerName: "AppDelegate", logLevel: .info)
16 |
17 | @IBOutlet weak var statusMenuController: StatusMenuController!
18 |
19 | func applicationDidFinishLaunching(_ aNotification: Notification) {
20 | var preferences: Preferences
21 | do {
22 | preferences = try PreferencesReader.readPreferencesFile()
23 | } catch {
24 | print("Error reading preferences: \(error)")
25 | return;
26 | }
27 | logger.info("Read preferences file; content:\n\(preferences.toYaml())")
28 |
29 | PHPhotoLibrary.requestAuthorization({(status: PHAuthorizationStatus) in
30 | if (status != PHAuthorizationStatus.authorized) {
31 | self.logger.warn("Not authorized to access the photo library. Abort.")
32 | return;
33 | }
34 |
35 | self.executeAllPlans(preferences: preferences)
36 |
37 | // self.statusMenuController.updateMenu(preferences: preferences)
38 |
39 | //PreferencesReader.writePreferencesFile(preferences: preferences)
40 |
41 |
42 | DispatchQueue.main.sync {
43 | NSApp.terminate(self)
44 | }
45 | });
46 | }
47 |
48 | func executeAllPlans(preferences: Preferences) {
49 |
50 | let photosMetadataReader = PhotosMetadataReader()
51 | photosMetadataReader.readMetadata(completion: {(photosMetadata: PhotosMetadata) in
52 |
53 | photosMetadata.rootCollection.printYaml(indent: 0)
54 |
55 | for plan in preferences.plans {
56 | // separator for multiple export jobs
57 | self.logger.info("")
58 | self.logger.info("=====================================================================")
59 | self.logger.info("")
60 |
61 | if plan.enabled {
62 | self.logger.info("Start export using plan:\n\(plan.toYaml(indent: 10))")
63 | do {
64 | let photosExporter = try PhotosExporterFactory.createPhotosExporter(plan: plan)
65 | photosExporter.exportPhotos(photosMetadata: photosMetadata)
66 | } catch {
67 | print("Error exporting photos for plan: \(String(describing: plan.name)); error: \(error)")
68 | }
69 | } else {
70 | self.logger.info("Ignore disabled plan: \(String(describing: plan.name))")
71 | }
72 | }
73 | });
74 | }
75 |
76 | func applicationWillTerminate(_ aNotification: Notification) {
77 |
78 | }
79 |
80 | }
81 |
82 |
--------------------------------------------------------------------------------
/PhotosExporter/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIconFile
10 |
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | APPL
19 | CFBundleShortVersionString
20 | 1.0
21 | CFBundleVersion
22 | 1
23 | LSApplicationCategoryType
24 | public.app-category.photography
25 | LSMinimumSystemVersion
26 | $(MACOSX_DEPLOYMENT_TARGET)
27 | LSUIElement
28 | 1
29 | NSHumanReadableCopyright
30 | Copyright © 2024 Andreas Bentele. All rights reserved.
31 | NSMainNibFile
32 | MainMenu
33 | NSPhotoLibraryUsageDescription
34 | The app want's to access the fotos library to be able to export it.
35 | NSPrincipalClass
36 | NSApplication
37 |
38 |
39 |
--------------------------------------------------------------------------------
/PhotosExporter/PhotosExporter.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/PhotosExporter/exporter/FlatFolderDescriptor.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FlatFolderDescriptor.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 10.03.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | class FlatFolderDescriptor {
12 | public var folderName: String
13 | public var countSubFolders: Int
14 |
15 | init(folderName: String, countSubFolders: Int) {
16 | self.folderName = folderName
17 | self.countSubFolders = countSubFolders
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/PhotosExporter/exporter/IncrementalPhotosExporter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IncrementalPhotosExporter.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 21.03.18.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /**
12 | * backup mode, like "Time Machine", which creates one folder per date and which is a real copy of the Photos Library data.
13 | */
14 | class IncrementalPhotosExporter : PhotosExporter {
15 |
16 | private var latestPath: String {
17 | return "\(targetPath)/Latest"
18 | }
19 |
20 | override func initExport() throws {
21 | try super.initExport()
22 | }
23 |
24 | override func exportFoldersFlat(photosMetadata: PhotosMetadata) throws {
25 | if exportOriginals {
26 | logger.info("export originals photos to \(inProgressPath)/\(originalsRelativePath)/\(flatRelativePath) folder")
27 |
28 | var candidatesToLinkTo: [FlatFolderDescriptor] = []
29 |
30 | if let baseExportPath = baseExportPath {
31 | candidatesToLinkTo = try candidatesToLinkTo + flatFolderIfExists("\(baseExportPath)/\(originalsRelativePath)/\(flatRelativePath)")
32 | }
33 |
34 | candidatesToLinkTo = try candidatesToLinkTo + flatFolderIfExists("\(latestPath)/\(originalsRelativePath)/\(flatRelativePath)")
35 |
36 | try exportFolderFlat(
37 | photosMetadata: photosMetadata,
38 | flatPath: "\(inProgressPath)/\(originalsRelativePath)/\(flatRelativePath)",
39 | candidatesToLinkTo: candidatesToLinkTo,
40 | version: PhotoVersion.originals)
41 | }
42 |
43 | if exportCurrent {
44 | logger.info("export current photos to \(inProgressPath)/\(currentRelativePath)/\(flatRelativePath) folder")
45 |
46 | var candidatesToLinkTo: [FlatFolderDescriptor] = []
47 |
48 | if let baseExportPath = baseExportPath {
49 | candidatesToLinkTo = try candidatesToLinkTo + flatFolderIfExists("\(baseExportPath)/\(currentRelativePath)/\(flatRelativePath)")
50 | }
51 |
52 | candidatesToLinkTo = try candidatesToLinkTo + flatFolderIfExists("\(latestPath)/\(currentRelativePath)/\(flatRelativePath)")
53 |
54 | if exportOriginals {
55 | candidatesToLinkTo = try candidatesToLinkTo + flatFolderIfExists("\(inProgressPath)/\(originalsRelativePath)/\(flatRelativePath)")
56 | }
57 |
58 | try exportFolderFlat(
59 | photosMetadata: photosMetadata,
60 | flatPath: "\(inProgressPath)/\(currentRelativePath)/\(flatRelativePath)",
61 | candidatesToLinkTo: candidatesToLinkTo,
62 | version: PhotoVersion.current)
63 | }
64 | if exportDerived {
65 | logger.info("export derived photos to \(inProgressPath)/\(derivedRelativePath)/\(flatRelativePath) folder")
66 |
67 | var candidatesToLinkTo: [FlatFolderDescriptor] = []
68 |
69 | if let baseExportPath = baseExportPath {
70 | candidatesToLinkTo = try candidatesToLinkTo + flatFolderIfExists("\(baseExportPath)/\(derivedRelativePath)/\(flatRelativePath)")
71 | }
72 |
73 | candidatesToLinkTo = try candidatesToLinkTo + flatFolderIfExists("\(latestPath)/\(derivedRelativePath)/\(flatRelativePath)")
74 |
75 | if exportOriginals {
76 | candidatesToLinkTo = try candidatesToLinkTo + flatFolderIfExists("\(inProgressPath)/\(originalsRelativePath)/\(flatRelativePath)")
77 | }
78 |
79 | try exportFolderFlat(
80 | photosMetadata: photosMetadata,
81 | flatPath: "\(inProgressPath)/\(derivedRelativePath)/\(flatRelativePath)",
82 | candidatesToLinkTo: candidatesToLinkTo,
83 | version: PhotoVersion.derived)
84 | }
85 |
86 | }
87 |
88 | /**
89 | * Finish the filesystem structures; invariant:
90 | * if no folder "InProgress" but folders with date exist, and there is a symbolic link "Latest", there was no error.
91 | */
92 | override func finishExport() throws {
93 | try super.finishExport()
94 |
95 | // remove the "Latest" symbolic link
96 | do {
97 | if fileManager.fileExists(atPath: latestPath) {
98 | try fileManager.removeItem(atPath: latestPath)
99 | }
100 | } catch {
101 | logger.error("Error removing link 'Latest': \(error) => abort export")
102 | throw error
103 | }
104 |
105 | // rename "InProgress" folder to export date
106 | let dateFormatter = DateFormatter()
107 | dateFormatter.dateFormat = "yyyy-MM-dd HH-mm-ss"
108 | let formattedDate = dateFormatter.string(from: Date())
109 | let newBackupPath = "\(targetPath)/\(formattedDate)"
110 | do {
111 | try fileManager.moveItem(atPath: inProgressPath, toPath: newBackupPath)
112 | } catch {
113 | logger.error("Error renaming InProgress folder: \(error) => abort export")
114 | throw error
115 | }
116 |
117 | // create new "Latest" symbolic link
118 | do {
119 | try fileManager.createSymbolicLink(atPath: latestPath, withDestinationPath: newBackupPath)
120 | } catch {
121 | logger.error("Error recreating link 'Latest': \(error) => abort export")
122 | throw error
123 | }
124 | }
125 |
126 | }
127 |
--------------------------------------------------------------------------------
/PhotosExporter/exporter/MediaLibUtil.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MediaLibUtil.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 23.09.18.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import MediaLibrary
11 |
12 | /**
13 | * Checks if a specific keyword is assigned to the mediaObject
14 | */
15 | func hasKeyword(mediaObject: MediaObject, keyword: String) -> Bool {
16 | return mediaObject.keywords.contains(keyword)
17 | }
18 |
19 |
--------------------------------------------------------------------------------
/PhotosExporter/exporter/PhotosExporterFactory.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PhotosExporterFactory.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 10.06.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import MediaLibrary
11 |
12 | enum PhotosExporterFactoryError: Error {
13 | case unknownType
14 | }
15 |
16 |
17 | class PhotosExporterFactory {
18 | static func createPhotosExporter(plan: Plan) throws -> PhotosExporter {
19 | // define which media groups should be exported
20 | let exportMediaGroupFilter = { (photoCollection: PhotoCollection) -> Bool in
21 | // export all media groups
22 | return true
23 | }
24 |
25 | let exportPhotosOfMediaGroupFilter = { (photoCollection: PhotoCollection) -> Bool in
26 | // return plan.mediaObjectFilter.mediaGroupTypeWhiteList.contains(photoCollection.typeIdentifier) &&
27 | // !("com.apple.Photos.FacesAlbum" == mediaGroup.typeIdentifier && mediaGroup.parent?.typeIdentifier == "com.apple.Photos.AlbumsGroup") &&
28 | // !("com.apple.Photos.PlacesAlbum" == mediaGroup.typeIdentifier && mediaGroup.parent?.typeIdentifier == "com.apple.Photos.RootGroup")
29 | return true
30 | }
31 |
32 | let exportMediaObjectFilter: ((MediaObject) -> Bool) = { (mediaObject) -> Bool in
33 | var result = true
34 | if plan.mediaObjectFilter.keywordWhiteList.count > 0 {
35 | result = false
36 | for keyword in plan.mediaObjectFilter.keywordWhiteList {
37 | if (hasKeyword(mediaObject: mediaObject, keyword: keyword)) {
38 | result = true
39 | break
40 | }
41 | }
42 | }
43 | if plan.mediaObjectFilter.keywordBlackList.count > 0 {
44 | for keyword in plan.mediaObjectFilter.keywordBlackList {
45 | if (hasKeyword(mediaObject: mediaObject, keyword: keyword)) {
46 | result = false
47 | break
48 | }
49 | }
50 | }
51 | return result
52 | }
53 |
54 | var photosExporter: PhotosExporter
55 |
56 | if let plan = plan as? IncrementalFileSystemExportPlan {
57 | // TODO existence of targetFolder must be validated
58 | let incrementalPhotosExporter = IncrementalPhotosExporter(targetPath: plan.targetFolder!)
59 | photosExporter = incrementalPhotosExporter
60 | } else if let plan = plan as? SnapshotFileSystemExportPlan {
61 | // TODO existence of targetFolder must be validated
62 | let snapshotPhotosExporter = SnapshotPhotosExporter(targetPath: plan.targetFolder!)
63 | if let deleteFlatPath = plan.deleteFlatPath {
64 | snapshotPhotosExporter.deleteFlatPath = deleteFlatPath
65 | }
66 | photosExporter = snapshotPhotosExporter
67 | } else {
68 | throw PhotosExporterFactoryError.unknownType
69 | }
70 |
71 | if let plan = plan as? FileSystemExportPlan {
72 | if let baseExportPath = plan.baseExportPath {
73 | photosExporter.baseExportPath = baseExportPath
74 | }
75 | }
76 | photosExporter.exportMediaGroupFilter = exportMediaGroupFilter
77 | photosExporter.exportPhotosOfMediaGroupFilter = exportPhotosOfMediaGroupFilter
78 | photosExporter.exportMediaObjectFilter = exportMediaObjectFilter
79 | if let exportDerived = plan.exportDerived {
80 | photosExporter.exportDerived = exportDerived
81 | }
82 | if let exportCurrent = plan.exportCurrent {
83 | photosExporter.exportCurrent = exportCurrent
84 | }
85 | if let exportOriginals = plan.exportOriginals {
86 | photosExporter.exportOriginals = exportOriginals
87 | }
88 |
89 | return photosExporter
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/PhotosExporter/exporter/SnapshotPhotosExporter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SnapshotPhotosExporter.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 21.03.18.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /**
12 | * simple export mode which creates one snapshot folder, with hard links to the original files to save disk space (only if the target directory is in the same file system as the Photos Library)
13 | */
14 | class SnapshotPhotosExporter : PhotosExporter {
15 |
16 | private var subTargetPath: String {
17 | return "\(targetPath)/Snapshot"
18 | }
19 |
20 | public var deleteFlatPath = true
21 |
22 | override func exportFoldersFlat(photosMetadata: PhotosMetadata) throws {
23 |
24 | if exportOriginals {
25 | logger.info("export originals photos to \(inProgressPath)/\(originalsRelativePath)/\(flatRelativePath) folder")
26 |
27 | var candidatesToLinkTo: [FlatFolderDescriptor] = []
28 |
29 | if let baseExportPath = baseExportPath {
30 | candidatesToLinkTo = try candidatesToLinkTo + flatFolderIfExists("\(baseExportPath)/\(originalsRelativePath)/\(flatRelativePath)")
31 | }
32 |
33 | try exportFolderFlat(
34 | photosMetadata: photosMetadata,
35 | flatPath: "\(inProgressPath)/\(originalsRelativePath)/\(flatRelativePath)",
36 | candidatesToLinkTo: candidatesToLinkTo,
37 | version: PhotoVersion.originals)
38 |
39 | }
40 | if exportCurrent {
41 | logger.info("export current photos to \(inProgressPath)/\(currentRelativePath)/\(flatRelativePath) folder")
42 |
43 | var candidatesToLinkTo: [FlatFolderDescriptor] = []
44 |
45 | if let baseExportPath = baseExportPath {
46 | candidatesToLinkTo = try candidatesToLinkTo + flatFolderIfExists("\(baseExportPath)/\(currentRelativePath)/\(flatRelativePath)")
47 | }
48 |
49 | if exportOriginals {
50 | candidatesToLinkTo = try candidatesToLinkTo + flatFolderIfExists("\(inProgressPath)/\(originalsRelativePath)/\(flatRelativePath)")
51 | }
52 |
53 | try exportFolderFlat(
54 | photosMetadata: photosMetadata,
55 | flatPath: "\(inProgressPath)/\(currentRelativePath)/\(flatRelativePath)",
56 | candidatesToLinkTo: candidatesToLinkTo,
57 | version: PhotoVersion.current)
58 | }
59 | if exportDerived {
60 | logger.info("export derived photos to \(inProgressPath)/\(derivedRelativePath)/\(flatRelativePath) folder")
61 |
62 | var candidatesToLinkTo: [FlatFolderDescriptor] = []
63 |
64 | if let baseExportPath = baseExportPath {
65 | candidatesToLinkTo = try candidatesToLinkTo + flatFolderIfExists("\(baseExportPath)/\(derivedRelativePath)/\(flatRelativePath)")
66 | }
67 |
68 | if exportOriginals {
69 | candidatesToLinkTo = try candidatesToLinkTo + flatFolderIfExists("\(inProgressPath)/\(originalsRelativePath)/\(flatRelativePath)")
70 | }
71 |
72 | try exportFolderFlat(
73 | photosMetadata: photosMetadata,
74 | flatPath: "\(inProgressPath)/\(derivedRelativePath)/\(flatRelativePath)",
75 | candidatesToLinkTo: candidatesToLinkTo,
76 | version: PhotoVersion.derived)
77 | }
78 | }
79 |
80 | let stopWatchLinkFile = StopWatch("fileManager.linkItem", LogLevel.info, addFileSizes: false)
81 |
82 | override func copyOrLinkFileInPhotosLibrary(sourceUrl: URL, targetUrl: URL) throws {
83 | if try filesAreOnSameDevice(path1: sourceUrl.path, path2: targetUrl.deletingLastPathComponent().path) {
84 | logger.debug("link image: \(sourceUrl) to \(targetUrl.lastPathComponent)")
85 | do {
86 | stopWatchLinkFile.start()
87 | try fileManager.linkItem(at: sourceUrl, to: targetUrl)
88 | stopWatchLinkFile.stop()
89 | statistics.countLinkedFiles += 1
90 | }
91 | catch let error as NSError {
92 | logger.error("\(String(describing: index)): Unable to link file: \(error)")
93 | throw error
94 | }
95 | } else {
96 | // copy
97 | try super.copyOrLinkFileInPhotosLibrary(sourceUrl: sourceUrl, targetUrl: targetUrl)
98 | }
99 | }
100 |
101 | private func filesAreOnSameDevice(path1: String, path2: String) throws -> Bool {
102 | let attributes1 = try fileManager.attributesOfItem(atPath: path1)
103 | let attributes2 = try fileManager.attributesOfItem(atPath: path2)
104 | let deviceIdentifier1 = attributes1[FileAttributeKey.systemNumber]
105 | let deviceIdentifier2 = attributes2[FileAttributeKey.systemNumber]
106 | if deviceIdentifier1 != nil && deviceIdentifier2 != nil {
107 | if (deviceIdentifier1 as! NSNumber) == (deviceIdentifier2 as! NSNumber) {
108 | return true
109 | }
110 | }
111 | return false
112 | }
113 |
114 | /**
115 | * Finish the filesystem structures; invariant:
116 | * if no folder "InProgress" but folders with date exist, and there is a symbolic link "Latest", there was no error.
117 | */
118 | override func finishExport() throws {
119 | try super.finishExport()
120 |
121 | // remove the ".flat" folders
122 | if (deleteFlatPath) {
123 | try deleteFolderIfExists(atPath: "\(inProgressPath)/\(originalsRelativePath)/\(flatRelativePath)")
124 | try deleteFolderIfExists(atPath: "\(inProgressPath)/\(currentRelativePath)/\(flatRelativePath)")
125 | }
126 |
127 | // remove the "Current" folder
128 | try deleteFolderIfExists(atPath: subTargetPath)
129 |
130 | // rename "InProgress" folder to "Current"
131 | do {
132 | try fileManager.moveItem(atPath: inProgressPath, toPath: subTargetPath)
133 | } catch {
134 | logger.error("Error renaming InProgress folder: \(error) => abort export")
135 | throw error
136 | }
137 | }
138 |
139 | func deleteFolderIfExists(atPath path: String) throws {
140 | do {
141 | if fileManager.fileExists(atPath: path) {
142 | logger.info("Delete folder: \(path)")
143 | for (retryCounter, _) in [0...2].enumerated() {
144 | do {
145 | try fileManager.removeItem(atPath: path)
146 | } catch {
147 | if retryCounter == 2 {
148 | throw error
149 | }
150 | }
151 | }
152 | }
153 | } catch {
154 | logger.error("Error deleting folder \(path)")
155 | throw error
156 | }
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/PhotosExporter/photolibrary-access/PhotoLibraryUtil.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PhotoLibraryUtil.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 17.04.21.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum PhotoLibraryError: Error {
12 | case systemLibraryPathNotDefined
13 | }
14 |
15 |
16 | // implements some helper functions to access the photo library
17 | class PhotoLibraryUtil {
18 |
19 | static private var systemPhotosLibraryPath: String?;
20 |
21 | static func getSystemPhotosLibraryPath() throws -> String {
22 | if let systemPhotosLibraryPath = systemPhotosLibraryPath {
23 | return systemPhotosLibraryPath;
24 | }
25 |
26 | systemPhotosLibraryPath = getSystemPhotosLibraryPathInternal()
27 |
28 | if systemPhotosLibraryPath == nil {
29 | throw PhotoLibraryError.systemLibraryPathNotDefined
30 | }
31 |
32 | return systemPhotosLibraryPath!
33 | }
34 |
35 | static func getSystemPhotosLibraryPathInternal() -> String? {
36 | let libraryDirectoryUrls = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)
37 |
38 | if libraryDirectoryUrls.count > 0 {
39 | let libraryDirectoryUrl = libraryDirectoryUrls[0]
40 | print("containerUrl: \(libraryDirectoryUrl)")
41 | let fileUrl = URL(fileURLWithPath: "Containers/com.apple.photolibraryd/Data/Library/Preferences/group.com.apple.photolibraryd.private.plist", relativeTo: libraryDirectoryUrl)
42 | print("fileUrl: \(fileUrl)")
43 | if let dictionary = NSDictionary(contentsOfFile: fileUrl.path) {
44 | return dictionary["SystemLibraryPath"] as? String
45 | }
46 | }
47 |
48 | return nil
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/PhotosExporter/photolibrary-access/PhotosMetadataReader.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PhotosMetadataReader.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 30.10.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import Photos
11 |
12 | class PhotosMetadataReader {
13 | public let logger = Logger(loggerName: "PhotosReader", logLevel: .info)
14 |
15 | init() {
16 | }
17 |
18 | func readMetadata(completion: @escaping (PhotosMetadata) -> (Void)) {
19 | do {
20 | let photosLibraryPath = try PhotoLibraryUtil.getSystemPhotosLibraryPath()
21 | let photosMetadata = try self.readMetadata()
22 |
23 | try loadAdditionalDataFromSqliteDatabase(photosLibraryPath: photosLibraryPath, allMediaObjects: photosMetadata.allMediaObjects)
24 |
25 | completion(photosMetadata)
26 | } catch {
27 | self.logger.error("Error occured: \(error) => abort export")
28 | }
29 | }
30 |
31 | func loadAdditionalDataFromSqliteDatabase(photosLibraryPath: String, allMediaObjects: [MediaObject]) throws {
32 | let photosSqliteDAO = try PhotosSqliteDAO(photosLibraryPath: photosLibraryPath)
33 | let keywordsMap = try photosSqliteDAO.readKeywords()
34 | let titleMap = try photosSqliteDAO.readTitles()
35 | let originalFilePathMap = try photosSqliteDAO.readOriginalFilePath()
36 |
37 | for mediaObject in allMediaObjects {
38 | let zuuid = mediaObject.zuuid()
39 |
40 | if let keywords = keywordsMap[zuuid] {
41 | mediaObject.keywords = keywords
42 | }
43 | if let title = titleMap[zuuid] {
44 | mediaObject.title = title
45 | }
46 |
47 | // originalUrl
48 | if let originalFilePath = originalFilePathMap[zuuid] {
49 | let absolutePath = "\(photosLibraryPath)/originals/\(originalFilePath)"
50 |
51 | // workaround: Apple doesn't expose the URL via PhotoKit API (PHAssetResource) for some PDF's in the PhotoLibrary => get it from SQLite database
52 | if mediaObject.originalUrl == nil {
53 | mediaObject.originalUrl = URL(fileURLWithPath: absolutePath)
54 | } else if (mediaObject.originalUrl!.path != absolutePath) {
55 | // this case is no problem: if in the Library both Jpeg's and RAW photos are uploaded as "original" => PhotoKit already exposes the RAW photo
56 | logger.debug("\(mediaObject.localIdentifier!): originalURL \(mediaObject.originalUrl!.path) not as expected: \(absolutePath)")
57 | }
58 | }
59 |
60 | // current URL: fallback to originalUrl
61 | if mediaObject.currentUrl == nil {
62 | mediaObject.currentUrl = mediaObject.originalUrl
63 | }
64 |
65 | // derived URL
66 | mediaObject.derivedUrl = getDerivedUrl(photosLibraryPath: photosLibraryPath, mediaObject: mediaObject)
67 | if mediaObject.derivedUrl == nil {
68 | mediaObject.derivedUrl = mediaObject.currentUrl
69 | }
70 | }
71 | }
72 |
73 | fileprivate func fetchOptions() -> PHFetchOptions {
74 | let fetchOptions: PHFetchOptions = PHFetchOptions()
75 | fetchOptions.includeAllBurstAssets = false
76 | fetchOptions.includeAssetSourceTypes = [PHAssetSourceType.typeUserLibrary]
77 | fetchOptions.wantsIncrementalChangeDetails = false
78 | return fetchOptions;
79 | }
80 |
81 | fileprivate func readMetadata() throws -> PhotosMetadata {
82 | let allMediaObjects = readAllAssets()
83 |
84 | let rootCollection = PhotoCollection()
85 | rootCollection.name = "Photos"
86 |
87 | let dispatchGroup = DispatchGroup()
88 |
89 | let userCollections = PHCollectionList.fetchTopLevelUserCollections(with: fetchOptions())
90 | try self.readCollection(fetchResult: userCollections, targetCollection: rootCollection, allMediaObjects: allMediaObjects, dispatchGroup: dispatchGroup)
91 |
92 | dispatchGroup.wait()
93 |
94 | var allMediaObjectsArray: [MediaObject] = []
95 | for mediaObject in allMediaObjects.values {
96 | allMediaObjectsArray += [mediaObject]
97 | }
98 |
99 | addAssetsNotInAnyCollection(allMediaObjects: allMediaObjectsArray, rootCollection: rootCollection)
100 |
101 | return PhotosMetadata(rootCollection: rootCollection, allMediaObjects: allMediaObjectsArray)
102 | }
103 |
104 | fileprivate func addAssetsNotInAnyCollection(allMediaObjects: [MediaObject], rootCollection: PhotoCollection) {
105 | var assetsInCollections: Set = []
106 | insertAllAssetsOfCollectionRecursive(collection: rootCollection, result: &assetsInCollections)
107 |
108 | for mediaObject in allMediaObjects {
109 | if !assetsInCollections.contains(mediaObject.zuuid()) {
110 | // add the photos to the root collection
111 | rootCollection.mediaObjects += [mediaObject]
112 | }
113 | }
114 | }
115 |
116 | fileprivate func insertAllAssetsOfCollectionRecursive(collection: PhotoCollection, result: inout Set) {
117 | for mediaObject in collection.mediaObjects {
118 | result.insert(mediaObject.zuuid())
119 | }
120 | for childCollection in collection.childCollections {
121 | insertAllAssetsOfCollectionRecursive(collection: childCollection, result: &result)
122 | }
123 | }
124 |
125 | // returns map [zuuid:MediaObject]
126 | fileprivate func readAllAssets() -> [String:MediaObject] {
127 |
128 | var allMediaObjects: [String:MediaObject] = [:]
129 |
130 | let fetchResult: PHFetchResult = PHAsset.fetchAssets(with:fetchOptions())
131 |
132 | for i in 0...fetchResult.count-1 {
133 | let asset = fetchResult.object(at: i)
134 |
135 | let mediaObject = MediaObject()
136 | mediaObject.localIdentifier = asset.localIdentifier
137 |
138 | let zuuid = PhotoObject.zuuid(localIdentifier: asset.localIdentifier)
139 | allMediaObjects[zuuid] = mediaObject
140 |
141 | mediaObject.creationDate = asset.creationDate
142 |
143 | let assetResources = PHAssetResource.assetResources(for: asset)
144 |
145 | // if mediaObject.zuuid() == "DEFDFF1A-11DD-4EC4-A72D-F13FB2B4B2ED" {
146 | // mediaObject.printYaml(indent: 0);
147 | // print("asset: \(asset)")
148 | // }
149 |
150 | for assetResource in assetResources {
151 | switch (assetResource.type) {
152 | case PHAssetResourceType.photo,
153 | PHAssetResourceType.video,
154 | PHAssetResourceType.audio:
155 | if mediaObject.originalUrl == nil {
156 | mediaObject.originalFilename = getStringProperty(object: assetResource, propertyName: "filename")
157 | // self.logger.info("Asset originalFileName: \(mediaObject.originalFilename)")
158 | let fileUrlString = getStringProperty(object: assetResource, propertyName: "fileURL")
159 | mediaObject.originalUrl = URL(string: fileUrlString);
160 | // self.logger.info("Asset originalUrl: \(mediaObject.originalUrl)")
161 | }
162 | break;
163 | case PHAssetResourceType.fullSizePhoto,
164 | PHAssetResourceType.fullSizeVideo:
165 | mediaObject.currentUrl = URL(string: getStringProperty(object: assetResource, propertyName: "fileURL"));
166 | // self.logger.info("Asset currentUrl: \(mediaObject.currentUrl)")
167 | break;
168 | case PHAssetResourceType.pairedVideo:
169 | let fileUrlString = getStringProperty(object: assetResource, propertyName: "fileURL")
170 | mediaObject.originalLiveUrl = URL(string: fileUrlString);
171 | break
172 | case PHAssetResourceType.fullSizePairedVideo:
173 | let fileUrlString = getStringProperty(object: assetResource, propertyName: "fileURL")
174 | mediaObject.currentLiveUrl = URL(string: fileUrlString);
175 | break
176 | case PHAssetResourceType.alternatePhoto:
177 | // prefer to export raw image instead of jpeg
178 | let utiValue = getStringProperty(object: assetResource, propertyName: "uti")
179 | if utiValue.contains("raw-image") {
180 | mediaObject.originalFilename = getStringProperty(object: assetResource, propertyName: "filename")
181 | mediaObject.originalUrl = URL(string: getStringProperty(object: assetResource, propertyName: "fileURL"));
182 | // self.logger.info("Prefer raw image URL: \(mediaObject.originalUrl)")
183 | }
184 | break;
185 | case PHAssetResourceType.adjustmentBasePhoto,
186 | PHAssetResourceType.adjustmentBaseVideo,
187 | PHAssetResourceType.adjustmentBasePairedVideo:
188 | // ignore
189 | break;
190 | case PHAssetResourceType.adjustmentData:
191 | // ignore
192 | break;
193 | case .photoProxy: // undocumented!?
194 | // ignore
195 | break;
196 | @unknown default:
197 | // ignore assetResource type 16 = original_adjustment
198 | if (assetResource.type.rawValue != 16) {
199 | self.logger.warn("Invalid asset resource type: \(assetResource.type.rawValue); asset: \(assetResource)")
200 | }
201 | //fatalError()
202 | }
203 | }
204 | }
205 |
206 | return allMediaObjects
207 | }
208 |
209 | func readCollection(fetchResult: PHFetchResult, targetCollection: PhotoCollection, allMediaObjects: [String:MediaObject], dispatchGroup: DispatchGroup) throws {
210 | if fetchResult.count > 0 {
211 | for i in 0...fetchResult.count-1 {
212 | if let result = fetchResult.object(at: i) as? PHCollectionList {
213 | let collection = PhotoCollection();
214 | collection.localIdentifier = result.localIdentifier
215 | collection.name = result.localizedTitle!
216 | targetCollection.childCollections += [collection]
217 |
218 | let subFetchResult = PHCollection.fetchCollections(in: result, options: fetchOptions())
219 | try readCollection(fetchResult: subFetchResult, targetCollection: collection, allMediaObjects: allMediaObjects, dispatchGroup: dispatchGroup)
220 | } else if let result = fetchResult.object(at: i) as? PHAssetCollection {
221 | let collection = PhotoCollection();
222 | collection.localIdentifier = result.localIdentifier
223 | collection.name = result.localizedTitle!
224 | targetCollection.childCollections += [collection]
225 |
226 | let assets = PHAsset.fetchAssets(in: result, options: fetchOptions())
227 | try readCollection(fetchResult: assets, targetCollection: collection, allMediaObjects: allMediaObjects, dispatchGroup: dispatchGroup)
228 | } else {
229 | let result = fetchResult.object(at: i)
230 | print("Unknown asset collection: \(result.localizedTitle!)")
231 | }
232 | }
233 | }
234 | }
235 |
236 | func readCollection(fetchResult: PHFetchResult, targetCollection: PhotoCollection, allMediaObjects: [String:MediaObject], dispatchGroup: DispatchGroup) throws {
237 | if fetchResult.count > 0 {
238 | for i in 0...fetchResult.count-1 {
239 | let asset = fetchResult.object(at: i)
240 |
241 | let zuuid = PhotoObject.zuuid(localIdentifier: asset.localIdentifier)
242 | if let mediaObject = allMediaObjects[zuuid] {
243 | targetCollection.mediaObjects += [mediaObject]
244 | } else {
245 | logger.warn("Media object with zuuid=\(zuuid) not found. Ignore it.")
246 | }
247 | }
248 | }
249 | }
250 |
251 | // workaround to get the derived URL (didn't find any API for this)
252 | func getDerivedUrl(photosLibraryPath: String, mediaObject: MediaObject) -> URL? {
253 | let originalPathComponents = mediaObject.originalUrl!.pathComponents
254 | if originalPathComponents.count < 2 {
255 | return nil
256 | }
257 | let subFolder = originalPathComponents[originalPathComponents.count - 2]
258 |
259 | let derivedPath = "\(photosLibraryPath)/resources/derivatives/\(subFolder)/\(mediaObject.zuuid())_1_100_o.jpeg"
260 | if FileManager.default.fileExists(atPath: derivedPath) {
261 | return URL(fileURLWithPath: derivedPath)
262 | }
263 | return nil
264 | }
265 |
266 | func getStringProperty(object: AnyObject, propertyName: String) -> String {
267 | //let description: String = String(describing: [object.debugDescription])
268 | let description = object.debugDescription!
269 | let properties = String(description[description.index(description.firstIndex(of: "{")!, offsetBy: 1).. [String:String] {
55 |
56 | let stmt = try prepareStatement(sql:
57 | """
58 | select ZASSET.ZUUID, ZASSET.ZDIRECTORY || '/' || ZASSET.ZFILENAME
59 | from ZASSET
60 | """
61 | )
62 |
63 | defer {
64 | sqlite3_finalize(stmt)
65 | }
66 |
67 | var result: [String:String] = [:]
68 |
69 | while sqlite3_step(stmt) == SQLITE_ROW {
70 | let zuuid_cString = sqlite3_column_text(stmt, 0)
71 | let path_cString = sqlite3_column_text(stmt, 1)
72 |
73 | if let zuuid_cString = zuuid_cString, let path_cString = path_cString {
74 | let zuuid = String(cString: zuuid_cString)
75 | let path = String(cString: path_cString)
76 |
77 | result[zuuid] = path
78 | }
79 | }
80 | return result
81 | }
82 |
83 | func readKeywords() throws -> [String:[String]] {
84 |
85 | let stmt = try prepareStatement(sql:
86 | """
87 | SELECT ZASSET.zuuid, zkeyword.ZTITLE
88 | FROM Z_1KEYWORDS
89 | join zkeyword on zkeyword.Z_PK = z_1keywords.z_47keywords
90 | join ZADDITIONALASSETATTRIBUTES on ZADDITIONALASSETATTRIBUTES.z_pk = Z_1ASSETATTRIBUTES
91 | join ZASSET on ZASSET.ZADDITIONALATTRIBUTES = ZADDITIONALASSETATTRIBUTES.Z_PK
92 | """
93 | )
94 |
95 | defer {
96 | sqlite3_finalize(stmt)
97 | }
98 |
99 | var result: [String:[String]] = [:]
100 |
101 | while sqlite3_step(stmt) == SQLITE_ROW {
102 | let zuuid_cString = sqlite3_column_text(stmt, 0)
103 | let keyword_cString = sqlite3_column_text(stmt, 1)
104 |
105 | if let zuuid_cString = zuuid_cString, let keyword_cString = keyword_cString {
106 | let zuuid = String(cString: zuuid_cString)
107 | let keyword = String(cString: keyword_cString)
108 |
109 | // if zuuid == "DEFDFF1A-11DD-4EC4-A72D-F13FB2B4B2ED" {
110 | // print("keyword: \(keyword)")
111 | // }
112 |
113 | if var keywords = result[zuuid] {
114 | keywords += [keyword]
115 | result[zuuid] = keywords
116 | } else {
117 | result[zuuid] = [keyword]
118 | }
119 |
120 | }
121 | }
122 | return result
123 | }
124 |
125 | func readTitles() throws -> [String:String] {
126 |
127 | let stmt = try prepareStatement(sql:
128 | """
129 | select
130 | ZASSET.ZUUID,
131 | ZADDITIONALASSETATTRIBUTES.ZTITLE
132 | from ZASSET
133 | join ZADDITIONALASSETATTRIBUTES on ZASSET.ZADDITIONALATTRIBUTES = ZADDITIONALASSETATTRIBUTES.Z_PK
134 | where not ZADDITIONALASSETATTRIBUTES.ZTITLE is null and ZADDITIONALASSETATTRIBUTES.ZTITLE <> ''
135 | """
136 | )
137 |
138 | defer {
139 | sqlite3_finalize(stmt)
140 | }
141 |
142 | var result: [String:String] = [:]
143 |
144 | while sqlite3_step(stmt) == SQLITE_ROW {
145 | let zuuid_cString = sqlite3_column_text(stmt, 0)
146 | let title_cString = sqlite3_column_text(stmt, 1)
147 |
148 | if let zuuid_cString = zuuid_cString, let title_cString = title_cString {
149 | let zuuid = String(cString: zuuid_cString)
150 | let title = String(cString: title_cString)
151 |
152 | result[zuuid] = title
153 |
154 | }
155 | }
156 | return result
157 | }
158 |
159 | func prepareStatement(sql: String) throws -> OpaquePointer? {
160 | var statement: OpaquePointer? = nil
161 | guard sqlite3_prepare_v2(dbPointer, sql, -1, &statement, nil) == SQLITE_OK else {
162 | throw SQLiteError.Prepare(message: "Could not prepare statement")
163 | }
164 |
165 | return statement
166 | }
167 |
168 | }
169 |
--------------------------------------------------------------------------------
/PhotosExporter/photosmodel/MediaObject.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MediaObject.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 30.10.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | class MediaObject : PhotoObject {
12 | var originalName: String?
13 | var originalFilename: String?
14 | var originalUrl: URL?
15 | var currentUrl: URL?
16 | var derivedUrl: URL?
17 | var originalLiveUrl: URL?
18 | var currentLiveUrl: URL?
19 | var creationDate: Date?
20 | var title: String?
21 | var keywords: [String] = []
22 |
23 | override init() {
24 | }
25 |
26 | override func printYaml(indent: Int) {
27 | super.printYaml(indent: indent);
28 | if let originalName = originalName {
29 | print("originalName: \(originalName)".indent(indent + 2))
30 | }
31 | if let originalFilename = originalFilename {
32 | print("originalFilename: \(originalFilename)".indent(indent + 2))
33 | }
34 | if let originalUrl = originalUrl {
35 | print("originalUrl: \(originalUrl)".indent(indent + 2))
36 | }
37 | if let currentUrl = currentUrl {
38 | print("currentUrl: \(currentUrl)".indent(indent + 2))
39 | }
40 | if let originalLiveUrl = originalLiveUrl {
41 | print("originalLiveUrl: \(originalLiveUrl)".indent(indent + 2))
42 | }
43 | if let currentLiveUrl = currentLiveUrl {
44 | print("currentLiveUrl: \(currentLiveUrl)".indent(indent + 2))
45 | }
46 | if let derivedUrl = derivedUrl {
47 | print("derivedUrl: \(derivedUrl)".indent(indent + 2))
48 | }
49 | if let creationDate = creationDate {
50 | print("creationDate: \(creationDate)".indent(indent + 2))
51 | }
52 | if let title = title {
53 | print("title: \(title)".indent(indent + 2))
54 | }
55 | print("keywords: \(keywords)".indent(indent + 2))
56 | }
57 | }
58 |
59 | extension MediaObject: CustomStringConvertible {
60 | var description: String {
61 | return "[localIdentifier: \(String(describing: localIdentifier)), originalName: \(String(describing: originalName))]"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/PhotosExporter/photosmodel/PhotoCollection.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PhotoCollection.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 30.10.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | class PhotoCollection : PhotoObject {
12 | var name: String = ""
13 | var childCollections: [PhotoCollection] = [];
14 | var mediaObjects: [MediaObject] = []
15 |
16 | override init() {
17 | }
18 |
19 | override func printYaml(indent: Int) {
20 | super.printYaml(indent: indent)
21 | print("name: \(name)".indent(indent + 2))
22 | print("children:".indent(indent + 2))
23 | for collection in childCollections {
24 | collection.printYaml(indent: indent + 4)
25 | }
26 | for mediaObject in mediaObjects {
27 | mediaObject.printYaml(indent: indent + 4)
28 | }
29 | }
30 | }
31 |
32 | extension PhotoCollection: CustomStringConvertible {
33 | var description: String {
34 | return "[localIdentifier: \(String(describing: localIdentifier)), name: \(String(describing: name))]"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/PhotosExporter/photosmodel/PhotoObject.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PhotoObject.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 30.10.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | class PhotoObject {
12 |
13 | var localIdentifier: String?
14 |
15 | init() {
16 | }
17 |
18 | func printYaml(indent: Int) {
19 | print("-".indent(indent))
20 | if let localIdentifier = localIdentifier {
21 | print("localIdentifier: \(localIdentifier)".indent(indent + 2))
22 | }
23 | }
24 |
25 | /** identifier used in the photos SQLite database */
26 | func zuuid() -> String {
27 | let zuuid = PhotoObject.zuuid(localIdentifier: localIdentifier!)
28 | return zuuid
29 | }
30 |
31 | /** identifier used in the photos SQLite database */
32 | static func zuuid(localIdentifier: String) -> String {
33 | let zuuid = String(localIdentifier[.. YAML {
30 | var document = [YAML]()
31 | var stack = [YAML]()
32 | // TODO var anchors = [String: YAML]()
33 | var lines = 1
34 | var index = stream.startIndex
35 | var flow = [Character]()
36 | var token: String?
37 | while index < stream.endIndex {
38 | do {
39 | let indent: Int = parseIndent(stream, index: &index)
40 | if token == nil {
41 | token = try parseToken(stream, index: &index, to: ":", inFlow: flow.last)
42 | }
43 | guard let t = token?.trimmingCharacters(in: CharacterSet(charactersIn: " ")), !t.isEmpty else {
44 | if index < stream.endIndex {
45 | switch stream[index] {
46 | case "\r", "\n", "\u{85}":
47 | lines += 1
48 | case ":":
49 | throw UniYAMLError.error(detail: "unexpected colon")
50 | default:
51 | break
52 | }
53 | index = stream.index(after: index)
54 | }
55 | continue
56 | }
57 | var key: String?
58 | var tag: String?
59 | var value: String?
60 | switch t {
61 | case "---", "...":
62 | if stack.count > 0 {
63 | try foldStack(&stack, toIndent: -1)
64 | document.append(stack.first!)
65 | stack.removeAll()
66 | }
67 | token = nil
68 | continue
69 | case "#":
70 | if let border = stream.rangeOfCharacter(from: CharacterSet(charactersIn: "\r\n\u{85}"), range: Range(uncheckedBounds: (index, stream.endIndex))) {
71 | index = border.lowerBound
72 | } else {
73 | index = stream.endIndex
74 | }
75 | token = nil
76 | continue
77 | case "{":
78 | if stack.count == 0 {
79 | guard indent == 0 else {
80 | throw UniYAMLError.error(detail: "unexpected indentation")
81 | }
82 | stack.append(YAML(indent: -1, type: .dictionary, key: nil, tag: nil, value: [String: YAML]()))
83 | }
84 | let last = stack.count - 1
85 | if stack[last].type == .pending {
86 | stack[last].type = .dictionary
87 | stack[last].value = [String: YAML]()
88 | }
89 | flow.append("}")
90 | token = nil
91 | continue
92 | case "[":
93 | if stack.count == 0 {
94 | guard indent == 0 else {
95 | throw UniYAMLError.error(detail: "unexpected indentation")
96 | }
97 | stack.append(YAML(indent: -1, type: .array, key: nil, tag: nil, value: [YAML]()))
98 | }
99 | let last = stack.count - 1
100 | if stack[last].type == .pending {
101 | stack[last].type = .array
102 | stack[last].value = [YAML]()
103 | }
104 | flow.append("]")
105 | token = nil
106 | continue
107 | case "}", "]":
108 | try foldStack(&stack, toIndent: nil)
109 | guard let brace = flow.popLast(), t[t.startIndex] == brace else {
110 | throw UniYAMLError.error(detail: "unexpected closing brace")
111 | }
112 | token = nil
113 | continue
114 | case "-":
115 | if stack.count == 0 {
116 | guard indent == 0 else {
117 | throw UniYAMLError.error(detail: "unexpected indentation")
118 | }
119 | stack.append(YAML(indent: -1, type: .array, key: nil, tag: nil, value: [YAML]()))
120 | }
121 | let last = stack.count - 1
122 | if stack[last].type == .pending {
123 | stack[last].type = .array
124 | stack[last].value = [YAML]()
125 | if flow.isEmpty, indent < stack[last].indent {
126 | throw UniYAMLError.error(detail: "unexpected indentation")
127 | }
128 | } else if flow.isEmpty, stack[last].indent > indent {
129 | try foldStack(&stack, toIndent: indent + 1)
130 | }
131 | token = try parseToken(stream, index: &index, honorDash: false, inFlow: flow.last)
132 | guard let tt = token?.trimmingCharacters(in: CharacterSet(charactersIn: " ")), !tt.isEmpty else {
133 | stack.append(YAML(indent: indent + 1, type: .pending, key: nil, tag: nil, value: nil))
134 | continue
135 | }
136 | switch tt {
137 | case "#":
138 | continue
139 | case "{", "[":
140 | stack.append(YAML(indent: indent, type: .pending, key: nil, tag: nil, value: nil))
141 | continue
142 | case "}", "]":
143 | throw UniYAMLError.error(detail: "unexpected closing brace")
144 | case "|", ">":
145 | value = try parseMultilineValue(stream, index: &index, line: &lines, indent: indent, folded: (tt == ">"))
146 | default:
147 | (tag, value) = parseValue(tt)
148 | }
149 | token = nil
150 | default:
151 | guard index < stream.endIndex else {
152 | throw UniYAMLError.error(detail: "unexpected stream end")
153 | }
154 | if stream[index] == ":" {
155 | if stack.count == 0 {
156 | guard indent == 0 else {
157 | throw UniYAMLError.error(detail: "unexpected indentation")
158 | }
159 | stack.append(YAML(indent: -1, type: .dictionary, key: nil, tag: nil, value: [String: YAML]()))
160 | }
161 | let last = stack.count - 1
162 | if stack[last].type == .pending {
163 | if flow.isEmpty, indent <= stack[last].indent {
164 | stack[last].type = .array
165 | stack[last].value = [YAML]()
166 | try foldStack(&stack, toIndent: indent)
167 | } else {
168 | stack[last].type = .dictionary
169 | stack[last].value = [String: YAML]()
170 | }
171 | } else if flow.isEmpty, stack[last].indent >= indent {
172 | try foldStack(&stack, toIndent: indent)
173 | }
174 | key = dequote(t)
175 | index = stream.index(after: index)
176 | token = try parseToken(stream, index: &index, honorDash: false, inFlow: flow.last)
177 | if let tt = token?.trimmingCharacters(in: CharacterSet(charactersIn: " ")), !tt.isEmpty {
178 | switch tt {
179 | case "#":
180 | continue
181 | case "{", "[":
182 | stack.append(YAML(indent: indent, type: .pending, key: key, tag: nil, value: nil))
183 | continue
184 | case "}", "]":
185 | throw UniYAMLError.error(detail: "unexpected closing brace")
186 | case "|", ">":
187 | value = try parseMultilineValue(stream, index: &index, line: &lines, indent: indent, folded: (tt == ">"))
188 | default:
189 | (tag, value) = parseValue(tt)
190 | }
191 | }
192 | } else if let f = flow.last, f == "]" {
193 | (tag, value) = parseValue(t)
194 | } else if flow.isEmpty, stack.last?.type == .pending {
195 | let last = stack.count - 1
196 | guard indent > stack[last].indent else {
197 | throw UniYAMLError.error(detail: "unexpected indentation")
198 | }
199 | index = stream.index(index, offsetBy: -(indent + t.count + 1))
200 | lines -= 1
201 | stack[last].type = .string
202 | stack[last].value = try parseMultilineValue(stream, index: &index, line: &lines, indent: stack[last].indent, folded: true)
203 | } else {
204 | throw UniYAMLError.error(detail: "unexpected value")
205 | }
206 | token = nil
207 | }
208 |
209 | //print("DEBUG\tindent: \(indent), key: \(key), value: \(value)")
210 |
211 | var last = stack.count - 1
212 | if let k = key {
213 | if let v = value {
214 | guard let dictionary = stack[last].value as? [String: YAML] else {
215 | throw UniYAMLError.error(detail: "value type mismatch")
216 | }
217 | if flow.isEmpty, let first = dictionary.values.first?.indent, indent != first {
218 | throw UniYAMLError.error(detail: "indentation mismatch")
219 | }
220 | var complete = v
221 | if flow.isEmpty, index < stream.endIndex, indent < checkIndent(stream, index: stream.index(after: index)) {
222 | // NOTE: handle the case where a value for a key spans to next line(s)
223 | let tail = try parseMultilineValue(stream, index: &index, line: &lines, indent: indent, folded: true)
224 | complete += " \(tail)"
225 | }
226 | var d = dictionary
227 | d[k] = YAML(indent: indent, type: .string, key: k, tag: tag, value: complete)
228 | stack[last].value = d
229 | } else {
230 | stack.append(YAML(indent: indent, type: .pending, key: k, tag: nil, value: nil))
231 | }
232 | } else if let v = value {
233 | guard let array = stack[last].value as? [YAML] else {
234 | throw UniYAMLError.error(detail: "value type mismatch")
235 | }
236 | var a = array
237 | if flow.isEmpty, let previous = array.last {
238 | if previous.indent < indent {
239 | throw UniYAMLError.error(detail: "indentation mismatch")
240 | } else if previous.indent > indent {
241 | try foldStack(&stack, toIndent: indent + 1)
242 | last = stack.count - 1
243 | guard let array2 = stack[last].value as? [YAML] else {
244 | throw UniYAMLError.error(detail: "value type mismatch")
245 | }
246 | a = array2
247 | }
248 | }
249 | a.append(YAML(indent: indent, type: .string, key: nil, tag: tag, value: v))
250 | stack[last].value = a
251 | }
252 | } catch UniYAMLError.error(let detail) {
253 | throw UniYAMLError.error(detail: "\(detail) at line \(lines)")
254 | }
255 | }
256 | if stack.count > 0 {
257 | guard flow.isEmpty else {
258 | throw UniYAMLError.error(detail: "unclosed brace")
259 | }
260 | try foldStack(&stack, toIndent: -1)
261 | document.append(stack.first!)
262 | }
263 | var result: YAML
264 | switch document.count {
265 | case 0:
266 | result = YAML(indent: 0, type: .empty, key: nil, tag: nil, value: nil)
267 | case 1:
268 | result = document[0]
269 | default:
270 | result = YAML(indent: 0, type: .array, key: nil, tag: nil, value: document)
271 | }
272 | return result
273 | }
274 |
275 | static private func checkIndent(_ stream: String, index: String.Index) -> Int {
276 | var indent = 0
277 | var idx = index
278 | while idx < stream.endIndex {
279 | guard stream[idx] == " " else {
280 | break
281 | }
282 | idx = stream.index(after: idx)
283 | indent += 1
284 | }
285 | return indent
286 | }
287 |
288 | static private func parseIndent(_ stream: String, index: inout String.Index) -> Int {
289 | let indent = checkIndent(stream, index: index)
290 | index = stream.index(index, offsetBy: indent)
291 | return indent
292 | }
293 |
294 | static private func parseToken(_ stream: String, index: inout String.Index, to: String = "", honorDash: Bool = true, inFlow: Character? = nil) throws -> String? {
295 | guard index < stream.endIndex else {
296 | return nil
297 | }
298 | _ = parseIndent(stream, index: &index)
299 | var search = Range(uncheckedBounds: (index, stream.endIndex))
300 | var location = search
301 | var fragments = ""
302 | switch stream[index] {
303 | case "\r", "\n", "\u{85}":
304 | return nil
305 | case "#", "{", "}", "[", "]", "|", ">":
306 | let i = stream.index(after: index)
307 | location = Range(uncheckedBounds: (index, i))
308 | index = i
309 | case "-" where (honorDash && inFlow == nil):
310 | guard let border = stream.rangeOfCharacter(from: CharacterSet(charactersIn: " \r\n\u{85}"), range: search) else {
311 | throw UniYAMLError.error(detail: "unexpected value")
312 | }
313 | location = Range(uncheckedBounds: (index, border.lowerBound))
314 | index = location.upperBound
315 | case "'":
316 | var i = stream.index(after: index)
317 | while i < stream.endIndex {
318 | search = Range(uncheckedBounds: (i, stream.endIndex))
319 | guard let ii = stream.rangeOfCharacter(from: CharacterSet(charactersIn: "'"), range: search) else {
320 | throw UniYAMLError.error(detail: "unclosed quotes")
321 | }
322 | guard ii.lowerBound < stream.endIndex, stream[ii] == "'" else {
323 | throw UniYAMLError.error(detail: "unclosed quotes")
324 | }
325 | // NOTE: bad ugly "fragments" hack to correctly parse notation like this:
326 | // key: 'this ''fragmented'' value'
327 | if ii.upperBound < stream.endIndex, stream[ii.upperBound] == "'" {
328 | fragments.append(String(stream[i.. (String?, String?) {
375 | let tag: String? = nil // TODO
376 | let value = dequote(string)
377 | return (tag, value)
378 | }
379 |
380 | static private func parseMultilineValue(_ stream: String, index: inout String.Index, line: inout Int, indent: Int, folded: Bool) throws -> String {
381 | guard index < stream.endIndex else {
382 | throw UniYAMLError.error(detail: "unexpected stream end")
383 | }
384 | guard stream[index] == "\r" || stream[index] == "\n" || stream[index] == "\u{85}" else {
385 | throw UniYAMLError.error(detail: "unexpected trailing characters")
386 | }
387 | index = stream.index(after: index)
388 | line += 1
389 | var value: String = ""
390 | var glue: Character = " "
391 | var block: Int = -1
392 | while index < stream.endIndex {
393 | let i = checkIndent(stream, index: index)
394 | guard i > indent else {
395 | break
396 | }
397 | if block == -1 {
398 | block = i
399 | }
400 | guard i >= block else {
401 | throw UniYAMLError.error(detail: "unexpected indentation")
402 | }
403 | index = stream.index(index, offsetBy: i)
404 | var location = Range(uncheckedBounds: (index, stream.endIndex))
405 | if let border = stream.rangeOfCharacter(from: CharacterSet(charactersIn: "\r\n\u{85}"), range: location) {
406 | location = Range(uncheckedBounds: (index, border.lowerBound))
407 | glue = (folded) ? " ":stream[border.lowerBound]
408 | }
409 | index = stream.index(after: location.upperBound)
410 | line += 1
411 | if !value.isEmpty {
412 | value += "\(glue)"
413 | }
414 | value += stream[location]
415 | }
416 | guard !value.isEmpty else {
417 | throw UniYAMLError.error(detail: "missing value")
418 | }
419 | return value
420 | }
421 |
422 | static private func dequote(_ string: String?) -> String? {
423 | guard let ss = string else {
424 | return nil
425 | }
426 | let s = ss.replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: "").replacingOccurrences(of: "\u{85}", with: "")
427 | if s.hasPrefix("'"), s.hasSuffix("'") {
428 | return String(s[s.index(after: s.startIndex).. Void {
444 | while stack.count > 1 {
445 | if let indent = toIndent, stack.last!.indent < indent {
446 | break
447 | }
448 | let last = stack.popLast()
449 | let idx = stack.count - 1
450 | switch stack[idx].type {
451 | case .array:
452 | guard let array = stack[idx].value as? [YAML] else {
453 | throw UniYAMLError.error(detail: "array value mismatch")
454 | }
455 | var a = array
456 | a.append(last!)
457 | stack[idx].value = a
458 | case .dictionary:
459 | guard let key = last!.key, let dictionary = stack[idx].value as? [String: YAML] else {
460 | throw UniYAMLError.error(detail: "dictionary value mismatch")
461 | }
462 | var d = dictionary
463 | d[key] = last!
464 | stack[idx].value = d
465 | default:
466 | throw UniYAMLError.error(detail: "unexpected value")
467 | }
468 | if let indent = toIndent, last!.indent > indent {
469 | continue
470 | }
471 | break
472 | }
473 | }
474 |
475 | }
476 |
--------------------------------------------------------------------------------
/PhotosExporter/preferences/Encoder.swift:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2017-2018 Seznam.cz, a.s.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | /*
18 | * Authors:
19 | * Daniel Bilik (daniel.bilik@firma.seznam.cz)
20 | * Tomas Zabojnik (tomas.zabojnik@firma.seznam.cz)
21 | */
22 |
23 | import Foundation
24 |
25 | public enum UniYAMLNotation: String {
26 | case json, yaml
27 | }
28 |
29 | extension UniYAML {
30 | private enum TokenType: String {
31 | case document, raw, emptyArray, emptyDict,
32 | arrayOpen, arrayClose, arraySeparator,
33 | dictOpen, dictClose, dictKey, dictSeparator,
34 | stream
35 | }
36 |
37 | private struct Token {
38 | let type: TokenType
39 | let data: Any?
40 | init(_ type: TokenType, data: Any?=nil) {
41 | self.type = type
42 | self.data = data
43 | }
44 | }
45 |
46 | static private func escape(_ input: String) -> String {
47 | return input.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"").replacingOccurrences(of: "\t", with: "\\t").replacingOccurrences(of: "\n", with: "\\n").replacingOccurrences(of: "\r", with: "\\r")
48 | }
49 |
50 | static private func escapeYamlString(_ input: String, isKey: Bool=false) -> String {
51 | var forbiddenChars = "[]{}<>-:=&\n%,@?*#|!\"'`"
52 | if isKey {
53 | forbiddenChars += " \t\r"
54 | }
55 | let forbidden = CharacterSet(charactersIn: forbiddenChars)
56 | if input.rangeOfCharacter(from: forbidden) != nil {
57 | return "\"" + UniYAML.escape(input) + "\"";
58 | }
59 | return input;
60 | }
61 |
62 | static public func encode(_ object: Any, with notation: UniYAMLNotation = .json) throws -> String {
63 | var stream = ""
64 | var indent: Int = 0
65 | var stack = [Token]()
66 | stack.append(Token(.raw, data: object))
67 | var prevToken: TokenType = .document
68 | while stack.count > 0 {
69 | let current = stack.remove(at: 0)
70 | if current.type == .raw {
71 | if let obj = current.data as? Int {
72 | stack.insert(Token(.stream, data: "\(obj)"), at: 0)
73 | } else if let obj = current.data as? Double {
74 | stack.insert(Token(.stream, data: "\(obj)"), at: 0)
75 | } else if let obj = current.data as? String {
76 | var escaped: String
77 | if notation == .yaml {
78 | escaped = escapeYamlString(obj)
79 | } else {
80 | escaped = "\"" + escape(obj) + "\""
81 | }
82 | stack.insert(Token(.stream, data: escaped), at: 0)
83 | } else if let obj = current.data as? [Any], obj.count == 0 {
84 | stack.insert(Token(.emptyArray), at: 0)
85 | } else if let obj = current.data as? [Any] {
86 | stack.insert(Token(.arrayOpen, data: indent), at: 0)
87 | var i = 1
88 | for item in obj {
89 | if (i > 1) {
90 | stack.insert(Token(.arraySeparator, data: indent), at: i)
91 | i += 1
92 | }
93 | stack.insert(Token(.raw, data: item), at: i)
94 | i += 1
95 | }
96 | stack.insert(Token(.arrayClose), at: i)
97 | } else if let obj = current.data as? [String: Any], obj.count == 0 {
98 | stack.insert(Token(.emptyDict), at: 0)
99 | } else if let obj = current.data as? [String: Any] {
100 | stack.insert(Token(.dictOpen, data: indent), at: 0)
101 | var i = 1
102 | for (key, value) in obj {
103 | if (i > 1) {
104 | stack.insert(Token(.dictSeparator, data: indent), at: i)
105 | i += 1
106 | }
107 | stack.insert(Token(.dictKey, data:key), at: i)
108 | stack.insert(Token(.raw, data: value), at: i+1)
109 | i += 2
110 | }
111 | stack.insert(Token(.dictClose), at: i)
112 | } else if let obj = current.data as? YAML, obj.value != nil {
113 | stack.insert(Token(.raw, data: obj.value), at: 0)
114 | } else {
115 | throw UniYAMLError.error(detail: "unsupported type")
116 | }
117 | } else {
118 | if current.type == .arrayOpen || current.type == .dictOpen {
119 | indent += 1
120 | } else if current.type == .arrayClose || current.type == .dictClose {
121 | indent -= 1
122 | }
123 |
124 | if notation == .yaml {
125 | stream += encodeYaml(current, prevToken)
126 | } else {
127 | stream += encodeJson(current)
128 | }
129 | prevToken = current.type
130 | }
131 | }
132 | return stream
133 | }
134 |
135 | static private func encodeJson(_ token: Token) -> String {
136 | let dict: [TokenType: String] = [
137 | .emptyArray: "[]",
138 | .arrayOpen: "[",
139 | .arrayClose: "]",
140 | .arraySeparator: ",",
141 | .emptyDict: "{}",
142 | .dictOpen: "{",
143 | .dictClose: "}",
144 | .dictSeparator: ",",
145 | ]
146 |
147 | if let val = dict[token.type] {
148 | return val
149 | } else if token.type == .dictKey {
150 | let key = token.data as! String
151 | return "\"" + escape(key) + "\":"
152 | } else if token.type == .stream {
153 | let str = token.data as! String
154 | return str
155 | }
156 |
157 | return ""
158 | }
159 |
160 | static private func encodeYaml(_ token: Token, _ context: TokenType) -> String {
161 | var result: String = ""
162 | switch token.type {
163 | case .emptyArray:
164 | return "[]\n"
165 | case .arrayOpen:
166 | if context == .dictKey {
167 | let indent = token.data as! Int
168 | result = "\n" + String(repeating: " ", count: indent*2)
169 | }
170 | result += "- "
171 | case .arraySeparator:
172 | let indent = token.data as! Int
173 | result = String(repeating: " ", count: indent*2) + "- "
174 | case .emptyDict:
175 | result = "{}\n"
176 | case .dictKey:
177 | let key = token.data as! String
178 | result = escapeYamlString(key, isKey: true) + ": "
179 | case .dictOpen:
180 | if context == .dictKey {
181 | let indent = token.data as! Int
182 | result = "\n" + String(repeating: " ", count: indent*2)
183 | }
184 | case .dictSeparator:
185 | let indent = token.data as! Int
186 | result = String(repeating: " ", count: indent*2)
187 | case .stream:
188 | let str = token.data as! String
189 | result = str + "\n"
190 | default:
191 | break
192 | }
193 | return result
194 | }
195 | }
196 |
--------------------------------------------------------------------------------
/PhotosExporter/preferences/PreferencesReader.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PreferencesReader.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 25.05.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum PreferencesReaderError: Error {
12 | case invalidYaml
13 | case invalidOrNoPlanType
14 | case missingRequiredAttribute
15 | }
16 |
17 | class PreferencesReader {
18 |
19 | private static let logger = Logger(loggerName: "PreferencesReader", logLevel: .info)
20 |
21 | static func readPreferencesFile() throws -> Preferences {
22 | let preferencesFolderUrl = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!.appendingPathComponent("PhotosExporter")
23 | do {
24 | let preferencesFileUrl = preferencesFolderUrl.appendingPathComponent("PhotosExporter.yaml")
25 | let path = preferencesFileUrl.path
26 | if FileManager.default.fileExists(atPath: path) {
27 | let fileContent = try String(contentsOfFile: path)
28 | logger.debug("Read preferences file; content:\n\(fileContent)")
29 | return try preferencesFromYaml(yamlStr: fileContent)
30 | } else {
31 | logger.info("Preferences file not found")
32 | }
33 | } catch {
34 | logger.error("Error reading preferences file: \(error)")
35 | throw error
36 | }
37 | return Preferences()
38 | }
39 |
40 | static func writePreferencesFile(preferences: Preferences) {
41 | let preferencesFolderUrl = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!.appendingPathComponent("PhotosExporter")
42 | do {
43 | try FileManager.default.createDirectory(at: preferencesFolderUrl, withIntermediateDirectories: true, attributes: nil)
44 | let preferencesFileUrl = preferencesFolderUrl.appendingPathComponent("PhotosExporter.yaml")
45 | let fileContent = preferences.toYaml()
46 | try fileContent.write(to: preferencesFileUrl, atomically: true, encoding: String.Encoding.utf8)
47 | logger.debug("Wrote preferences file to : \(preferencesFileUrl.path)")
48 | } catch {
49 | logger.error("Error writing preferences file: \(error)")
50 | }
51 | }
52 |
53 |
54 | static func preferencesFromYaml(yamlStr: String) throws -> Preferences {
55 | let preferences = Preferences()
56 |
57 | var preferencesYaml: YAML
58 | do {
59 | preferencesYaml = try UniYAML.decode(yamlStr)
60 | } catch UniYAMLError.error(let detail) {
61 | print(detail)
62 | throw PreferencesReaderError.invalidYaml
63 | } catch {
64 | print("error: \(error)")
65 | throw PreferencesReaderError.invalidYaml
66 | }
67 |
68 | if let plansYaml = preferencesYaml["plans"], let plansArray = plansYaml.array {
69 | for planYaml in plansArray {
70 | let planDict = planYaml.dictionary!
71 | if let type = planDict["type"]?.string {
72 | var plan: Plan
73 | switch (type) {
74 | case "IncrementalFileSystemExport":
75 | plan = IncrementalFileSystemExportPlan()
76 | break
77 | case "SnapshotFileSystemExport":
78 | plan = SnapshotFileSystemExportPlan()
79 | break
80 | case "GooglePhotosExport":
81 | plan = GooglePhotosExportPlan()
82 | break
83 | default:
84 | throw PreferencesReaderError.invalidOrNoPlanType
85 | }
86 |
87 | plan.name = planDict["name"]?.string
88 | if let enabledYaml = planDict["enabled"]?.bool {
89 | plan.enabled = enabledYaml
90 | }
91 | plan.exportCurrent = planDict["exportCurrent"]?.bool
92 | plan.exportOriginals = planDict["exportOriginals"]?.bool
93 | plan.exportDerived = planDict["exportDerived"]?.bool
94 |
95 | if let plan = plan as? FileSystemExportPlan {
96 | plan.targetFolder = planDict["targetFolder"]?.string
97 | plan.baseExportPath = planDict["baseExportPath"]?.string
98 | }
99 |
100 | if let plan = plan as? SnapshotFileSystemExportPlan {
101 | plan.deleteFlatPath = planDict["deleteFlatPath"]?.bool
102 | }
103 |
104 | if let mediaObjectFilterYaml = planDict["mediaObjectFilter"] {
105 | let mediaObjectFilterDict = mediaObjectFilterYaml.dictionary!
106 |
107 | if let mediaGroupTypeWhiteListYaml = mediaObjectFilterDict["mediaGroupTypeWhiteList"]?.array {
108 | plan.mediaObjectFilter.mediaGroupTypeWhiteList = []
109 | for elem in mediaGroupTypeWhiteListYaml {
110 | if let str = elem.string {
111 | plan.mediaObjectFilter.mediaGroupTypeWhiteList.append(str)
112 | }
113 | }
114 | }
115 | if let keywordWhiteListYaml = mediaObjectFilterDict["keywordWhiteList"]?.array {
116 | plan.mediaObjectFilter.keywordWhiteList = []
117 | for elem in keywordWhiteListYaml {
118 | if let str = elem.string {
119 | plan.mediaObjectFilter.keywordWhiteList.append(str)
120 | }
121 | }
122 | }
123 | if let keywordBlackListYaml = mediaObjectFilterDict["keywordBlackList"]?.array {
124 | plan.mediaObjectFilter.keywordBlackList = []
125 | for elem in keywordBlackListYaml {
126 | if let str = elem.string {
127 | plan.mediaObjectFilter.keywordBlackList.append(str)
128 | }
129 | }
130 | }
131 | }
132 |
133 | preferences.plans.append(plan)
134 | } else {
135 | logger.warn("Plan defined without type attribute: \(String(describing: planYaml.dictionary))")
136 | throw PreferencesReaderError.invalidOrNoPlanType
137 | }
138 | }
139 | }
140 |
141 |
142 | return preferences
143 | }
144 |
145 | }
146 |
--------------------------------------------------------------------------------
/PhotosExporter/preferences/YAML.swift:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2017-2018 Seznam.cz, a.s.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | /*
18 | * Author: Daniel Bilik (daniel.bilik@firma.seznam.cz)
19 | */
20 |
21 | import Foundation
22 |
23 | public enum YAMLType: String {
24 | case pending
25 | case empty
26 | case string
27 | case integer
28 | case double
29 | case array
30 | case dictionary
31 | }
32 |
33 | public struct YAML {
34 | let indent: Int
35 | var type: YAMLType
36 | let key: String?
37 | let tag: String?
38 | var value: Any?
39 | public var count: Int? {
40 | if type == .array, let array = value as? [YAML] {
41 | return array.count
42 | } else if type == .dictionary, let dictionary = value as? [String: YAML] {
43 | return dictionary.keys.count
44 | }
45 | return nil
46 | }
47 | public var keys: [String]? {
48 | if type == .dictionary, let dictionary = value as? [String: YAML] {
49 | return dictionary.keys.map { member in return member }
50 | }
51 | return nil
52 | }
53 | public var string: String? {
54 | return (type == .string) ? (value as? String):nil
55 | }
56 | public var int: Int? {
57 | if type == .integer, let i = value as? Int {
58 | return i
59 | } else if type == .string, let i = value as? String {
60 | return Int(i)
61 | }
62 | return nil
63 | }
64 | public var uint: UInt? {
65 | if type == .integer, let i = value as? Int {
66 | return UInt(i)
67 | } else if type == .string, let i = value as? String, let u = UInt(i) {
68 | return u
69 | }
70 | return nil
71 | }
72 | public var double: Double? {
73 | if type == .double, let d = value as? Double {
74 | return d
75 | } else if type == .string, let i = value as? String {
76 | return Double(i)
77 | }
78 | return nil
79 | }
80 | public var bool: Bool? {
81 | var s: String
82 | if type == .string {
83 | s = value as! String
84 | } else if type == .integer {
85 | s = "\(value as! Int)"
86 | } else {
87 | return nil
88 | }
89 | switch s.lowercased() {
90 | case "0", "off", "false", "no":
91 | return false
92 | case "1", "on", "true", "yes":
93 | return true
94 | default:
95 | return nil
96 | }
97 | }
98 | public var array: [YAML]? {
99 | return (type == .array) ? (value as? [YAML]):nil
100 | }
101 | public var dictionary: [String: YAML]? {
102 | return (type == .dictionary) ? (value as? [String: YAML]):nil
103 | }
104 | public var first: YAML? {
105 | guard type == .array, let array = value as? [YAML] else {
106 | return nil
107 | }
108 | return array.first
109 | }
110 | public var last: YAML? {
111 | guard type == .array, let array = value as? [YAML] else {
112 | return nil
113 | }
114 | return array.last
115 | }
116 | public subscript(index: Int) -> YAML? {
117 | get {
118 | guard type == .array, let array = value as? [YAML] else {
119 | return nil
120 | }
121 | return array[index]
122 | }
123 | set(newValue) {
124 | if let v = newValue, type == .array, var array = value as? [YAML] {
125 | array[index] = v
126 | value = array
127 | }
128 | }
129 | }
130 | public subscript(key: String) -> YAML? {
131 | get {
132 | guard type == .dictionary, let dictionary = value as? [String: YAML] else {
133 | return nil
134 | }
135 | return dictionary[key]
136 | }
137 | set(newValue) {
138 | if let v = newValue, type == .dictionary, var dictionary = value as? [String: YAML] {
139 | dictionary[key] = YAML(indent: 0, type: v.type, key: key, tag: nil, value: v.value)
140 | value = dictionary
141 | }
142 | }
143 | }
144 | public init(indent: Int, type: YAMLType, key: String?, tag: String?, value: Any?) {
145 | self.indent = indent
146 | self.type = type
147 | self.value = value
148 | self.key = key
149 | self.tag = tag
150 | }
151 | public init(_ string: String) {
152 | indent = 0
153 | type = .string
154 | value = string
155 | key = nil
156 | tag = nil
157 | }
158 | public init(_ int: Int) {
159 | indent = 0
160 | type = .integer
161 | value = int
162 | key = nil
163 | tag = nil
164 | }
165 | public init(_ uint: UInt) {
166 | indent = 0
167 | type = .integer
168 | value = Int(uint)
169 | key = nil
170 | tag = nil
171 | }
172 | public init(_ double: Double) {
173 | indent = 0
174 | type = .double
175 | value = double
176 | key = nil
177 | tag = nil
178 | }
179 | public init?(_ array: [Any]) {
180 | var obj = [YAML]()
181 | for member in array {
182 | if let s = member as? String {
183 | obj.append(YAML(s))
184 | } else if let i = member as? Int {
185 | obj.append(YAML(i))
186 | } else if let u = member as? UInt {
187 | obj.append(YAML(u))
188 | } else if let d = member as? Double {
189 | obj.append(YAML(d))
190 | } else if let y = member as? YAML {
191 | obj.append(y)
192 | } else {
193 | return nil
194 | }
195 | }
196 | indent = 0
197 | type = .array
198 | value = obj
199 | key = nil
200 | tag = nil
201 | }
202 | public init?(_ dictionary: [String: Any]) {
203 | var obj = [String: YAML]()
204 | for (k, v) in dictionary {
205 | if let s = v as? String {
206 | obj[k] = YAML(s)
207 | } else if let i = v as? Int {
208 | obj[k] = YAML(i)
209 | } else if let u = v as? UInt {
210 | obj[k] = YAML(u)
211 | } else if let d = v as? Double {
212 | obj[k] = YAML(d)
213 | } else if let y = v as? YAML {
214 | obj[k] = y
215 | } else {
216 | return nil
217 | }
218 | obj[k]!.key = k
219 | }
220 | indent = 0
221 | type = .dictionary
222 | value = obj
223 | key = nil
224 | tag = nil
225 | }
226 | }
227 |
--------------------------------------------------------------------------------
/PhotosExporter/preferences/model/FileSystemExportPlan.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ExportPlan.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 24.05.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | class FileSystemExportPlan : Plan {
12 |
13 | public var targetFolder: String?
14 | public var baseExportPath:String?
15 |
16 | override func toYaml(indent: Int) -> String {
17 | var result: String = ""
18 | result += super.toYaml(indent: indent)
19 | if let targetFolder = targetFolder {
20 | result += "targetFolder: \(targetFolder)\n".indent(indent)
21 | }
22 | if let baseExportPath = baseExportPath {
23 | result += "baseExportPath: \(baseExportPath)\n".indent(indent)
24 | }
25 | return result
26 | }
27 |
28 | }
29 |
30 | class IncrementalFileSystemExportPlan : FileSystemExportPlan {
31 |
32 | override func getType() -> String {
33 | return "IncrementalFileSystemExport"
34 | }
35 |
36 | override func toYaml(indent: Int) -> String {
37 | var result: String = ""
38 | result += super.toYaml(indent: indent)
39 | return result
40 | }
41 |
42 | }
43 |
44 | class SnapshotFileSystemExportPlan : FileSystemExportPlan {
45 |
46 | public var deleteFlatPath: Bool?
47 |
48 | override func getType() -> String {
49 | return "SnapshotFileSystemExport"
50 | }
51 |
52 | override func toYaml(indent: Int) -> String {
53 | var result: String = ""
54 | result += super.toYaml(indent: indent)
55 | if let deleteFlatPath = deleteFlatPath {
56 | result += "deleteFlatPath: \(deleteFlatPath)\n".indent(indent)
57 | }
58 | return result
59 | }
60 |
61 | }
62 |
--------------------------------------------------------------------------------
/PhotosExporter/preferences/model/GooglePhotosExportPlan.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GooglePhotosPlan.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 24.05.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | class GooglePhotosExportPlan : Plan {
12 |
13 | override func getType() -> String {
14 | return "GooglePhotosExport"
15 | }
16 |
17 | override func toYaml(indent: Int) -> String {
18 | var result: String = ""
19 | result += super.toYaml(indent: indent)
20 | return result
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/PhotosExporter/preferences/model/MediaObjectFilter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MediaObjectFilter.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 03.06.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | let allMediaGroupTypes = [
12 | "com.apple.Photos.RootGroup",
13 | "com.apple.Photos.AllMomentsGroup",
14 | "com.apple.Photos.MomentGroup",
15 | "com.apple.Photos.AllCollectionsGroup",
16 | "com.apple.Photos.CollectionGroup",
17 | "com.apple.Photos.AllYearsGroup",
18 | "com.apple.Photos.YearGroup",
19 | "com.apple.Photos.PlacesAlbum",
20 | "com.apple.Photos.PlacesCountryAlbum",
21 | "com.apple.Photos.PlacesProvinceAlbum",
22 | "com.apple.Photos.PlacesCityAlbum",
23 | "com.apple.Photos.PlacesPointOfInterestAlbum",
24 | "com.apple.Photos.AlbumsGroup",
25 | "com.apple.Photos.FacesAlbum",
26 | "com.apple.Photos.VideosGroup",
27 | "com.apple.Photos.FrontCameraGroup",
28 | "com.apple.Photos.PanoramasGroup",
29 | "com.apple.Photos.DepthEffectGroup",
30 | "com.apple.Photos.BurstGroup",
31 | "com.apple.Photos.ScreenshotGroup",
32 | "com.apple.Photos.Folder",
33 | "com.apple.Photos.SmartAlbum",
34 | "com.apple.Photos.Album"
35 | ]
36 |
37 | let defaultMediaGroupTypeWhiteList = [
38 | "com.apple.Photos.Album",
39 | "com.apple.Photos.SmartAlbum",
40 | "com.apple.Photos.CollectionGroup",
41 | "com.apple.Photos.MomentGroup",
42 | "com.apple.Photos.YearGroup",
43 | "com.apple.Photos.PlacesCountryAlbum",
44 | "com.apple.Photos.PlacesProvinceAlbum",
45 | "com.apple.Photos.PlacesCityAlbum",
46 | "com.apple.Photos.PlacesPointOfInterestAlbum",
47 | "com.apple.Photos.FacesAlbum",
48 | "com.apple.Photos.VideosGroup",
49 | "com.apple.Photos.FrontCameraGroup",
50 | "com.apple.Photos.PanoramasGroup",
51 | "com.apple.Photos.BurstGroup",
52 | "com.apple.Photos.ScreenshotGroup"
53 | ]
54 |
55 | // TODO always apply this blacklist condition:
56 | // !("com.apple.Photos.FacesAlbum" == mediaGroup.typeIdentifier && mediaGroup.parent?.typeIdentifier == "com.apple.Photos.AlbumsGroup") &&
57 | // !("com.apple.Photos.PlacesAlbum" == mediaGroup.typeIdentifier && mediaGroup.parent?.typeIdentifier == "com.apple.Photos.RootGroup")
58 |
59 |
60 | // empty whitelist or blacklists are ignored when filtering for photos
61 | class MediaObjectFilter {
62 |
63 | var mediaGroupTypeWhiteList: [String] = defaultMediaGroupTypeWhiteList
64 |
65 | var keywordWhiteList: [String] = []
66 | var keywordBlackList: [String] = []
67 |
68 | func toYaml(indent: Int) -> String {
69 | var result: String = "mediaObjectFilter:\n".indent(indent)
70 |
71 | result += "mediaGroupTypeWhiteList:\n".indent(indent + 2)
72 | for elem in mediaGroupTypeWhiteList {
73 | result += "- \(elem)\n".indent(indent + 4)
74 | }
75 | result += "keywordWhiteList:\n".indent(indent + 2)
76 | for elem in keywordWhiteList {
77 | result += "- \(elem)\n".indent(indent + 4)
78 | }
79 | result += "keywordBlackList:\n".indent(indent + 2)
80 | for elem in keywordBlackList {
81 | result += "- \(elem)\n".indent(indent + 4)
82 | }
83 |
84 | return result
85 | }
86 |
87 | }
88 |
--------------------------------------------------------------------------------
/PhotosExporter/preferences/model/Plan.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Plan.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 24.05.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | class Plan {
12 | public var enabled: Bool = true
13 | public var name: String?
14 |
15 | // exports derived resources for all assets (i.e. jpeg images instead of RAW)
16 | public var exportDerived: Bool?
17 |
18 | // exports the current assets (original if unmodified, otherwise the changed resource)
19 | public var exportCurrent: Bool?
20 |
21 | // exports the original assets
22 | public var exportOriginals: Bool?
23 |
24 |
25 | public var mediaObjectFilter = MediaObjectFilter()
26 |
27 | func getType() -> String {
28 | return String(describing: self)
29 | }
30 |
31 | func toYaml(indent: Int) -> String {
32 | var result: String = ""
33 | result += "type: \(getType())\n".indent(indent)
34 |
35 | // enabled == true is the default => only write false to Yaml
36 | if (!enabled) {
37 | result += "enabled: \(enabled)\n".indent(indent)
38 | }
39 |
40 | if let name = name {
41 | result += "name: \(name)\n".indent(indent)
42 | }
43 | if let exportDerived = exportDerived {
44 | result += "exportDerived: \(exportDerived)\n".indent(indent)
45 | }
46 | if let exportCurrent = exportCurrent {
47 | result += "exportCurrent: \(exportCurrent)\n".indent(indent)
48 | }
49 | if let exportOriginals = exportOriginals {
50 | result += "exportOriginals: \(exportOriginals)\n".indent(indent)
51 | }
52 | result += mediaObjectFilter.toYaml(indent: indent)
53 |
54 | return result
55 | }
56 |
57 | }
58 |
--------------------------------------------------------------------------------
/PhotosExporter/preferences/model/Preferences.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Preferences.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 24.05.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | class Preferences {
12 | public var plans: [Plan] = []
13 |
14 | func toYaml() -> String {
15 | var result = "---\n"
16 | result += "plans:\n"
17 | for plan in plans {
18 | result += "-\n".indent(2)
19 | result += "\(plan.toYaml(indent: 4))"
20 | }
21 | result = result.trimmingCharacters(in: CharacterSet.newlines)
22 | return result
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/PhotosExporter/ui/DataModel.xcdatamodeld/PreferencesDataModel.xcdatamodel/contents:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/PhotosExporter/ui/MainMenu.xib:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/PhotosExporter/ui/StatusMenuController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StatusMenuController.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 24.05.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Cocoa
10 |
11 | class StatusMenuController: NSObject {
12 |
13 | private let logger = Logger(loggerName: "StatusMenuController", logLevel: .info)
14 |
15 | @IBOutlet weak var statusMenu: NSMenu!
16 |
17 | let statusBarItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
18 |
19 | @IBOutlet weak var backupPlansMenuItem: NSMenuItem!
20 |
21 | var backupPlanMenuItems: [NSMenuItem] = []
22 |
23 | var preferencesWindowController: PreferencesWindowController?
24 |
25 | var preferences: Preferences?
26 |
27 | func updateMenu(preferences: Preferences) {
28 | self.preferences = preferences
29 |
30 | // remove previous items
31 | for menuItem in backupPlanMenuItems {
32 | statusMenu.removeItem(menuItem)
33 | }
34 | backupPlanMenuItems = []
35 |
36 | for plan in preferences.plans {
37 | if (plan.enabled) {
38 | var title: String
39 | if let name = plan.name {
40 | title = " " + name
41 | } else {
42 | title = " "
43 | }
44 | let menuItem : NSMenuItem = NSMenuItem(title: title, action: #selector(runExportTask(sender:)), keyEquivalent: "")
45 | menuItem.representedObject = plan
46 | menuItem.target = self
47 | //runSubmenu.addItem(menuItem)
48 | statusMenu.insertItem(menuItem, at: statusMenu.index(of: backupPlansMenuItem) + 1)
49 | backupPlanMenuItems.append(menuItem)
50 | }
51 | }
52 | }
53 |
54 | override func awakeFromNib() {
55 | statusBarItem.menu = statusMenu
56 |
57 | if let button = statusBarItem.button {
58 | button.image = NSImage(named: "statusBarIcon")
59 |
60 | // for dark mode, automatically invert the image
61 | button.image?.isTemplate = true
62 | }
63 |
64 | //preferencesClicked(self)
65 | }
66 |
67 | @objc func runExportTask(sender: AnyObject?) {
68 | if let menuItem = sender as? NSMenuItem, let plan = menuItem.representedObject as? Plan {
69 | logger.info("Start export using plan:\n\(plan.toYaml(indent: 10))")
70 |
71 | let photosMetadataReader = PhotosMetadataReader()
72 | photosMetadataReader.readMetadata(completion: {(photosMetadata: PhotosMetadata) in
73 | do {
74 | let photosExporter = try PhotosExporterFactory.createPhotosExporter(plan: plan)
75 | photosExporter.exportPhotos(photosMetadata: photosMetadata)
76 | } catch {
77 | print("Photos exporter could not be instantiated from preferences: \(String(describing: plan.name))")
78 | }
79 | });
80 | }
81 | }
82 |
83 | @IBAction func preferencesClicked(_ sender: Any) {
84 | if preferencesWindowController == nil {
85 | preferencesWindowController = PreferencesWindowController()
86 | }
87 | preferencesWindowController!.showWindow(nil)
88 | }
89 |
90 | @IBAction func quitClicked(_ sender: Any) {
91 | NSApplication.shared.terminate(self)
92 | }
93 |
94 | }
95 |
--------------------------------------------------------------------------------
/PhotosExporter/ui/assets/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "version" : 1,
4 | "author" : "xcode"
5 | }
6 | }
--------------------------------------------------------------------------------
/PhotosExporter/ui/assets/Assets.xcassets/statusBarIcon.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "filename" : "sync_black.png",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "filename" : "sync_black@2x.png",
11 | "scale" : "2x"
12 | },
13 | {
14 | "idiom" : "universal",
15 | "scale" : "3x"
16 | }
17 | ],
18 | "info" : {
19 | "version" : 1,
20 | "author" : "xcode"
21 | }
22 | }
--------------------------------------------------------------------------------
/PhotosExporter/ui/assets/Assets.xcassets/statusBarIcon.imageset/sync_black.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/abentele/PhotosExporter/3722dfdea4dc8d1427b7901a096a5508de7881ad/PhotosExporter/ui/assets/Assets.xcassets/statusBarIcon.imageset/sync_black.png
--------------------------------------------------------------------------------
/PhotosExporter/ui/assets/Assets.xcassets/statusBarIcon.imageset/sync_black@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/abentele/PhotosExporter/3722dfdea4dc8d1427b7901a096a5508de7881ad/PhotosExporter/ui/assets/Assets.xcassets/statusBarIcon.imageset/sync_black@2x.png
--------------------------------------------------------------------------------
/PhotosExporter/ui/preferences/GeneralSettingsView.xib:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/PhotosExporter/ui/preferences/GeneralSettingsViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GeneralSettingsController.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 11.06.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Cocoa
10 |
11 | class GeneralSettingsViewController: NSViewController {
12 |
13 | convenience init() {
14 | self.init(nibName: "GeneralSettingsView", bundle: nil);
15 | }
16 |
17 | override func viewDidLoad() {
18 | super.viewDidLoad()
19 | // Do view setup here.
20 | }
21 |
22 | }
23 |
--------------------------------------------------------------------------------
/PhotosExporter/ui/preferences/PlansView.xib:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
--------------------------------------------------------------------------------
/PhotosExporter/ui/preferences/PlansViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PlansController.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 11.06.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Cocoa
10 |
11 | class PlansViewController: NSViewController, NSTableViewDelegate, NSTableViewDataSource {
12 |
13 | convenience init() {
14 | self.init(nibName: "PlansView", bundle: nil);
15 | }
16 |
17 | override func viewDidLoad() {
18 | super.viewDidLoad()
19 | // Do view setup here.
20 | }
21 |
22 | }
23 |
--------------------------------------------------------------------------------
/PhotosExporter/ui/preferences/PreferencesWindow.xib:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/PhotosExporter/ui/preferences/PreferencesWindowController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PreferencesWindowController.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 11.06.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Cocoa
10 |
11 | class PreferencesWindowController: NSWindowController {
12 |
13 | var generalSettingsViewController: GeneralSettingsViewController?
14 | var plansViewController: PlansViewController?
15 | var currentView: NSView?
16 |
17 | @IBOutlet weak var toolBar: NSToolbar!
18 | @IBOutlet weak var generalToolbarButton: NSToolbarItem!
19 | @IBOutlet weak var plansToolbarButton: NSToolbarItem!
20 |
21 | override var windowNibName: String! {
22 | return "PreferencesWindow"
23 | }
24 |
25 | init() {
26 | super.init(window: nil) // Call this to get NSWindowController to init with the windowNibName property
27 | }
28 |
29 | // Override this as required per the class spec
30 | required init?(coder: NSCoder) {
31 | fatalError("init(coder:) has not been implemented. Use init()")
32 | }
33 |
34 | override func windowDidLoad() {
35 | super.windowDidLoad()
36 |
37 | toolbarButtonGeneralClicked(self)
38 | }
39 |
40 | @IBAction func toolbarButtonGeneralClicked(_ sender: Any) {
41 | if (generalSettingsViewController == nil) {
42 | generalSettingsViewController = GeneralSettingsViewController()
43 | }
44 | switchToView(viewController: generalSettingsViewController!, toolbarItem: generalToolbarButton)
45 | //toolBar.selectedItemIdentifier = generalToolbarButton.itemIdentifier
46 | }
47 |
48 | @IBAction func toolbarButtonPlansClicked(_ sender: Any) {
49 | if (plansViewController == nil) {
50 | plansViewController = PlansViewController()
51 | }
52 | switchToView(viewController: plansViewController!, toolbarItem: plansToolbarButton)
53 | //toolBar.selectedItemIdentifier = plansToolbarButton.itemIdentifier
54 | }
55 |
56 | func switchToView(viewController: NSViewController, toolbarItem: NSToolbarItem) {
57 | let view = viewController.view
58 |
59 | toolBar.selectedItemIdentifier = toolbarItem.itemIdentifier
60 |
61 | self.window!.title = toolbarItem.label
62 |
63 | if let currentView = currentView {
64 | currentView.removeFromSuperview()
65 | }
66 |
67 | var windowFrame: CGRect = self.window!.frame;
68 | let currentContentViewFrame = self.window!.contentView!.frame;
69 | let nextViewFrame = view.frame;
70 |
71 | let wd: CGFloat = NSWidth(currentContentViewFrame) - NSWidth(nextViewFrame);
72 | let hd: CGFloat = NSHeight(currentContentViewFrame) - NSHeight(nextViewFrame);
73 |
74 | if(hd < 0) {
75 | windowFrame.size.height += hd * -1;
76 | windowFrame.origin.y -= hd * -1;
77 | } else {
78 | windowFrame.size.height -= hd;
79 | windowFrame.origin.y += hd;
80 | }
81 |
82 | if(wd < 0) {
83 | windowFrame.size.width += wd * -1;
84 | } else {
85 | windowFrame.size.width -= wd;
86 | }
87 |
88 | self.window!.setFrame(windowFrame, display:true, animate:true)
89 |
90 | self.window!.contentView!.addSubview(view);
91 |
92 | currentView = view
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/PhotosExporter/util/Errors.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Errors.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 01.04.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum FileNotFoundException: Error {
12 | case fileNotFound
13 | }
14 |
--------------------------------------------------------------------------------
/PhotosExporter/util/Logger.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Logger.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 10.03.18.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum LogLevel: String {
12 | case debug
13 | case info
14 | case warn
15 | case error
16 | }
17 |
18 | /**
19 | * Simple Logger Utility class.
20 | */
21 | class Logger {
22 |
23 | var dateFormatter = DateFormatter()
24 | var loggerName: String = ""
25 | public var logLevel = LogLevel.info
26 |
27 | init() {
28 | dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
29 | }
30 |
31 | convenience init(loggerName: String, logLevel: LogLevel) {
32 | self.init()
33 | self.loggerName = loggerName
34 | self.logLevel = logLevel
35 | }
36 |
37 | private func normalizeLength(text: String, length: Int) -> String {
38 | var result = text
39 | if result.count > length {
40 | result = String(result[...result.index(result.startIndex, offsetBy: length)])
41 | }
42 | while result.count < length {
43 | result = result + " "
44 | }
45 | return result
46 | }
47 |
48 | private func printFormatted(_ level: LogLevel, _ text: String) {
49 | print("\(dateFormatter.string(from: Date())) \(normalizeLength(text: loggerName, length: 15)) \(normalizeLength(text: level.rawValue, length: 8)) \(text)")
50 | }
51 |
52 | func isDebugEnabled() -> Bool {
53 | return logLevel == .debug
54 | }
55 |
56 | func debug(_ text: String) {
57 | if logLevel == .debug {
58 | printFormatted(.debug, text)
59 | }
60 | }
61 |
62 | func info(_ text: String) {
63 | if logLevel == .debug || logLevel == .info {
64 | printFormatted(.info, text)
65 | }
66 | }
67 |
68 | func warn(_ text: String) {
69 | if logLevel == .debug || logLevel == .info || logLevel == .warn {
70 | printFormatted(.warn, text)
71 | }
72 | }
73 |
74 | func error(_ text: String) {
75 | printFormatted(.error, text)
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/PhotosExporter/util/StopWatch.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StopWatch.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 01.03.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /**
12 | * Stop watch to measure and print elapsed time.
13 | *
14 | * let stopWatch = StopWatch("", logLevel)
15 | * stopWatch.start()
16 | * ...do something...
17 | * stopWatch.stop()
18 | */
19 | class StopWatch {
20 |
21 | var logger: Logger
22 |
23 | var description: String
24 | var begin: TimeInterval = 0
25 | var running = false
26 |
27 | let WINDOW = 100
28 |
29 | // values for this window
30 | var fileSizes: [UInt64]?
31 | var durationSeconds = [Double]()
32 |
33 | init(_ description: String, _ logLevel: LogLevel, addFileSizes: Bool) {
34 | self.description = description
35 | self.logger = Logger(loggerName: "StopWatch", logLevel: logLevel)
36 | if addFileSizes {
37 | self.fileSizes = [UInt64]()
38 | }
39 | }
40 |
41 | func start() {
42 | start(fileSizeFn: { return 0})
43 | }
44 |
45 | func start(fileSizeFn: () throws -> UInt64) {
46 | if logger.isDebugEnabled() {
47 | if running {
48 | logger.warn("StopWatch \(description): called start before stop")
49 | }
50 | running = true
51 | begin = ProcessInfo.processInfo.systemUptime
52 |
53 | if let fileSizes = self.fileSizes {
54 | do {
55 | let fileSize = try fileSizeFn()
56 | self.fileSizes = fileSizes + [fileSize]
57 | }
58 | catch let error as NSError {
59 | logger.error("Unable to determine file size: \(error)")
60 | }
61 | if self.fileSizes!.count > WINDOW {
62 | self.fileSizes!.remove(at: 0)
63 | }
64 | }
65 | }
66 | }
67 |
68 | func stop() {
69 | if logger.isDebugEnabled() {
70 | if !running {
71 | logger.warn("StopWatch \(description): didn't call start before calling stop")
72 | }
73 |
74 | running = false
75 |
76 | let diffSeconds = ProcessInfo.processInfo.systemUptime - begin
77 | let diffMillisAsStr = String(format: "%.1f", diffSeconds*1000.0)
78 |
79 | durationSeconds = durationSeconds + [diffSeconds]
80 | if durationSeconds.count > WINDOW {
81 | durationSeconds.remove(at: 0)
82 | }
83 |
84 | var accumulatedTimeSeconds: Double = 0
85 | for d in durationSeconds {
86 | accumulatedTimeSeconds = accumulatedTimeSeconds + d
87 | }
88 | let accumulatedAsStr = String(format: "%.1f", accumulatedTimeSeconds*1000.0)
89 |
90 | let count = durationSeconds.count
91 |
92 | let avg = accumulatedTimeSeconds / Double(count)
93 | let avgAsStr = String(format: "%.1f", avg*1000.0)
94 |
95 | if let fileSizes = self.fileSizes {
96 | var accumulatedFileSize: UInt64 = 0
97 | for fileSize in fileSizes {
98 | accumulatedFileSize = accumulatedFileSize + fileSize
99 | }
100 |
101 | let mbPerSecondAccumulated = Double(accumulatedFileSize) / 1024 / 1024 / accumulatedTimeSeconds
102 | let mbPerSecondAccumulatedAsStr = String(format: "%.1f", mbPerSecondAccumulated)
103 | logger.debug("\(description): \(diffMillisAsStr)ms; accumulated: \(accumulatedAsStr); count: \(count); avg time: \(avgAsStr)ms; \(mbPerSecondAccumulatedAsStr) MB/s")
104 | }
105 | else {
106 | if (count == 1) {
107 | logger.debug("\(description): \(diffMillisAsStr)ms; accumulated: \(accumulatedAsStr); count: \(count); avg time: \(avgAsStr)ms")
108 | } else {
109 | logger.debug("\(description): \(diffMillisAsStr)ms")
110 | }
111 | }
112 |
113 | }
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/PhotosExporter/util/String+extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // String+extensions.swift
3 | // PhotosExporter
4 | //
5 | // Created by Andreas Bentele on 02.03.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension String {
12 |
13 | // hash(0) = 5381
14 | // hash(i) = hash(i - 1) * 33 ^ str[i];
15 | var djb2hash: Int {
16 | let unicodeScalars = self.unicodeScalars.map { $0.value }
17 | return unicodeScalars.reduce(5381) {
18 | ($0 << 5) &+ $0 &+ Int($1)
19 | }
20 | }
21 |
22 | func indent(_ count: Int) -> String {
23 | var result: String = "\(self)"
24 | if (count > 0) {
25 | for _ in 1...count {
26 | result = " \(result)"
27 | }
28 | }
29 | return result
30 | }
31 |
32 | }
33 |
--------------------------------------------------------------------------------
/PreferencesReaderTest/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | BNDL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/PreferencesReaderTest/PreferencesReaderTest.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PreferencesReaderTest.swift
3 | // PreferencesReaderTest
4 | //
5 | // Created by Andreas Bentele on 25.05.19.
6 | // Copyright © 2021 Andreas Bentele. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import PhotosExporter
11 |
12 | class PreferencesReaderTest: XCTestCase {
13 |
14 | let yaml0 = """
15 | ---
16 | plans:
17 | """
18 |
19 | let yaml1 = """
20 | ---
21 | plans:
22 | -
23 | type: IncrementalFileSystemExport
24 | name: Incremental export example
25 | mediaObjectFilter:
26 | mediaGroupTypeWhiteList:
27 | keywordWhiteList:
28 | keywordBlackList:
29 | targetFolder: /Volumes/test
30 | """
31 |
32 | let yaml2 = """
33 | ---
34 | plans:
35 | -
36 | type: IncrementalFileSystemExport
37 | name: Incremental export example
38 | mediaObjectFilter:
39 | mediaGroupTypeWhiteList:
40 | - com.apple.Photos.Album
41 | - com.apple.Photos.SmartAlbum
42 | - com.apple.Photos.CollectionGroup
43 | - com.apple.Photos.MomentGroup
44 | - com.apple.Photos.YearGroup
45 | - com.apple.Photos.PlacesCountryAlbum
46 | - com.apple.Photos.PlacesProvinceAlbum
47 | - com.apple.Photos.PlacesCityAlbum
48 | - com.apple.Photos.PlacesPointOfInterestAlbum
49 | - com.apple.Photos.FacesAlbum
50 | - com.apple.Photos.VideosGroup
51 | - com.apple.Photos.FrontCameraGroup
52 | - com.apple.Photos.PanoramasGroup
53 | - com.apple.Photos.BurstGroup
54 | - com.apple.Photos.ScreenshotGroup
55 | keywordWhiteList:
56 | keywordBlackList:
57 | targetFolder: /Volumes/test
58 | -
59 | type: GooglePhotosExport
60 | name: Google photos example
61 | mediaObjectFilter:
62 | mediaGroupTypeWhiteList:
63 | - com.apple.Photos.Album
64 | - com.apple.Photos.SmartAlbum
65 | - com.apple.Photos.CollectionGroup
66 | - com.apple.Photos.MomentGroup
67 | - com.apple.Photos.YearGroup
68 | - com.apple.Photos.PlacesCountryAlbum
69 | - com.apple.Photos.PlacesProvinceAlbum
70 | - com.apple.Photos.PlacesCityAlbum
71 | - com.apple.Photos.PlacesPointOfInterestAlbum
72 | - com.apple.Photos.FacesAlbum
73 | - com.apple.Photos.VideosGroup
74 | - com.apple.Photos.FrontCameraGroup
75 | - com.apple.Photos.PanoramasGroup
76 | - com.apple.Photos.BurstGroup
77 | - com.apple.Photos.ScreenshotGroup
78 | keywordWhiteList:
79 | keywordBlackList:
80 | """
81 |
82 | let yamlEnabledFalse = """
83 | ---
84 | plans:
85 | -
86 | type: IncrementalFileSystemExport
87 | enabled: false
88 | name: Incremental export example
89 | mediaObjectFilter:
90 | mediaGroupTypeWhiteList:
91 | - com.apple.Photos.Album
92 | - com.apple.Photos.SmartAlbum
93 | - com.apple.Photos.CollectionGroup
94 | - com.apple.Photos.MomentGroup
95 | - com.apple.Photos.YearGroup
96 | - com.apple.Photos.PlacesCountryAlbum
97 | - com.apple.Photos.PlacesProvinceAlbum
98 | - com.apple.Photos.PlacesCityAlbum
99 | - com.apple.Photos.PlacesPointOfInterestAlbum
100 | - com.apple.Photos.FacesAlbum
101 | - com.apple.Photos.VideosGroup
102 | - com.apple.Photos.FrontCameraGroup
103 | - com.apple.Photos.PanoramasGroup
104 | - com.apple.Photos.BurstGroup
105 | - com.apple.Photos.ScreenshotGroup
106 | keywordWhiteList:
107 | keywordBlackList:
108 | """
109 |
110 | let yamlEnabledTrue = """
111 | ---
112 | plans:
113 | -
114 | type: IncrementalFileSystemExport
115 | name: Incremental export example
116 | mediaObjectFilter:
117 | mediaGroupTypeWhiteList:
118 | - com.apple.Photos.Album
119 | - com.apple.Photos.SmartAlbum
120 | - com.apple.Photos.CollectionGroup
121 | - com.apple.Photos.MomentGroup
122 | - com.apple.Photos.YearGroup
123 | - com.apple.Photos.PlacesCountryAlbum
124 | - com.apple.Photos.PlacesProvinceAlbum
125 | - com.apple.Photos.PlacesCityAlbum
126 | - com.apple.Photos.PlacesPointOfInterestAlbum
127 | - com.apple.Photos.FacesAlbum
128 | - com.apple.Photos.VideosGroup
129 | - com.apple.Photos.FrontCameraGroup
130 | - com.apple.Photos.PanoramasGroup
131 | - com.apple.Photos.BurstGroup
132 | - com.apple.Photos.ScreenshotGroup
133 | keywordWhiteList:
134 | keywordBlackList:
135 | """
136 |
137 | let yamlExportCurrent = """
138 | ---
139 | plans:
140 | -
141 | type: IncrementalFileSystemExport
142 | name: Incremental export example
143 | exportCurrent: true
144 | mediaObjectFilter:
145 | mediaGroupTypeWhiteList:
146 | - com.apple.Photos.Album
147 | - com.apple.Photos.SmartAlbum
148 | - com.apple.Photos.CollectionGroup
149 | - com.apple.Photos.MomentGroup
150 | - com.apple.Photos.YearGroup
151 | - com.apple.Photos.PlacesCountryAlbum
152 | - com.apple.Photos.PlacesProvinceAlbum
153 | - com.apple.Photos.PlacesCityAlbum
154 | - com.apple.Photos.PlacesPointOfInterestAlbum
155 | - com.apple.Photos.FacesAlbum
156 | - com.apple.Photos.VideosGroup
157 | - com.apple.Photos.FrontCameraGroup
158 | - com.apple.Photos.PanoramasGroup
159 | - com.apple.Photos.BurstGroup
160 | - com.apple.Photos.ScreenshotGroup
161 | keywordWhiteList:
162 | keywordBlackList:
163 | targetFolder: /Volumes/test
164 | """
165 |
166 | let yamlExportOriginals = """
167 | ---
168 | plans:
169 | -
170 | type: IncrementalFileSystemExport
171 | name: Incremental export example
172 | exportOriginals: false
173 | mediaObjectFilter:
174 | mediaGroupTypeWhiteList:
175 | - com.apple.Photos.Album
176 | - com.apple.Photos.SmartAlbum
177 | - com.apple.Photos.CollectionGroup
178 | - com.apple.Photos.MomentGroup
179 | - com.apple.Photos.YearGroup
180 | - com.apple.Photos.PlacesCountryAlbum
181 | - com.apple.Photos.PlacesProvinceAlbum
182 | - com.apple.Photos.PlacesCityAlbum
183 | - com.apple.Photos.PlacesPointOfInterestAlbum
184 | - com.apple.Photos.FacesAlbum
185 | - com.apple.Photos.VideosGroup
186 | - com.apple.Photos.FrontCameraGroup
187 | - com.apple.Photos.PanoramasGroup
188 | - com.apple.Photos.BurstGroup
189 | - com.apple.Photos.ScreenshotGroup
190 | keywordWhiteList:
191 | keywordBlackList:
192 | targetFolder: /Volumes/test
193 | """
194 |
195 | let yamlBaseExportPath = """
196 | ---
197 | plans:
198 | -
199 | type: IncrementalFileSystemExport
200 | name: Incremental export example
201 | mediaObjectFilter:
202 | mediaGroupTypeWhiteList:
203 | - com.apple.Photos.Album
204 | - com.apple.Photos.SmartAlbum
205 | - com.apple.Photos.CollectionGroup
206 | - com.apple.Photos.MomentGroup
207 | - com.apple.Photos.YearGroup
208 | - com.apple.Photos.PlacesCountryAlbum
209 | - com.apple.Photos.PlacesProvinceAlbum
210 | - com.apple.Photos.PlacesCityAlbum
211 | - com.apple.Photos.PlacesPointOfInterestAlbum
212 | - com.apple.Photos.FacesAlbum
213 | - com.apple.Photos.VideosGroup
214 | - com.apple.Photos.FrontCameraGroup
215 | - com.apple.Photos.PanoramasGroup
216 | - com.apple.Photos.BurstGroup
217 | - com.apple.Photos.ScreenshotGroup
218 | keywordWhiteList:
219 | keywordBlackList:
220 | baseExportPath: /Volumes/base
221 | """
222 |
223 | let yamlDeleteFlatPath = """
224 | ---
225 | plans:
226 | -
227 | type: SnapshotFileSystemExport
228 | name: Snapshot export example
229 | mediaObjectFilter:
230 | mediaGroupTypeWhiteList:
231 | - com.apple.Photos.Album
232 | - com.apple.Photos.SmartAlbum
233 | - com.apple.Photos.CollectionGroup
234 | - com.apple.Photos.MomentGroup
235 | - com.apple.Photos.YearGroup
236 | - com.apple.Photos.PlacesCountryAlbum
237 | - com.apple.Photos.PlacesProvinceAlbum
238 | - com.apple.Photos.PlacesCityAlbum
239 | - com.apple.Photos.PlacesPointOfInterestAlbum
240 | - com.apple.Photos.FacesAlbum
241 | - com.apple.Photos.VideosGroup
242 | - com.apple.Photos.FrontCameraGroup
243 | - com.apple.Photos.PanoramasGroup
244 | - com.apple.Photos.BurstGroup
245 | - com.apple.Photos.ScreenshotGroup
246 | keywordWhiteList:
247 | keywordBlackList:
248 | deleteFlatPath: true
249 | """
250 |
251 | let yamlMediaObjectFilter = """
252 | ---
253 | plans:
254 | -
255 | type: IncrementalFileSystemExport
256 | name: Incremental export example
257 | mediaObjectFilter:
258 | mediaGroupTypeWhiteList:
259 | - com.apple.Photos.Album
260 | - com.apple.Photos.SmartAlbum
261 | keywordWhiteList:
262 | - photos-for-my-dad
263 | - photos-for-linda
264 | keywordBlackList:
265 | - dont-export
266 | - private
267 | targetFolder: /Volumes/test
268 | """
269 |
270 | let mediaGroupTypeWhiteList = [
271 | "com.apple.Photos.Album",
272 | "com.apple.Photos.SmartAlbum"
273 | ]
274 |
275 | let keywordWhiteList = [
276 | "photos-for-my-dad",
277 | "photos-for-linda"
278 | ]
279 |
280 | let keywordBlackList = [
281 | "dont-export",
282 | "private"
283 | ]
284 |
285 | let invalidYaml = """
286 | invalid
287 | """
288 |
289 | let yamlPlanWithoutType = """
290 | ---
291 | plans:
292 | -
293 | name: Incremental export example
294 | """
295 |
296 | let yamlInvalidPlanType = """
297 | ---
298 | plans:
299 | -
300 | type: invalid-type
301 | name: Incremental export example
302 | """
303 |
304 |
305 | /**
306 | * Test serialization of an empty preferences object
307 | */
308 | func testSerialize0() {
309 | let preferences = Preferences()
310 |
311 | let yamlStr = preferences.toYaml()
312 |
313 | XCTAssertEqual(yaml0, yamlStr, "Yaml string not as expected")
314 | }
315 |
316 | /**
317 | * Test deserialization of an empty preferences object
318 | */
319 | func testDeserialize0() throws {
320 | let preferences = try PreferencesReader.preferencesFromYaml(yamlStr: yaml0)
321 |
322 | XCTAssertEqual(0, preferences.plans.count)
323 | }
324 |
325 | /**
326 | * Test serialization of a preferences object with exactly one plan
327 | */
328 | func testSerialize1() {
329 | let preferences = Preferences()
330 | let plan = IncrementalFileSystemExportPlan()
331 | plan.name = "Incremental export example"
332 | plan.targetFolder = "/Volumes/test"
333 | plan.mediaObjectFilter.mediaGroupTypeWhiteList = []
334 | preferences.plans.append(plan)
335 |
336 | let yamlStr = preferences.toYaml()
337 |
338 | XCTAssertEqual(yaml1, yamlStr, "Yaml string not as expected")
339 | }
340 |
341 | /**
342 | * Test deserialization of a preferences object with exactly one plan
343 | */
344 | func testDeserialize1() throws {
345 | let preferences = try PreferencesReader.preferencesFromYaml(yamlStr: yaml1)
346 |
347 | XCTAssertEqual(1, preferences.plans.count)
348 | XCTAssertEqual("IncrementalFileSystemExport", preferences.plans[0].getType())
349 | XCTAssertEqual("Incremental export example", preferences.plans[0].name)
350 | XCTAssertEqual("/Volumes/test", (preferences.plans[0] as! FileSystemExportPlan).targetFolder)
351 | XCTAssertEqual(nil, preferences.plans[0].exportCurrent)
352 | XCTAssertEqual(nil, preferences.plans[0].exportOriginals)
353 | XCTAssertEqual([], preferences.plans[0].mediaObjectFilter.mediaGroupTypeWhiteList)
354 | XCTAssertEqual([], preferences.plans[0].mediaObjectFilter.keywordWhiteList)
355 | XCTAssertEqual([], preferences.plans[0].mediaObjectFilter.keywordBlackList)
356 | }
357 |
358 | /**
359 | * Test serialization of a preferences object with two plans of different type
360 | */
361 | func testSerialize2() {
362 | let preferences = Preferences()
363 |
364 | let plan1 = IncrementalFileSystemExportPlan()
365 | plan1.name = "Incremental export example"
366 | plan1.targetFolder = "/Volumes/test"
367 | preferences.plans.append(plan1)
368 |
369 | let plan2 = GooglePhotosExportPlan()
370 | plan2.name = "Google photos example"
371 | preferences.plans.append(plan2)
372 |
373 | let yamlStr = preferences.toYaml()
374 |
375 | XCTAssertEqual(yaml2, yamlStr, "Yaml string not as expected")
376 | }
377 |
378 | /**
379 | * Test deserialization of a preferences object with two plans of different type
380 | */
381 | func testDeserialize2() throws {
382 | let preferences = try PreferencesReader.preferencesFromYaml(yamlStr: yaml2)
383 |
384 | XCTAssertEqual(2, preferences.plans.count)
385 |
386 | XCTAssertEqual("IncrementalFileSystemExport", preferences.plans[0].getType())
387 | XCTAssertEqual("Incremental export example", preferences.plans[0].name)
388 | XCTAssertEqual("/Volumes/test", (preferences.plans[0] as! FileSystemExportPlan).targetFolder)
389 | XCTAssertEqual(nil, preferences.plans[0].exportCurrent)
390 | XCTAssertEqual(nil, preferences.plans[0].exportOriginals)
391 |
392 | XCTAssertEqual("GooglePhotosExport", preferences.plans[1].getType())
393 | XCTAssertEqual("Google photos example", preferences.plans[1].name)
394 | }
395 |
396 | /**
397 | * Test serialization of the "enabled" attribute
398 | */
399 | func testSerializeEnabledFalse() {
400 | let preferences = Preferences()
401 | let plan = IncrementalFileSystemExportPlan()
402 | plan.name = "Incremental export example"
403 | plan.enabled = false
404 | preferences.plans.append(plan)
405 |
406 | let yamlStr = preferences.toYaml()
407 |
408 | XCTAssertEqual(yamlEnabledFalse, yamlStr, "Yaml string not as expected")
409 | }
410 |
411 | /**
412 | * Test deserialization of the "exportCurrent" attribute
413 | */
414 | func testDeserializeEnabledFalse() throws {
415 | let preferences = try PreferencesReader.preferencesFromYaml(yamlStr: yamlEnabledFalse)
416 |
417 | XCTAssertEqual(1, preferences.plans.count)
418 |
419 | XCTAssertEqual("IncrementalFileSystemExport", preferences.plans[0].getType())
420 | XCTAssertEqual("Incremental export example", preferences.plans[0].name)
421 | XCTAssertEqual(false, preferences.plans[0].enabled)
422 | }
423 |
424 | /**
425 | * Test serialization of the "enabled" attribute
426 | */
427 | func testSerializeEnabledTrue() {
428 | let preferences = Preferences()
429 | let plan = IncrementalFileSystemExportPlan()
430 | plan.name = "Incremental export example"
431 | plan.enabled = true
432 | preferences.plans.append(plan)
433 |
434 | let yamlStr = preferences.toYaml()
435 |
436 | XCTAssertEqual(yamlEnabledTrue, yamlStr, "Yaml string not as expected")
437 | }
438 |
439 | /**
440 | * Test deserialization of the "exportCurrent" attribute
441 | */
442 | func testDeserializeEnabledTrue() throws {
443 | let preferences = try PreferencesReader.preferencesFromYaml(yamlStr: yamlEnabledTrue)
444 |
445 | XCTAssertEqual(1, preferences.plans.count)
446 |
447 | XCTAssertEqual("IncrementalFileSystemExport", preferences.plans[0].getType())
448 | XCTAssertEqual("Incremental export example", preferences.plans[0].name)
449 | XCTAssertEqual(true, preferences.plans[0].enabled)
450 | }
451 |
452 |
453 |
454 | /**
455 | * Test serialization of the "exportCurrent" attribute
456 | */
457 | func testSerializeExportCurrent() {
458 | let preferences = Preferences()
459 | let plan = IncrementalFileSystemExportPlan()
460 | plan.name = "Incremental export example"
461 | plan.targetFolder = "/Volumes/test"
462 | plan.exportCurrent = true
463 | preferences.plans.append(plan)
464 |
465 | let yamlStr = preferences.toYaml()
466 |
467 | XCTAssertEqual(yamlExportCurrent, yamlStr, "Yaml string not as expected")
468 | }
469 |
470 | /**
471 | * Test deserialization of the "exportCurrent" attribute
472 | */
473 | func testDeserializeExportCurrent() throws {
474 | let preferences = try PreferencesReader.preferencesFromYaml(yamlStr: yamlExportCurrent)
475 |
476 | XCTAssertEqual(1, preferences.plans.count)
477 |
478 | XCTAssertEqual("IncrementalFileSystemExport", preferences.plans[0].getType())
479 | XCTAssertEqual("Incremental export example", preferences.plans[0].name)
480 | XCTAssertEqual("/Volumes/test", (preferences.plans[0] as! FileSystemExportPlan).targetFolder)
481 | XCTAssertEqual(true, preferences.plans[0].exportCurrent)
482 | XCTAssertEqual(nil, preferences.plans[0].exportOriginals)
483 | }
484 |
485 | /**
486 | * Test serialization of the "exportOriginals" attribute
487 | */
488 | func testSerializeExportOriginals() {
489 | let preferences = Preferences()
490 | let plan = IncrementalFileSystemExportPlan()
491 | plan.name = "Incremental export example"
492 | plan.targetFolder = "/Volumes/test"
493 | plan.exportOriginals = false
494 | preferences.plans.append(plan)
495 |
496 | let yamlStr = preferences.toYaml()
497 |
498 | XCTAssertEqual(yamlExportOriginals, yamlStr, "Yaml string not as expected")
499 | }
500 |
501 | /**
502 | * Test deserialization of the "exportOriginals" attribute
503 | */
504 | func testDeserializeExportOriginals() throws {
505 | let preferences = try PreferencesReader.preferencesFromYaml(yamlStr: yamlExportOriginals)
506 |
507 | XCTAssertEqual(1, preferences.plans.count)
508 |
509 | XCTAssertEqual("IncrementalFileSystemExport", preferences.plans[0].getType())
510 | XCTAssertEqual("Incremental export example", preferences.plans[0].name)
511 | XCTAssertEqual("/Volumes/test", (preferences.plans[0] as! FileSystemExportPlan).targetFolder)
512 | XCTAssertEqual(nil, preferences.plans[0].exportCurrent)
513 | XCTAssertEqual(false, preferences.plans[0].exportOriginals)
514 | }
515 |
516 | /**
517 | * Test serialization of the "baseExportPath" attribute
518 | */
519 | func testSerializeBaseExportPath() {
520 | let preferences = Preferences()
521 | let plan = IncrementalFileSystemExportPlan()
522 | plan.name = "Incremental export example"
523 | plan.baseExportPath = "/Volumes/base"
524 | preferences.plans.append(plan)
525 |
526 | let yamlStr = preferences.toYaml()
527 |
528 | XCTAssertEqual(yamlBaseExportPath, yamlStr, "Yaml string not as expected")
529 | }
530 |
531 | /**
532 | * Test deserialization of the "baseExportPath" attribute
533 | */
534 | func testDeserializeBaseExportPath() throws {
535 | let preferences = try PreferencesReader.preferencesFromYaml(yamlStr: yamlBaseExportPath)
536 |
537 | XCTAssertEqual(1, preferences.plans.count)
538 |
539 | XCTAssertEqual("IncrementalFileSystemExport", preferences.plans[0].getType())
540 | XCTAssertEqual("Incremental export example", preferences.plans[0].name)
541 | XCTAssertEqual("/Volumes/base", (preferences.plans[0] as! IncrementalFileSystemExportPlan).baseExportPath)
542 | }
543 |
544 | /**
545 | * Test serialization of the "deleteFlatPath" attribute
546 | */
547 | func testSerializeDeleteFlatPath() {
548 | let preferences = Preferences()
549 | let plan = SnapshotFileSystemExportPlan()
550 | plan.name = "Snapshot export example"
551 | plan.deleteFlatPath = true
552 | preferences.plans.append(plan)
553 |
554 | let yamlStr = preferences.toYaml()
555 |
556 | XCTAssertEqual(yamlDeleteFlatPath, yamlStr, "Yaml string not as expected")
557 | }
558 |
559 | /**
560 | * Test deserialization of the "deleteFlatPath" attribute
561 | */
562 | func testDeserializeDeleteFlatPath() throws {
563 | let preferences = try PreferencesReader.preferencesFromYaml(yamlStr: yamlDeleteFlatPath)
564 |
565 | XCTAssertEqual(1, preferences.plans.count)
566 |
567 | XCTAssertEqual("SnapshotFileSystemExport", preferences.plans[0].getType())
568 | XCTAssertEqual("Snapshot export example", preferences.plans[0].name)
569 | XCTAssertEqual(true, (preferences.plans[0] as! SnapshotFileSystemExportPlan).deleteFlatPath)
570 | }
571 |
572 | /**
573 | * Test serialization of the "deleteFlatPath" attribute
574 | */
575 | func testSerializeMediaObjectFilter() {
576 | let preferences = Preferences()
577 | let plan = IncrementalFileSystemExportPlan()
578 | plan.name = "Incremental export example"
579 | plan.targetFolder = "/Volumes/test"
580 | plan.mediaObjectFilter.mediaGroupTypeWhiteList = mediaGroupTypeWhiteList
581 | plan.mediaObjectFilter.keywordWhiteList = keywordWhiteList
582 | plan.mediaObjectFilter.keywordBlackList = keywordBlackList
583 | preferences.plans.append(plan)
584 |
585 | let yamlStr = preferences.toYaml()
586 |
587 | XCTAssertEqual(yamlMediaObjectFilter, yamlStr, "Yaml string not as expected")
588 | }
589 |
590 |
591 | /**
592 | * Test deserialization of the "mediaObjectFilter" attribute
593 | */
594 | func testDeserializeMediaObjectFilter() throws {
595 | let preferences = try PreferencesReader.preferencesFromYaml(yamlStr: yamlMediaObjectFilter)
596 |
597 | XCTAssertEqual(1, preferences.plans.count)
598 |
599 | XCTAssertEqual("IncrementalFileSystemExport", preferences.plans[0].getType())
600 | XCTAssertEqual("Incremental export example", preferences.plans[0].name)
601 | XCTAssertEqual("/Volumes/test", (preferences.plans[0] as! IncrementalFileSystemExportPlan).targetFolder)
602 | XCTAssertEqual(mediaGroupTypeWhiteList, preferences.plans[0].mediaObjectFilter.mediaGroupTypeWhiteList)
603 | XCTAssertEqual(keywordWhiteList, preferences.plans[0].mediaObjectFilter.keywordWhiteList)
604 | XCTAssertEqual(keywordBlackList, preferences.plans[0].mediaObjectFilter.keywordBlackList)
605 | }
606 |
607 |
608 | /**
609 | * Test behavior of deserializing an invalid Yaml string.
610 | */
611 | func testDeserializeInvalidYaml() {
612 | XCTAssertThrowsError(try PreferencesReader.preferencesFromYaml(yamlStr: invalidYaml)) { error in
613 | XCTAssertEqual(error as! PreferencesReaderError, PreferencesReaderError.invalidYaml)
614 | }
615 | }
616 |
617 | /**
618 | * Test behavior of deserializing a plan without type.
619 | */
620 | func testDeserializePlanWithoutType() {
621 | XCTAssertThrowsError(try PreferencesReader.preferencesFromYaml(yamlStr: yamlPlanWithoutType)) { error in
622 | XCTAssertEqual(error as! PreferencesReaderError, PreferencesReaderError.invalidOrNoPlanType)
623 | }
624 | }
625 |
626 | /**
627 | * Test behavior of deserializing a plan with an invalid type.
628 | */
629 | func testDeserializeInvalidPlanType() {
630 | XCTAssertThrowsError(try PreferencesReader.preferencesFromYaml(yamlStr: yamlInvalidPlanType)) { error in
631 | XCTAssertEqual(error as! PreferencesReaderError, PreferencesReaderError.invalidOrNoPlanType)
632 | }
633 | }
634 | }
635 |
--------------------------------------------------------------------------------
/Readme.md:
--------------------------------------------------------------------------------
1 | October 2024, Andreas Bentele
2 |
3 | # PhotosExporter
4 |
5 | This is a headless program (with command line interface) to export all photos of the macOS Photos library to a filesystem folder. Like Apple's Time Machine, it backup's the data in folders containing full backups, while using hard links to minimize disk usage.
6 |
7 | You may ask: why another backup solution in addition to Time Machine, which already backups my Photos library?
8 | The answer is: Time Machine is one of the best backup solutions I know. But the macOS Photos Library has it's very special database structure - it's in parts file system based, but not intended to open it with any other program than Apple's Photos. Time Machine does nothing else but backup this database to an external disk. Backups are not portable to another system other than a Mac with Photos.
9 |
10 | Using Time Machine to backup my photos is what I've done for many years till I lost some of the most important photos in my Photos library (maybe by own mistake). Because of Time Machine automatically removes old backups to get new disk space, the photos were also deleted from my backups. Fortunately I found the photos in a backup of my old Aperture library. This opened my eyes, so I decided to implement a small program which implements the following requirements, partly inspired by how Time Machine works:
11 |
12 | * export all original and modified photos
13 | * generate a human readable folder structure containing all folders and albums from the Photos library (usable with any other program on any platform)
14 | * full exports: each export creates a new folder named after the current date
15 | * make use of hard links whenever possible instead of copying files, to minimize additional disk usage e.g. due to consecutive full backups or photos contained in several albums
16 | * never delete old backups (I want to be able to decide which old backups should be deleted, or if I want to buy a disk with more space for my backups)
17 | * performance and robustness: the backup of my around 30.000 photos should be done in less than an hour (currently it takes around 30 minutes)
18 | * keep it simple: no complex interface to configure things, no complex configuration
19 | * provide two export modes: 1. Time Machine like backup, 2. snapshot export with no extra disk usage if export is on same file system
20 |
21 | There are two possible general use cases:
22 | * **SnapshotPhotosExporter**: export all your photos, e.g. to share them with other programs or devices like your TV or other non-Apple devices, share them in the cloud etc.; if the export folder is on the same file system as the Photos Library, there is no extra disk usage
23 | * **IncrementalPhotosExporter**: backup all your photos while keeping the previous backups, like with Time Machine (I would suggest to add a cron job to trigger it daily or weekly)
24 |
25 | You also can combine both: export to local disk using SnapshotPhotosExporter. Make a backup using Time Machine to an external disk. Then use the IncrementalPhotosExporter to export the photos to the same external disk while using hard links to the already exported photos in the Time Machine backup instead of copying all photos again (see parameter baseExportPath for more details).
26 |
27 | I believe some other people who think backups are very important could make use of it as well, so I've decided to make the code open source. Any feedback is appreciated, especially pull requests with improvements.
28 |
29 |
30 | # History
31 |
32 | This program was not my first try. I've tried out some other solutions before, that didn't work at all:
33 |
34 | * export photos within the Photos app; this didn't work because of several annoying bugs of Photos (I've already reported the bugs to Apple). Also the export in Photos doesn't fulfill the requirements discussed above.
35 | * searched for another existing external solution including scripts, Objective-C or Swift based programs posted in the internet (and github); there were no real existing solutions but only some discussions on the same topic which showed me that this was not only a problem of me. I refused to use solutions which depend highly on re-engineering the photos sqlite database, because of they tend to break with each new Photos update.
36 | * then I've written a script using the Applescript language which automates the Photos app and calls shell scripts to do the file system based things; while the script was rather expressive and short, it observed very bad performance and bad robustness because of annoying bugs in the Photos app and Apple's scripting architecture as well. Also the Photos API was rather limited at the time I used it.
37 |
38 | The next step was to re-implement everything using Apple's latest programming language [Swift](https://developer.apple.com/swift/) and the [MediaLibrary Framework](https://developer.apple.com/documentation/medialibrary). The result was very robust, performant and maintainable compared to the previous solutions and worked with macOS Mojave.
39 |
40 | Starting with macOS Catalina the MediaLibrary Framework had a bug which causes keywords cannot be requested from the Photos library any more. With macOS Big Sur, the MediaLibrary became deprecated. Therefore I've replaced the MediaLibrary Framework with the [PhotoKit](https://developer.apple.com/documentation/photokit). This framework has also some drawbacks, e.g. the performance is not really optimal and some information cannot be requested from the Photos library, e.g. keywords. To compensate for this, keywords and other data are loaded directly from the Photos sqlite database.
41 |
42 |
43 | # Usage
44 |
45 | Currently the program doesn't have any arguments and no user interface (I've started to work on it, but it's far from being usable).
46 |
47 | To use it:
48 | * create a YAML file ~/Library/Application Support/PhotosExporter/PhotosExporter.yaml and edit the content, e.g.:
49 | ```
50 | ---
51 | plans:
52 | -
53 | type: SnapshotFileSystemExport
54 | enabled: false
55 | name: My export
56 | mediaObjectFilter:
57 | keywordWhiteList:
58 | - test
59 | keywordBlackList:
60 | targetFolder: /Users//Pictures/Fotos-Export/test
61 | deleteFlatPath: false
62 | exportOriginals: true
63 | ```
64 | For settings attributes see the description below.
65 | * checkout the github project, open it in Xcode. Then build and run the application.
66 |
67 | Normally, the exporter adds the timestamp of a photo to the exported photo's filename. Use the keyword "export-no-date" in Photos to omit the timestamp in the filename.
68 |
69 | If you move the exported folder, be sure to recreate the `Latest` link, because it would be broken after moving the folders.
70 |
71 | ## Settings of the exporter
72 |
73 | All settings can be applied both to the SnapshotPhotosExporter and to IncrementalPhotosExporter.
74 |
75 | * plans: each plan is a configuration to export your photos. You can add one or multiple plans to export
76 | * type: either `SnapshotFileSystemExport` or `IncrementalFileSystemExport`
77 | * enabled: `true` or `false` - can be used to disable export configuration
78 | * mediaObjectFilter: if defined, only photos based on whitelist / blacklist configuration are exported:
79 | ** keywordWhiteList: list with keywords of photos which should be exported
80 | ** keywordBlackList: list with keyword of photos which should not be exported
81 | * exportOriginals: set to false if original photos should not be exported (default: true); original photos are the photos which are not edited
82 | * exportCurrent: set to false if current photos should not be exported (default: true); current photo is either the original photo (e.g. in RAW format) if the photo isn't modified by the user, or the modified photo in jpeg format
83 | * exportDerived: set to false if derived photos should not be exported (default: true); derived photos are the renderings (jpeg photos) which may contain user modifications
84 | * targetFolder: the folder the photos should be exported to
85 | * baseExportPath (only IncrementalPhotosExporter, optional parameter): the path to an already existing export folder on the same device; example: export all photos using SnapshotPhotosExporter to your local disk or SSD; create a backup using Time Machine; additionally export the photos using IncrementalPhotosExporter to the same device as your Time Machine backup. Then you would set the baseExportPath to the export folder within your Time Machine backup, to link the exported photos with the photos of your Time Machine backup to save disk space.
86 | * deleteFlatPath: true if the .flat folders should be deleted after the export (default:true; disable deleting if you want to use the export folder as base for an incremental export, see parameter baseExportPath)
87 |
88 | # Supported platforms
89 |
90 | * macOS 11 "Big Sur" (tested by the maintainer)
91 |
92 | # Limitations
93 |
94 | * only photos from the System Photos Library can be exported, not from any other Photos Library. This is due to restrictions of the PhotoKit API.
95 | * only photos that are part of the users photos library are exported. Assets that originate from an iCloud shared album are ignored.
96 | * smart albums are not exported.
97 |
98 | # Implementation
99 |
100 | The program starts with reading all metadata of the [System Photos Library](https://support.apple.com/en-us/HT204414). This is implemented in [PhotosMetadataReader.swift](PhotosExporter/photolibrary-access/PhotosMetadataReader.swift) using [PhotoKit](https://developer.apple.com/documentation/photokit) and SQL.
101 |
102 | The rest is implemented in [PhotosExporter.swift](PhotosExporter/exporter/PhotosExporter.swift) and inherited classes [SnapshotPhotosExporter.swift](PhotosExporter/exporter/SnapshotPhotosExporter.swift) and [IncrementalPhotosExporter.swift](PhotosExporter/exporter/IncrementalPhotosExporter.swift).
103 |
104 | The two implementations are different:
105 | * [IncrementalPhotosExporter.swift](PhotosExporter/exporter/IncrementalPhotosExporter.swift): After loading the metadata, a folder `InProgress` is created. This is a temporary folder where the program copies all exported folders and files to; . After the export of all files has been succeeded, the folder is renamed to the current date formatted with the date pattern `yyyy-MM-dd HH-mm-ss`. Also a symbolic link (alias) to this folder named `Latest` is created to know which files to link on the next export. If an error occurs during the backup, the `InProgress` folder will be left, until the next run of the program finally deletes it.
106 | * [SnapshotPhotosExporter.swift](PhotosExporter/exporter/SnapshotPhotosExporter.swift): it uses an `InProgress` folder, too. If the files of the Photos Library and the target folder are on the same file system, the files are not copied to the `InProgress` folder. Instead, hard links are created, to minimize disk usage. After the export of all files has been succeeded, the folder is renamed to `Snapshot`, while the old `Snapshot` folder is removed before.
107 |
108 | The main part - exporting the albums and photos - is done in two phases: the first phase is to export all original and modified photos to a folder named `.flat`. The second phase creates all sub-folders based on the folders and albums in the Photos Library.
109 |
110 | This screenshot should give an idea about the generated folder structure:
111 | 
112 |
113 | While exporting media files to the `.flat` folder, the program checks if a file has been changed since the last export, and uses a hard link in case the file hasn't been changed. This is done by comparing the media file in the Photos Library with the corresponding file in the `Latest` folder. While a comparison by something like a MD5 or SHA checksum would be the preferred way to check if the file content has been changed, I've decided to implement a simple comparison based on the file size for performance reasons.
114 |
115 | As you can see, the SnapshotPhotosExporter is highly optimized to always keep the export directory on the same file system. If you backup your disk with Time Machine, no extra disk space is required because of the hard links. If photos are modified within the Photos app (or Photos recalculates the photos e.g. because of changed algorithms), the photos in the export directory may also be changed. For using the photos with other devices or programs, this behavior will be what you need. For backups on external drives it wouldn't be sufficient - therefore the IncrementalPhotosExporter was designed.
116 |
117 |
--------------------------------------------------------------------------------
/doc/filesystem-structure.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/abentele/PhotosExporter/3722dfdea4dc8d1427b7901a096a5508de7881ad/doc/filesystem-structure.png
--------------------------------------------------------------------------------