├── 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 | 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 | -------------------------------------------------------------------------------- /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 | 87 | 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 | ![](/doc/filesystem-structure.png) 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 --------------------------------------------------------------------------------