├── .github └── workflows │ └── unit_tests.yml ├── .gitignore ├── CHANGELOG.md ├── DuplicateFinder.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ └── DuplicateFinder.xcscheme ├── DuplicateFinder ├── AppDelegate.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── 1024.png │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 256-1.png │ │ ├── 256.png │ │ ├── 32-1.png │ │ ├── 32.png │ │ ├── 512-1.png │ │ ├── 512.png │ │ ├── 64.png │ │ └── Contents.json │ ├── Contents.json │ └── WolfLogo.imageset │ │ ├── Contents.json │ │ └── NixonAvatar.pdf ├── Base.lproj │ └── Main.storyboard ├── DuplicateFinder.entitlements ├── DuplicateFinderError.swift ├── Info.plist ├── SearchInfo.swift ├── SearchPreference.swift ├── SearchResultViewController.swift ├── SearchSettingsViewController.swift ├── SelectDirectoryViewController.swift ├── WelcomeViewController.swift ├── en.lproj │ ├── Localizable.strings │ └── Main.strings ├── zh-Hant.lproj │ ├── Localizable.strings │ └── Main.strings └── zh.lproj │ ├── Localizable.strings │ └── Main.strings ├── FileExplorer ├── .swiftpm │ └── xcode │ │ └── xcshareddata │ │ └── xcschemes │ │ └── FileExplorer.xcscheme ├── Package.swift ├── README.md ├── Sources │ └── FileExplorer │ │ ├── ExcludedInfo.swift │ │ ├── FileExplorer.swift │ │ ├── FileExplorerError.swift │ │ ├── PathIterator │ │ ├── DiskPathIterator.swift │ │ └── PathIterator.swift │ │ ├── PathValidator.swift │ │ ├── SystemFile.swift │ │ └── URL+fileName.swift └── Tests │ └── FileExplorerTests │ ├── FileExplorerTests.swift │ ├── Helper │ └── MockPathIterator.swift │ ├── PathValidatorTests.swift │ └── URL+fileNameTests.swift ├── LICENSE ├── README.md ├── Utils ├── .swiftpm │ └── xcode │ │ └── xcshareddata │ │ └── xcschemes │ │ └── Utils.xcscheme ├── Package.swift ├── README.md ├── Sources │ └── Utils │ │ ├── Extensions │ │ └── String+Localized.swift │ │ └── Storage │ │ ├── Storage.swift │ │ ├── StoredContainer.swift │ │ └── UserDefaults+StoredContainer.swift └── Tests │ └── UtilsTests │ ├── Helpers │ └── MockStoredContainer.swift │ └── StorageTests.swift └── codecov.yml /.github/workflows/unit_tests.yml: -------------------------------------------------------------------------------- 1 | name: UnitTests 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | 8 | jobs: 9 | swiftpm_tests: 10 | name: ${{ matrix.scheme }} tests 11 | runs-on: macos-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | scheme: ["FileExplorer", "Utils"] 16 | xcode: ["12.2"] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | - name: Select Xcode ${{ matrix.xcode }} 22 | run: sudo xcode-select --switch /Applications/Xcode_${{ matrix.xcode }}.app 23 | 24 | - name: ${{ matrix.scheme }} tests 25 | run: set -o pipefail && xcodebuild test -scheme ${{ matrix.scheme }} -destination 'platform=OS X,arch=x86_64' -enableCodeCoverage YES | xcpretty 26 | 27 | - name: Upload coverage to Codecov 28 | uses: codecov/codecov-action@v1 29 | with: 30 | fail_ci_if_error: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | 3 | ## Build generated 4 | build/ 5 | *.app.dSYM.zip 6 | *.ipa 7 | 8 | ## User settings 9 | xcuserdata/ 10 | 11 | ## Obj-C/Swift specific 12 | *.hmap 13 | 14 | # Swift Package Manager 15 | .build/ 16 | 17 | # fastlane 18 | /fastlane/report.xml 19 | 20 | ## deliver temporary files 21 | /fastlane/Preview.html 22 | 23 | ## snapshot generated screenshots 24 | /fastlane/screenshots 25 | 26 | ## scan temporary files 27 | /fastlane/test_output 28 | 29 | # macOS 30 | .DS_Store 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | #### Releases 8 | - `1.x` Releases - [1.6.2](#162---2020-11-21) | [1.6.1](#161---2020-10-03) | [1.6.0](#160---2020-10-03) | [1.5.0](#150---2018-12-11) | [1.4.0](#140---2017-10-06) | [1.3.0](#130---2016-11-10) | [1.2.0](#120---2016-10-20) | [1.1.0](#110---2016-10-14) 9 | 10 | ## [Unreleased] 11 | 12 | ## [1.6.2] - 2020-11-21 13 | ### Updated 14 | - Support Xcode 12.2 15 | 16 | ## [1.6.1] - 2020-10-03 17 | ### Updated 18 | - Application version 19 | 20 | ## [1.6.0] - 2020-10-03 21 | ### Added 22 | - New module of FileExplorer 23 | - New module of Utils 24 | - Localization for English and Chinese 25 | - A property wrapper of storage 26 | - Supoort `Dark mode` 27 | ### Updated 28 | - Support Swift 5.2 29 | - Project name 30 | ### Removed 31 | - SearchFileBrain 32 | - Sandbox 33 | 34 | ## [1.5.0] - 2018-12-11 35 | ### Updated 36 | - Support Swift 4.2 37 | 38 | ## [1.4.0] - 2017-10-06 39 | ### Updated 40 | - Support Swift 4.0 41 | 42 | ## [1.3.0] - 2016-11-10 43 | ### Updated 44 | - Support Swift 3.0 45 | 46 | ## [1.2.0] - 2016-10-20 47 | ### Added 48 | - Feature of user preference. 49 | 50 | ## [1.1.0] - 2016-10-14 51 | ### Added 52 | - Feature of exclude folder from search 53 | -------------------------------------------------------------------------------- /DuplicateFinder.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 2519329E25271E600084D3B7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2519329D25271E600084D3B7 /* AppDelegate.swift */; }; 11 | 251932A225271E610084D3B7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 251932A125271E610084D3B7 /* Assets.xcassets */; }; 12 | 251932A525271E610084D3B7 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 251932A325271E610084D3B7 /* Main.storyboard */; }; 13 | 251932B325272B370084D3B7 /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251932AF25272B370084D3B7 /* WelcomeViewController.swift */; }; 14 | 251932B425272B370084D3B7 /* SearchPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251932B025272B370084D3B7 /* SearchPreference.swift */; }; 15 | 251932B525272B370084D3B7 /* SearchSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251932B125272B370084D3B7 /* SearchSettingsViewController.swift */; }; 16 | 251932B625272B370084D3B7 /* SearchResultViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251932B225272B370084D3B7 /* SearchResultViewController.swift */; }; 17 | 251932C125272B910084D3B7 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 251932BF25272B910084D3B7 /* Localizable.strings */; }; 18 | 251932C525272BB20084D3B7 /* FileExplorer in Frameworks */ = {isa = PBXBuildFile; productRef = 251932C425272BB20084D3B7 /* FileExplorer */; }; 19 | 251932C725272BB50084D3B7 /* Utils in Frameworks */ = {isa = PBXBuildFile; productRef = 251932C625272BB50084D3B7 /* Utils */; }; 20 | 251932C925277BAB0084D3B7 /* DuplicateFinderError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251932C825277BAA0084D3B7 /* DuplicateFinderError.swift */; }; 21 | 251932CB25279EF20084D3B7 /* SearchInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251932CA25279EF20084D3B7 /* SearchInfo.swift */; }; 22 | /* End PBXBuildFile section */ 23 | 24 | /* Begin PBXFileReference section */ 25 | 2519329A25271E600084D3B7 /* DuplicateFinder.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DuplicateFinder.app; sourceTree = BUILT_PRODUCTS_DIR; }; 26 | 2519329D25271E600084D3B7 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 27 | 251932A125271E610084D3B7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 28 | 251932A425271E610084D3B7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 29 | 251932A625271E610084D3B7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 30 | 251932A725271E610084D3B7 /* DuplicateFinder.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DuplicateFinder.entitlements; sourceTree = ""; }; 31 | 251932AD25272B150084D3B7 /* FileExplorer */ = {isa = PBXFileReference; lastKnownFileType = folder; path = FileExplorer; sourceTree = ""; }; 32 | 251932AE25272B150084D3B7 /* Utils */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Utils; sourceTree = ""; }; 33 | 251932AF25272B370084D3B7 /* WelcomeViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = ""; }; 34 | 251932B025272B370084D3B7 /* SearchPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchPreference.swift; sourceTree = ""; }; 35 | 251932B125272B370084D3B7 /* SearchSettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchSettingsViewController.swift; sourceTree = ""; }; 36 | 251932B225272B370084D3B7 /* SearchResultViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchResultViewController.swift; sourceTree = ""; }; 37 | 251932B825272B670084D3B7 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Main.strings; sourceTree = ""; }; 38 | 251932C025272B910084D3B7 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 39 | 251932C825277BAA0084D3B7 /* DuplicateFinderError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuplicateFinderError.swift; sourceTree = ""; }; 40 | 251932CA25279EF20084D3B7 /* SearchInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchInfo.swift; sourceTree = ""; }; 41 | 25512AEF252894ED00FDC47C /* zh */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh; path = zh.lproj/Main.strings; sourceTree = ""; }; 42 | 25512AF0252894ED00FDC47C /* zh */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh; path = zh.lproj/Localizable.strings; sourceTree = ""; }; 43 | 25DC710F2528959A007DC75E /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Main.strings"; sourceTree = ""; }; 44 | 25DC7110252895D4007DC75E /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; 45 | /* End PBXFileReference section */ 46 | 47 | /* Begin PBXFrameworksBuildPhase section */ 48 | 2519329725271E600084D3B7 /* Frameworks */ = { 49 | isa = PBXFrameworksBuildPhase; 50 | buildActionMask = 2147483647; 51 | files = ( 52 | 251932C725272BB50084D3B7 /* Utils in Frameworks */, 53 | 251932C525272BB20084D3B7 /* FileExplorer in Frameworks */, 54 | ); 55 | runOnlyForDeploymentPostprocessing = 0; 56 | }; 57 | /* End PBXFrameworksBuildPhase section */ 58 | 59 | /* Begin PBXGroup section */ 60 | 2519329125271E600084D3B7 = { 61 | isa = PBXGroup; 62 | children = ( 63 | 2519329C25271E600084D3B7 /* DuplicateFinder */, 64 | 251932AD25272B150084D3B7 /* FileExplorer */, 65 | 251932AE25272B150084D3B7 /* Utils */, 66 | 2519329B25271E600084D3B7 /* Products */, 67 | 251932C325272BB20084D3B7 /* Frameworks */, 68 | ); 69 | sourceTree = ""; 70 | }; 71 | 2519329B25271E600084D3B7 /* Products */ = { 72 | isa = PBXGroup; 73 | children = ( 74 | 2519329A25271E600084D3B7 /* DuplicateFinder.app */, 75 | ); 76 | name = Products; 77 | sourceTree = ""; 78 | }; 79 | 2519329C25271E600084D3B7 /* DuplicateFinder */ = { 80 | isa = PBXGroup; 81 | children = ( 82 | 2519329D25271E600084D3B7 /* AppDelegate.swift */, 83 | 251932AF25272B370084D3B7 /* WelcomeViewController.swift */, 84 | 251932B225272B370084D3B7 /* SearchResultViewController.swift */, 85 | 251932B025272B370084D3B7 /* SearchPreference.swift */, 86 | 251932CA25279EF20084D3B7 /* SearchInfo.swift */, 87 | 251932B125272B370084D3B7 /* SearchSettingsViewController.swift */, 88 | 251932C825277BAA0084D3B7 /* DuplicateFinderError.swift */, 89 | 251932BF25272B910084D3B7 /* Localizable.strings */, 90 | 251932A125271E610084D3B7 /* Assets.xcassets */, 91 | 251932A325271E610084D3B7 /* Main.storyboard */, 92 | 251932A625271E610084D3B7 /* Info.plist */, 93 | 251932A725271E610084D3B7 /* DuplicateFinder.entitlements */, 94 | ); 95 | path = DuplicateFinder; 96 | sourceTree = ""; 97 | }; 98 | 251932C325272BB20084D3B7 /* Frameworks */ = { 99 | isa = PBXGroup; 100 | children = ( 101 | ); 102 | name = Frameworks; 103 | sourceTree = ""; 104 | }; 105 | /* End PBXGroup section */ 106 | 107 | /* Begin PBXNativeTarget section */ 108 | 2519329925271E600084D3B7 /* DuplicateFinder */ = { 109 | isa = PBXNativeTarget; 110 | buildConfigurationList = 251932AA25271E610084D3B7 /* Build configuration list for PBXNativeTarget "DuplicateFinder" */; 111 | buildPhases = ( 112 | 2519329625271E600084D3B7 /* Sources */, 113 | 2519329725271E600084D3B7 /* Frameworks */, 114 | 2519329825271E600084D3B7 /* Resources */, 115 | ); 116 | buildRules = ( 117 | ); 118 | dependencies = ( 119 | ); 120 | name = DuplicateFinder; 121 | packageProductDependencies = ( 122 | 251932C425272BB20084D3B7 /* FileExplorer */, 123 | 251932C625272BB50084D3B7 /* Utils */, 124 | ); 125 | productName = DuplicateFinder; 126 | productReference = 2519329A25271E600084D3B7 /* DuplicateFinder.app */; 127 | productType = "com.apple.product-type.application"; 128 | }; 129 | /* End PBXNativeTarget section */ 130 | 131 | /* Begin PBXProject section */ 132 | 2519329225271E600084D3B7 /* Project object */ = { 133 | isa = PBXProject; 134 | attributes = { 135 | LastSwiftUpdateCheck = 1160; 136 | LastUpgradeCheck = 1220; 137 | ORGANIZATIONNAME = "Nixon Shih"; 138 | TargetAttributes = { 139 | 2519329925271E600084D3B7 = { 140 | CreatedOnToolsVersion = 11.6; 141 | }; 142 | }; 143 | }; 144 | buildConfigurationList = 2519329525271E600084D3B7 /* Build configuration list for PBXProject "DuplicateFinder" */; 145 | compatibilityVersion = "Xcode 9.3"; 146 | developmentRegion = en; 147 | hasScannedForEncodings = 0; 148 | knownRegions = ( 149 | en, 150 | Base, 151 | zh, 152 | "zh-Hant", 153 | ); 154 | mainGroup = 2519329125271E600084D3B7; 155 | productRefGroup = 2519329B25271E600084D3B7 /* Products */; 156 | projectDirPath = ""; 157 | projectRoot = ""; 158 | targets = ( 159 | 2519329925271E600084D3B7 /* DuplicateFinder */, 160 | ); 161 | }; 162 | /* End PBXProject section */ 163 | 164 | /* Begin PBXResourcesBuildPhase section */ 165 | 2519329825271E600084D3B7 /* Resources */ = { 166 | isa = PBXResourcesBuildPhase; 167 | buildActionMask = 2147483647; 168 | files = ( 169 | 251932A225271E610084D3B7 /* Assets.xcassets in Resources */, 170 | 251932C125272B910084D3B7 /* Localizable.strings in Resources */, 171 | 251932A525271E610084D3B7 /* Main.storyboard in Resources */, 172 | ); 173 | runOnlyForDeploymentPostprocessing = 0; 174 | }; 175 | /* End PBXResourcesBuildPhase section */ 176 | 177 | /* Begin PBXSourcesBuildPhase section */ 178 | 2519329625271E600084D3B7 /* Sources */ = { 179 | isa = PBXSourcesBuildPhase; 180 | buildActionMask = 2147483647; 181 | files = ( 182 | 251932CB25279EF20084D3B7 /* SearchInfo.swift in Sources */, 183 | 251932C925277BAB0084D3B7 /* DuplicateFinderError.swift in Sources */, 184 | 2519329E25271E600084D3B7 /* AppDelegate.swift in Sources */, 185 | 251932B625272B370084D3B7 /* SearchResultViewController.swift in Sources */, 186 | 251932B325272B370084D3B7 /* WelcomeViewController.swift in Sources */, 187 | 251932B525272B370084D3B7 /* SearchSettingsViewController.swift in Sources */, 188 | 251932B425272B370084D3B7 /* SearchPreference.swift in Sources */, 189 | ); 190 | runOnlyForDeploymentPostprocessing = 0; 191 | }; 192 | /* End PBXSourcesBuildPhase section */ 193 | 194 | /* Begin PBXVariantGroup section */ 195 | 251932A325271E610084D3B7 /* Main.storyboard */ = { 196 | isa = PBXVariantGroup; 197 | children = ( 198 | 251932A425271E610084D3B7 /* Base */, 199 | 251932B825272B670084D3B7 /* en */, 200 | 25512AEF252894ED00FDC47C /* zh */, 201 | 25DC710F2528959A007DC75E /* zh-Hant */, 202 | ); 203 | name = Main.storyboard; 204 | sourceTree = ""; 205 | }; 206 | 251932BF25272B910084D3B7 /* Localizable.strings */ = { 207 | isa = PBXVariantGroup; 208 | children = ( 209 | 251932C025272B910084D3B7 /* en */, 210 | 25512AF0252894ED00FDC47C /* zh */, 211 | 25DC7110252895D4007DC75E /* zh-Hant */, 212 | ); 213 | name = Localizable.strings; 214 | sourceTree = ""; 215 | }; 216 | /* End PBXVariantGroup section */ 217 | 218 | /* Begin XCBuildConfiguration section */ 219 | 251932A825271E610084D3B7 /* Debug */ = { 220 | isa = XCBuildConfiguration; 221 | buildSettings = { 222 | ALWAYS_SEARCH_USER_PATHS = NO; 223 | CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; 224 | CLANG_ANALYZER_NONNULL = YES; 225 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 226 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 227 | CLANG_CXX_LIBRARY = "libc++"; 228 | CLANG_ENABLE_MODULES = YES; 229 | CLANG_ENABLE_OBJC_ARC = YES; 230 | CLANG_ENABLE_OBJC_WEAK = YES; 231 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 232 | CLANG_WARN_BOOL_CONVERSION = YES; 233 | CLANG_WARN_COMMA = YES; 234 | CLANG_WARN_CONSTANT_CONVERSION = YES; 235 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 236 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 237 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 238 | CLANG_WARN_EMPTY_BODY = YES; 239 | CLANG_WARN_ENUM_CONVERSION = YES; 240 | CLANG_WARN_INFINITE_RECURSION = YES; 241 | CLANG_WARN_INT_CONVERSION = YES; 242 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 243 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 244 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 245 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 246 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 247 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 248 | CLANG_WARN_STRICT_PROTOTYPES = YES; 249 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 250 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 251 | CLANG_WARN_UNREACHABLE_CODE = YES; 252 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 253 | COPY_PHASE_STRIP = NO; 254 | DEBUG_INFORMATION_FORMAT = dwarf; 255 | ENABLE_STRICT_OBJC_MSGSEND = YES; 256 | ENABLE_TESTABILITY = YES; 257 | GCC_C_LANGUAGE_STANDARD = gnu11; 258 | GCC_DYNAMIC_NO_PIC = NO; 259 | GCC_NO_COMMON_BLOCKS = YES; 260 | GCC_OPTIMIZATION_LEVEL = 0; 261 | GCC_PREPROCESSOR_DEFINITIONS = ( 262 | "DEBUG=1", 263 | "$(inherited)", 264 | ); 265 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 266 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 267 | GCC_WARN_UNDECLARED_SELECTOR = YES; 268 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 269 | GCC_WARN_UNUSED_FUNCTION = YES; 270 | GCC_WARN_UNUSED_VARIABLE = YES; 271 | MACOSX_DEPLOYMENT_TARGET = 10.15; 272 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 273 | MTL_FAST_MATH = YES; 274 | ONLY_ACTIVE_ARCH = YES; 275 | SDKROOT = macosx; 276 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 277 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 278 | }; 279 | name = Debug; 280 | }; 281 | 251932A925271E610084D3B7 /* Release */ = { 282 | isa = XCBuildConfiguration; 283 | buildSettings = { 284 | ALWAYS_SEARCH_USER_PATHS = NO; 285 | CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; 286 | CLANG_ANALYZER_NONNULL = YES; 287 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 288 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 289 | CLANG_CXX_LIBRARY = "libc++"; 290 | CLANG_ENABLE_MODULES = YES; 291 | CLANG_ENABLE_OBJC_ARC = YES; 292 | CLANG_ENABLE_OBJC_WEAK = YES; 293 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 294 | CLANG_WARN_BOOL_CONVERSION = YES; 295 | CLANG_WARN_COMMA = YES; 296 | CLANG_WARN_CONSTANT_CONVERSION = YES; 297 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 298 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 299 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 300 | CLANG_WARN_EMPTY_BODY = YES; 301 | CLANG_WARN_ENUM_CONVERSION = YES; 302 | CLANG_WARN_INFINITE_RECURSION = YES; 303 | CLANG_WARN_INT_CONVERSION = YES; 304 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 305 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 306 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 307 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 308 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 309 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 310 | CLANG_WARN_STRICT_PROTOTYPES = YES; 311 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 312 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 313 | CLANG_WARN_UNREACHABLE_CODE = YES; 314 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 315 | COPY_PHASE_STRIP = NO; 316 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 317 | ENABLE_NS_ASSERTIONS = NO; 318 | ENABLE_STRICT_OBJC_MSGSEND = YES; 319 | GCC_C_LANGUAGE_STANDARD = gnu11; 320 | GCC_NO_COMMON_BLOCKS = YES; 321 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 322 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 323 | GCC_WARN_UNDECLARED_SELECTOR = YES; 324 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 325 | GCC_WARN_UNUSED_FUNCTION = YES; 326 | GCC_WARN_UNUSED_VARIABLE = YES; 327 | MACOSX_DEPLOYMENT_TARGET = 10.15; 328 | MTL_ENABLE_DEBUG_INFO = NO; 329 | MTL_FAST_MATH = YES; 330 | SDKROOT = macosx; 331 | SWIFT_COMPILATION_MODE = wholemodule; 332 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 333 | }; 334 | name = Release; 335 | }; 336 | 251932AB25271E610084D3B7 /* Debug */ = { 337 | isa = XCBuildConfiguration; 338 | buildSettings = { 339 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 340 | CODE_SIGN_ENTITLEMENTS = DuplicateFinder/DuplicateFinder.entitlements; 341 | CODE_SIGN_IDENTITY = "-"; 342 | CODE_SIGN_STYLE = Automatic; 343 | COMBINE_HIDPI_IMAGES = YES; 344 | DEVELOPMENT_TEAM = RF986632CV; 345 | ENABLE_HARDENED_RUNTIME = YES; 346 | INFOPLIST_FILE = DuplicateFinder/Info.plist; 347 | LD_RUNPATH_SEARCH_PATHS = ( 348 | "$(inherited)", 349 | "@executable_path/../Frameworks", 350 | ); 351 | MARKETING_VERSION = 1.6.2; 352 | PRODUCT_BUNDLE_IDENTIFIER = net.nixondesign.DuplicateFinder; 353 | PRODUCT_NAME = "$(TARGET_NAME)"; 354 | SWIFT_VERSION = 5.0; 355 | }; 356 | name = Debug; 357 | }; 358 | 251932AC25271E610084D3B7 /* Release */ = { 359 | isa = XCBuildConfiguration; 360 | buildSettings = { 361 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 362 | CODE_SIGN_ENTITLEMENTS = DuplicateFinder/DuplicateFinder.entitlements; 363 | CODE_SIGN_IDENTITY = "-"; 364 | CODE_SIGN_STYLE = Automatic; 365 | COMBINE_HIDPI_IMAGES = YES; 366 | DEVELOPMENT_TEAM = RF986632CV; 367 | ENABLE_HARDENED_RUNTIME = YES; 368 | INFOPLIST_FILE = DuplicateFinder/Info.plist; 369 | LD_RUNPATH_SEARCH_PATHS = ( 370 | "$(inherited)", 371 | "@executable_path/../Frameworks", 372 | ); 373 | MARKETING_VERSION = 1.6.2; 374 | PRODUCT_BUNDLE_IDENTIFIER = net.nixondesign.DuplicateFinder; 375 | PRODUCT_NAME = "$(TARGET_NAME)"; 376 | SWIFT_VERSION = 5.0; 377 | }; 378 | name = Release; 379 | }; 380 | /* End XCBuildConfiguration section */ 381 | 382 | /* Begin XCConfigurationList section */ 383 | 2519329525271E600084D3B7 /* Build configuration list for PBXProject "DuplicateFinder" */ = { 384 | isa = XCConfigurationList; 385 | buildConfigurations = ( 386 | 251932A825271E610084D3B7 /* Debug */, 387 | 251932A925271E610084D3B7 /* Release */, 388 | ); 389 | defaultConfigurationIsVisible = 0; 390 | defaultConfigurationName = Release; 391 | }; 392 | 251932AA25271E610084D3B7 /* Build configuration list for PBXNativeTarget "DuplicateFinder" */ = { 393 | isa = XCConfigurationList; 394 | buildConfigurations = ( 395 | 251932AB25271E610084D3B7 /* Debug */, 396 | 251932AC25271E610084D3B7 /* Release */, 397 | ); 398 | defaultConfigurationIsVisible = 0; 399 | defaultConfigurationName = Release; 400 | }; 401 | /* End XCConfigurationList section */ 402 | 403 | /* Begin XCSwiftPackageProductDependency section */ 404 | 251932C425272BB20084D3B7 /* FileExplorer */ = { 405 | isa = XCSwiftPackageProductDependency; 406 | productName = FileExplorer; 407 | }; 408 | 251932C625272BB50084D3B7 /* Utils */ = { 409 | isa = XCSwiftPackageProductDependency; 410 | productName = Utils; 411 | }; 412 | /* End XCSwiftPackageProductDependency section */ 413 | }; 414 | rootObject = 2519329225271E600084D3B7 /* Project object */; 415 | } 416 | -------------------------------------------------------------------------------- /DuplicateFinder.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /DuplicateFinder.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /DuplicateFinder.xcodeproj/xcshareddata/xcschemes/DuplicateFinder.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 44 | 46 | 52 | 53 | 54 | 55 | 61 | 63 | 69 | 70 | 71 | 72 | 74 | 75 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /DuplicateFinder/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Nixon Shih on 2020/10/2. 3 | // Copyright © 2020 Nixon Shih. All rights reserved. 4 | // 5 | 6 | import Cocoa 7 | 8 | @NSApplicationMain 9 | internal final class AppDelegate: NSObject, NSApplicationDelegate { } 10 | 11 | -------------------------------------------------------------------------------- /DuplicateFinder/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powerwolf543/DuplicateFinder/2fd830d28cf483a892b4d4d6c942b1cbc240772f/DuplicateFinder/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /DuplicateFinder/Assets.xcassets/AppIcon.appiconset/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powerwolf543/DuplicateFinder/2fd830d28cf483a892b4d4d6c942b1cbc240772f/DuplicateFinder/Assets.xcassets/AppIcon.appiconset/128.png -------------------------------------------------------------------------------- /DuplicateFinder/Assets.xcassets/AppIcon.appiconset/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powerwolf543/DuplicateFinder/2fd830d28cf483a892b4d4d6c942b1cbc240772f/DuplicateFinder/Assets.xcassets/AppIcon.appiconset/16.png -------------------------------------------------------------------------------- /DuplicateFinder/Assets.xcassets/AppIcon.appiconset/256-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powerwolf543/DuplicateFinder/2fd830d28cf483a892b4d4d6c942b1cbc240772f/DuplicateFinder/Assets.xcassets/AppIcon.appiconset/256-1.png -------------------------------------------------------------------------------- /DuplicateFinder/Assets.xcassets/AppIcon.appiconset/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powerwolf543/DuplicateFinder/2fd830d28cf483a892b4d4d6c942b1cbc240772f/DuplicateFinder/Assets.xcassets/AppIcon.appiconset/256.png -------------------------------------------------------------------------------- /DuplicateFinder/Assets.xcassets/AppIcon.appiconset/32-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powerwolf543/DuplicateFinder/2fd830d28cf483a892b4d4d6c942b1cbc240772f/DuplicateFinder/Assets.xcassets/AppIcon.appiconset/32-1.png -------------------------------------------------------------------------------- /DuplicateFinder/Assets.xcassets/AppIcon.appiconset/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powerwolf543/DuplicateFinder/2fd830d28cf483a892b4d4d6c942b1cbc240772f/DuplicateFinder/Assets.xcassets/AppIcon.appiconset/32.png -------------------------------------------------------------------------------- /DuplicateFinder/Assets.xcassets/AppIcon.appiconset/512-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powerwolf543/DuplicateFinder/2fd830d28cf483a892b4d4d6c942b1cbc240772f/DuplicateFinder/Assets.xcassets/AppIcon.appiconset/512-1.png -------------------------------------------------------------------------------- /DuplicateFinder/Assets.xcassets/AppIcon.appiconset/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powerwolf543/DuplicateFinder/2fd830d28cf483a892b4d4d6c942b1cbc240772f/DuplicateFinder/Assets.xcassets/AppIcon.appiconset/512.png -------------------------------------------------------------------------------- /DuplicateFinder/Assets.xcassets/AppIcon.appiconset/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powerwolf543/DuplicateFinder/2fd830d28cf483a892b4d4d6c942b1cbc240772f/DuplicateFinder/Assets.xcassets/AppIcon.appiconset/64.png -------------------------------------------------------------------------------- /DuplicateFinder/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "32.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "32-1.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "64.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "256.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "256-1.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "512.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "512-1.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "1024.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /DuplicateFinder/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /DuplicateFinder/Assets.xcassets/WolfLogo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "NixonAvatar.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "preserves-vector-representation" : true, 14 | "template-rendering-intent" : "template" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /DuplicateFinder/Assets.xcassets/WolfLogo.imageset/NixonAvatar.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powerwolf543/DuplicateFinder/2fd830d28cf483a892b4d4d6c942b1cbc240772f/DuplicateFinder/Assets.xcassets/WolfLogo.imageset/NixonAvatar.pdf -------------------------------------------------------------------------------- /DuplicateFinder/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 212 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 328 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 591 | 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 604 | 605 | 606 | 607 | 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | 623 | 624 | 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | -------------------------------------------------------------------------------- /DuplicateFinder/DuplicateFinder.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /DuplicateFinder/DuplicateFinderError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Nixon Shih on 2020/10/2. 3 | // Copyright © 2020 Nixon Shih. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | internal enum DuplicateFinderError: String, LocalizedError { 9 | case targetFolderNotFound 10 | case pathNotFound 11 | 12 | internal var errorDescription: String? { "\(rawValue)_error_description".localized } 13 | internal var recoverySuggestion: String? { "\(rawValue)_recovery_suggestion".localized } 14 | } 15 | -------------------------------------------------------------------------------- /DuplicateFinder/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 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 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | 1 23 | LSApplicationCategoryType 24 | public.app-category.developer-tools 25 | LSMinimumSystemVersion 26 | $(MACOSX_DEPLOYMENT_TARGET) 27 | NSHumanReadableCopyright 28 | Copyright © 2020 Nixon Shih. All rights reserved. 29 | NSMainStoryboardFile 30 | Main 31 | NSPrincipalClass 32 | NSApplication 33 | NSSupportsAutomaticTermination 34 | 35 | NSSupportsSuddenTermination 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /DuplicateFinder/SearchInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Nixon Shih on 2020/10/3. 3 | // Copyright © 2020 Nixon Shih. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import Utils 8 | 9 | /// A data model that includes the informations of search 10 | internal struct SearchInfo: Codable { 11 | internal static var empty: SearchInfo = SearchInfo() 12 | 13 | @Storage(key: "SearchInfo.current", defaultValue: empty) 14 | internal static var persisted: SearchInfo 15 | 16 | internal var targetPath: URL? 17 | internal var excludedPaths: [URL] 18 | internal var excludedFileNames: [String] 19 | 20 | internal init(targetPath: URL? = nil, excludedPaths: [URL] = [], excludeFileNames: [String] = []) { 21 | self.targetPath = targetPath 22 | self.excludedPaths = excludedPaths 23 | self.excludedFileNames = excludeFileNames 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /DuplicateFinder/SearchPreference.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by NixonShih on 2019/10/03. 3 | // Copyright © 2019 Nixon. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import Utils 8 | 9 | /// Aids to access the search preferences 10 | @dynamicMemberLookup 11 | internal final class SearchPreference { 12 | internal static let shared: SearchPreference = SearchPreference() 13 | 14 | @Storage(key: "SearchPreferencesManager.isPersistedEnabled", defaultValue: false) 15 | internal static var isPersistedEnabled: Bool 16 | 17 | private let privateQueue: DispatchQueue 18 | 19 | private init() { 20 | privateQueue = DispatchQueue(label: "net.nixondesign.DuplicateFinder.SearchPreferencesManager") 21 | } 22 | 23 | internal subscript(dynamicMember keyPath: WritableKeyPath) -> T { 24 | get { 25 | privateQueue.sync { 26 | if SearchPreference.isPersistedEnabled { 27 | return SearchInfo.persisted[keyPath: keyPath] 28 | } else { 29 | return SearchInfo.empty[keyPath: keyPath] 30 | } 31 | } 32 | } 33 | set { 34 | privateQueue.sync { 35 | SearchInfo.persisted[keyPath: keyPath] = newValue 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /DuplicateFinder/SearchResultViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by NixonShih on 2016/10/6. 3 | // Copyright © 2019 Nixon. All rights reserved. 4 | // 5 | 6 | import Cocoa 7 | import FileExplorer 8 | import Utils 9 | 10 | internal final class SearchResultViewController: NSViewController { 11 | internal var searchInfo: SearchInfo = SearchInfo.empty 12 | 13 | @IBOutlet private weak var searchStatusLabel: NSTextField! 14 | @IBOutlet private weak var searchStatusIndicator: NSProgressIndicator! 15 | 16 | @IBOutlet 17 | private weak var searchResultTableView: NSTableView! { 18 | didSet { 19 | searchResultTableView.tableColumns[0].title = "result_page_table_column_file_name".localized 20 | searchResultTableView.tableColumns[1].title = "result_page_table_column_path".localized 21 | } 22 | } 23 | 24 | private var fileExplorer: FileExplorer? 25 | private var searchResultDataSource = [URL]() 26 | 27 | // MARK: - Override 28 | 29 | override 30 | internal func viewDidLoad() { 31 | super.viewDidLoad() 32 | prepareUI() 33 | } 34 | 35 | override 36 | internal func viewWillAppear() { 37 | super.viewWillAppear() 38 | search() 39 | } 40 | 41 | override 42 | internal func viewWillDisappear() { 43 | super.viewWillDisappear() 44 | fileExplorer?.stopSearch() 45 | } 46 | 47 | // MARK: - 48 | 49 | private func prepareUI() { 50 | searchStatusIndicator.startAnimation(nil) 51 | searchStatusLabel.stringValue = "result_page_searching".localized 52 | searchResultTableView.dataSource = self 53 | searchResultTableView.delegate = self 54 | 55 | let theMenu = NSMenu(title: "Contextual Menu") 56 | theMenu.insertItem(withTitle: "result_page_show_in_finder".localized, 57 | action: #selector(SearchResultViewController.showInFinder(_:)), 58 | keyEquivalent: "", 59 | at: 0) 60 | searchResultTableView.menu = theMenu 61 | } 62 | 63 | private func search() { 64 | guard let targetPath = searchInfo.targetPath else { return } 65 | 66 | let excludedFileNamesSet = searchInfo.excludedFileNames.reduce(into: Set()) { $0.insert($1) } 67 | let excludedDirectories = searchInfo.excludedPaths.reduce(into: Set()) { $0.insert($1) } 68 | let fileExplorer = FileExplorer( 69 | excludedInfo: ExcludedInfo( 70 | fileNames: excludedFileNamesSet, 71 | directories: excludedDirectories 72 | ) 73 | ) 74 | 75 | fileExplorer.findDuplicatedFile(at: targetPath) { [weak self] result in 76 | guard let self = self else { return } 77 | DispatchQueue.main.sync { 78 | switch result { 79 | case .success(let duplicatedPaths): 80 | self.searchResultDataSource = duplicatedPaths 81 | self.searchResultTableView.reloadData() 82 | self.searchStatusIndicator.isHidden = true 83 | self.searchStatusLabel.stringValue = "result_page_search_complete".localized 84 | 85 | case .failure(let error): 86 | print(error) 87 | self.searchStatusIndicator.isHidden = true 88 | self.searchStatusLabel.stringValue = "result_page_search_failure".localized 89 | } 90 | } 91 | } 92 | 93 | fileExplorer.onStateChange = { [weak self] state in 94 | guard let self = self else { return } 95 | DispatchQueue.main.async { 96 | self.searchStatusLabel.stringValue = state.display 97 | } 98 | } 99 | 100 | self.fileExplorer = fileExplorer 101 | } 102 | 103 | @objc 104 | private func showInFinder(_ sender: AnyObject) { 105 | let selectedRow = searchResultTableView.clickedRow 106 | let selectedURL = searchResultDataSource[selectedRow] 107 | 108 | var error: NSError? 109 | let isExist = (selectedURL as NSURL).checkResourceIsReachableAndReturnError(&error) 110 | 111 | guard isExist else { 112 | NSAlert(error: DuplicateFinderError.pathNotFound).runModal() 113 | return 114 | } 115 | 116 | NSWorkspace.shared.activateFileViewerSelecting([selectedURL as URL]) 117 | } 118 | } 119 | 120 | // MARK: - NSTableViewDataSource NSTableViewDelegate 121 | extension SearchResultViewController: NSTableViewDataSource, NSTableViewDelegate { 122 | internal func numberOfRows(in tableView: NSTableView) -> Int { searchResultDataSource.count } 123 | 124 | internal func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { 125 | guard let identifier = tableColumn?.identifier else { return nil } 126 | 127 | let cell = tableView.makeView(withIdentifier: identifier, owner: nil) as! NSTableCellView 128 | 129 | switch identifier.rawValue { 130 | case "FileNameID": 131 | cell.textField?.stringValue = searchResultDataSource[row].fileName 132 | case "FilePathID": 133 | cell.textField?.stringValue = searchResultDataSource[row].absoluteString 134 | default: break 135 | } 136 | 137 | return cell 138 | } 139 | } 140 | 141 | fileprivate extension FileExplorer.State { 142 | var display: String { 143 | switch self { 144 | case .idle: return "file_explorer_state_idle".localized 145 | case .searching(let stage): 146 | switch stage { 147 | case .group: return "file_explorer_state_searching_group".localized 148 | case .checkDuplicated: return "file_explorer_state_searching_check_duplicated".localized 149 | case .flat: return "file_explorer_state_searching_flat".localized 150 | case .exclude: return "file_explorer_state_searching_exclude".localized 151 | } 152 | case .finish: return "file_explorer_state_finish".localized 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /DuplicateFinder/SearchSettingsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by NixonShih on 2016/10/5. 3 | // Copyright © 2019 Nixon. All rights reserved. 4 | // 5 | 6 | import Cocoa 7 | import Utils 8 | 9 | internal final class SearchSettingsViewController: NSViewController { 10 | @IBOutlet 11 | private weak var titleLabel: NSTextField! { 12 | didSet { titleLabel.stringValue = "setup_page_title".localized } 13 | } 14 | 15 | @IBOutlet 16 | private weak var filePathTextField: NSTextField! { 17 | didSet { filePathTextField.placeholderString = "setup_page_choose_folder_text_field_placehodler".localized } 18 | } 19 | 20 | @IBOutlet 21 | private weak var checkButton: NSButton! { 22 | didSet { checkButton.title = "setup_page_check_button_title".localized } 23 | } 24 | 25 | @IBOutlet 26 | private weak var excludedFolderTitleLabel: NSTextField! { 27 | didSet { excludedFolderTitleLabel.stringValue = "setup_page_excluded_folder_field_title".localized } 28 | } 29 | 30 | @IBOutlet 31 | private weak var excludedNamesTitleLabel: NSTextField! { 32 | didSet { excludedNamesTitleLabel.stringValue = "setup_page_excluded_names_field_title".localized } 33 | } 34 | 35 | @IBOutlet 36 | private weak var excludeFolderTableView: NSTableView! { 37 | didSet { 38 | excludeFolderTableView.tableColumns.first?.title = "setup_page_excluded_folder_table_column_title".localized 39 | } 40 | } 41 | 42 | @IBOutlet 43 | private weak var excludeFileNameTableView: NSTableView! { 44 | didSet { 45 | excludeFileNameTableView.tableColumns.first?.title = "setup_page_excluded_names_table_column_title".localized 46 | } 47 | } 48 | 49 | @IBOutlet 50 | private weak var excludeFileNameTextField: NSTextField! { 51 | didSet { 52 | excludeFileNameTextField.placeholderString = "setup_page_excluded_names_text_field_placehodler".localized 53 | } 54 | } 55 | 56 | @IBOutlet private weak var excludedPathsSegmentControl: NSSegmentedControl! 57 | @IBOutlet private weak var excludedFileNamesSegmentControl: NSSegmentedControl! 58 | 59 | private var excludePaths = [URL]() { 60 | didSet { 61 | excludedPathsSegmentControl.setEnabled(excludePaths.count > 0, forSegment: 1) 62 | SearchPreference.shared.excludedPaths = excludePaths 63 | } 64 | } 65 | 66 | private var excludedFileNames = [String]() { 67 | didSet { 68 | excludedFileNamesSegmentControl.setEnabled(excludedFileNames.count > 0, forSegment: 1) 69 | SearchPreference.shared.excludedFileNames = excludedFileNames 70 | } 71 | } 72 | 73 | private var excludeFolderTableViewSelectedRow: Int? 74 | private var excludeFileNameTableViewSelectedRow: Int? 75 | private var searchResultWindowController: NSWindowController? 76 | 77 | // MARK: - Override 78 | 79 | internal override func viewDidLoad() { 80 | super.viewDidLoad() 81 | prepareUI() 82 | } 83 | 84 | internal override func viewWillAppear() { 85 | super.viewWillAppear() 86 | view.window?.delegate = self 87 | } 88 | 89 | // MARK: - 90 | 91 | private func prepareUI() { 92 | let gesture = NSClickGestureRecognizer() 93 | gesture.buttonMask = 0x1 // left mouse 94 | gesture.numberOfClicksRequired = 1 95 | gesture.target = self 96 | gesture.action = #selector(SearchSettingsViewController.didClickFilePathTextField(_:)) 97 | filePathTextField.addGestureRecognizer(gesture) 98 | 99 | excludeFolderTableView.dataSource = self 100 | excludeFolderTableView.delegate = self 101 | 102 | excludeFileNameTableView.dataSource = self 103 | excludeFileNameTableView.delegate = self 104 | 105 | let searchPreferenceManager = SearchPreference.shared 106 | filePathTextField.stringValue = searchPreferenceManager.targetPath?.absoluteString ?? "" 107 | excludePaths = searchPreferenceManager.excludedPaths 108 | excludedFileNames = searchPreferenceManager.excludedFileNames 109 | } 110 | 111 | private func getFolderPathFromFinder() -> URL? { 112 | // Open file browser 113 | let openPanel = NSOpenPanel() 114 | openPanel.allowsMultipleSelection = false 115 | openPanel.canChooseDirectories = true 116 | openPanel.canChooseFiles = false 117 | 118 | let clickedResult = openPanel.runModal() 119 | 120 | guard clickedResult == NSApplication.ModalResponse.OK, 121 | let url = openPanel.urls.first 122 | else { return nil } 123 | return url 124 | } 125 | 126 | /// sender.selectedSegment: 0 is appended, 1 is deleted 127 | @IBAction 128 | private func didChangeValueOfExcludeSegment(_ sender: NSSegmentedControl) { 129 | switch (sender.selectedSegment, sender) { 130 | case (0, excludedPathsSegmentControl): 131 | guard let path = getFolderPathFromFinder() else { break } 132 | excludePaths.append(path) 133 | excludeFolderTableView.reloadData() 134 | 135 | case (0, excludedFileNamesSegmentControl): 136 | guard !excludeFileNameTextField.stringValue.isEmpty else { break } 137 | excludedFileNames.append(excludeFileNameTextField.stringValue) 138 | excludeFileNameTableView.reloadData() 139 | excludeFileNameTextField.stringValue = "" 140 | 141 | case (1, excludedPathsSegmentControl): 142 | guard let row = excludeFolderTableViewSelectedRow else { return } 143 | excludePaths.remove(at: row) 144 | excludeFolderTableView.reloadData() 145 | excludeFolderTableViewSelectedRow = nil 146 | 147 | case (1, excludedFileNamesSegmentControl): 148 | guard let row = excludeFileNameTableViewSelectedRow else { return } 149 | excludedFileNames.remove(at: row) 150 | excludeFileNameTableView.reloadData() 151 | excludeFileNameTableViewSelectedRow = nil 152 | 153 | default: 154 | fatalError("Unknown option") 155 | } 156 | } 157 | 158 | @IBAction 159 | private func didClickCheckButton(_ sender: NSButton) { 160 | guard !filePathTextField.stringValue.isEmpty else { 161 | NSAlert(error: DuplicateFinderError.targetFolderNotFound).runModal() 162 | return 163 | } 164 | 165 | print("Selected directorie -> \"\(SearchInfo.persisted.targetPath?.absoluteString ?? "")\"") 166 | 167 | searchResultWindowController = storyboard?.instantiateController(withIdentifier: NSStoryboard.SceneIdentifier("SearchFileNameResultWindowSID")) as? NSWindowController 168 | let vc = searchResultWindowController?.contentViewController as! SearchResultViewController 169 | vc.searchInfo = SearchInfo.persisted 170 | searchResultWindowController?.showWindow(self) 171 | } 172 | 173 | @objc 174 | private func didClickFilePathTextField(_ sender: AnyObject) { 175 | guard let targetPath = getFolderPathFromFinder() else { return } 176 | 177 | SearchPreference.shared.targetPath = targetPath 178 | filePathTextField.stringValue = targetPath.absoluteString 179 | } 180 | } 181 | 182 | // MARK: - NSTableViewDataSource NSTableViewDelegate 183 | extension SearchSettingsViewController: NSTableViewDataSource, NSTableViewDelegate { 184 | internal func numberOfRows(in tableView: NSTableView) -> Int { 185 | switch tableView { 186 | case excludeFolderTableView: 187 | return excludePaths.count 188 | case excludeFileNameTableView: 189 | return excludedFileNames.count 190 | default: 191 | fatalError("Unrecogniz tableView.") 192 | } 193 | } 194 | 195 | internal func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { 196 | guard let identifier = tableColumn?.identifier else { return nil } 197 | 198 | let cell = tableView.makeView(withIdentifier: identifier, owner: nil) as! NSTableCellView 199 | 200 | switch identifier.rawValue { 201 | case "FilePathCell_SID": 202 | cell.textField?.stringValue = excludePaths[row].absoluteString 203 | case "FileNameCell_SID": 204 | cell.textField?.stringValue = excludedFileNames[row] 205 | default: break 206 | } 207 | 208 | return cell 209 | } 210 | 211 | internal func selectionShouldChange(in tableView: NSTableView) -> Bool { 212 | switch tableView { 213 | case excludeFolderTableView: 214 | if tableView.clickedRow == -1 { 215 | excludedPathsSegmentControl.setEnabled(false, forSegment: 1) 216 | excludeFolderTableViewSelectedRow = nil 217 | }else{ 218 | excludedPathsSegmentControl.setEnabled(true, forSegment: 1) 219 | excludeFolderTableViewSelectedRow = tableView.clickedRow 220 | } 221 | case excludeFileNameTableView: 222 | if tableView.clickedRow == -1 { 223 | excludedFileNamesSegmentControl.setEnabled(false, forSegment: 1) 224 | excludeFileNameTableViewSelectedRow = nil 225 | }else{ 226 | excludedFileNamesSegmentControl.setEnabled(true, forSegment: 1) 227 | excludeFileNameTableViewSelectedRow = tableView.clickedRow 228 | } 229 | default: 230 | fatalError("Unrecogniz tableView.") 231 | } 232 | return true 233 | } 234 | } 235 | 236 | // MARK: - NSWindowDelegate 237 | extension SearchSettingsViewController: NSWindowDelegate { 238 | internal func windowWillClose(_ notification: Notification) { 239 | NSApp.terminate(self) 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /DuplicateFinder/SelectDirectoryViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by NixonShih on 2016/10/5. 3 | // Copyright © 2019 Nixon. All rights reserved. 4 | // 5 | 6 | import Cocoa 7 | import Utils 8 | 9 | internal final class SearchSettingsViewController: NSViewController { 10 | @IBOutlet 11 | private weak var titleLabel: NSTextField! { 12 | didSet { titleLabel.stringValue = "setup_page_title".localized } 13 | } 14 | 15 | @IBOutlet 16 | private weak var filePathTextField: NSTextField! { 17 | didSet { filePathTextField.placeholderString = "setup_page_choose_folder_text_field_placehodler".localized } 18 | } 19 | 20 | @IBOutlet 21 | private weak var checkButton: NSButton! { 22 | didSet { checkButton.title = "setup_page_check_button_title".localized } 23 | } 24 | 25 | @IBOutlet 26 | private weak var excludedFolderTitleLabel: NSTextField! { 27 | didSet { excludedFolderTitleLabel.stringValue = "setup_page_excluded_folder_field_title".localized } 28 | } 29 | 30 | @IBOutlet 31 | private weak var excludedNamesTitleLabel: NSTextField! { 32 | didSet { excludedNamesTitleLabel.stringValue = "setup_page_excluded_names_field_title".localized } 33 | } 34 | 35 | @IBOutlet 36 | private weak var excludeFolderTableView: NSTableView! { 37 | didSet { 38 | excludeFolderTableView.tableColumns.first?.title = "setup_page_excluded_folder_table_column_title".localized 39 | } 40 | } 41 | 42 | @IBOutlet 43 | private weak var excludeFileNameTableView: NSTableView! { 44 | didSet { 45 | excludeFileNameTableView.tableColumns.first?.title = "setup_page_excluded_names_table_column_title".localized 46 | } 47 | } 48 | 49 | @IBOutlet 50 | private weak var excludeFileNameTextField: NSTextField! { 51 | didSet { 52 | excludeFileNameTextField.placeholderString = "setup_page_excluded_names_text_field_placehodler".localized 53 | } 54 | } 55 | 56 | @IBOutlet private weak var excludedPathsSegmentControl: NSSegmentedControl! 57 | @IBOutlet private weak var excludedFileNamesSegmentControl: NSSegmentedControl! 58 | 59 | private var excludePaths = [URL]() { 60 | didSet { 61 | excludedPathsSegmentControl.setEnabled(excludePaths.count > 0, forSegment: 1) 62 | SearchPreference.shared.excludedPaths = excludePaths 63 | } 64 | } 65 | 66 | private var excludedFileNames = [String]() { 67 | didSet { 68 | excludedFileNamesSegmentControl.setEnabled(excludedFileNames.count > 0, forSegment: 1) 69 | SearchPreference.shared.excludedFileNames = excludedFileNames 70 | } 71 | } 72 | 73 | private var excludeFolderTableViewSelectedRow: Int? 74 | private var excludeFileNameTableViewSelectedRow: Int? 75 | private var searchResultWindowController: NSWindowController? 76 | 77 | // MARK: - Override 78 | 79 | internal override func viewDidLoad() { 80 | super.viewDidLoad() 81 | prepareUI() 82 | } 83 | 84 | internal override func viewWillAppear() { 85 | super.viewWillAppear() 86 | view.window?.delegate = self 87 | } 88 | 89 | // MARK: - 90 | 91 | private func prepareUI() { 92 | let gesture = NSClickGestureRecognizer() 93 | gesture.buttonMask = 0x1 // left mouse 94 | gesture.numberOfClicksRequired = 1 95 | gesture.target = self 96 | gesture.action = #selector(SearchSettingsViewController.didClickFilePathTextField(_:)) 97 | filePathTextField.addGestureRecognizer(gesture) 98 | 99 | excludeFolderTableView.dataSource = self 100 | excludeFolderTableView.delegate = self 101 | 102 | excludeFileNameTableView.dataSource = self 103 | excludeFileNameTableView.delegate = self 104 | 105 | let searchPreferenceManager = SearchPreference.shared 106 | filePathTextField.stringValue = searchPreferenceManager.targetPath?.absoluteString ?? "" 107 | excludePaths = searchPreferenceManager.excludedPaths 108 | excludedFileNames = searchPreferenceManager.excludedFileNames 109 | } 110 | 111 | private func getFolderPathFromFinder() -> URL? { 112 | // Open file browser 113 | let openPanel = NSOpenPanel() 114 | openPanel.allowsMultipleSelection = false 115 | openPanel.canChooseDirectories = true 116 | openPanel.canChooseFiles = false 117 | 118 | let clickedResult = openPanel.runModal() 119 | 120 | guard clickedResult == NSApplication.ModalResponse.OK, 121 | let url = openPanel.urls.first 122 | else { return nil } 123 | return url 124 | } 125 | 126 | /// sender.selectedSegment: 0 is appended, 1 is deleted 127 | @IBAction 128 | private func didChangeValueOfExcludeSegment(_ sender: NSSegmentedControl) { 129 | switch (sender.selectedSegment, sender) { 130 | case (0, excludedPathsSegmentControl): 131 | guard let path = getFolderPathFromFinder() else { break } 132 | excludePaths.append(path) 133 | excludeFolderTableView.reloadData() 134 | 135 | case (0, excludedFileNamesSegmentControl): 136 | guard !excludeFileNameTextField.stringValue.isEmpty else { break } 137 | excludedFileNames.append(excludeFileNameTextField.stringValue) 138 | excludeFileNameTableView.reloadData() 139 | excludeFileNameTextField.stringValue = "" 140 | 141 | case (1, excludedPathsSegmentControl): 142 | guard let row = excludeFolderTableViewSelectedRow else { return } 143 | excludePaths.remove(at: row) 144 | excludeFolderTableView.reloadData() 145 | excludeFolderTableViewSelectedRow = nil 146 | 147 | case (1, excludedFileNamesSegmentControl): 148 | guard let row = excludeFileNameTableViewSelectedRow else { return } 149 | excludedFileNames.remove(at: row) 150 | excludeFileNameTableView.reloadData() 151 | excludeFileNameTableViewSelectedRow = nil 152 | 153 | default: 154 | fatalError("Unknown option") 155 | } 156 | } 157 | 158 | @IBAction 159 | private func didClickCheckButton(_ sender: NSButton) { 160 | guard !filePathTextField.stringValue.isEmpty else { 161 | NSAlert(error: DuplicateFinderError.targetFolderNotFound).runModal() 162 | return 163 | } 164 | 165 | print("Selected directorie -> \"\(SearchInfo.persisted.targetPath?.absoluteString ?? "")\"") 166 | 167 | searchResultWindowController = storyboard?.instantiateController(withIdentifier: NSStoryboard.SceneIdentifier("SearchFileNameResultWindowSID")) as? NSWindowController 168 | let vc = searchResultWindowController?.contentViewController as! SearchResultViewController 169 | vc.searchInfo = SearchInfo.persisted 170 | searchResultWindowController?.showWindow(self) 171 | } 172 | 173 | @objc 174 | private func didClickFilePathTextField(_ sender: AnyObject) { 175 | guard let targetPath = getFolderPathFromFinder() else { return } 176 | 177 | SearchPreference.shared.targetPath = targetPath 178 | filePathTextField.stringValue = targetPath.absoluteString 179 | } 180 | } 181 | 182 | // MARK: - NSTableViewDataSource NSTableViewDelegate 183 | extension SearchSettingsViewController: NSTableViewDataSource, NSTableViewDelegate { 184 | internal func numberOfRows(in tableView: NSTableView) -> Int { 185 | switch tableView { 186 | case excludeFolderTableView: 187 | return excludePaths.count 188 | case excludeFileNameTableView: 189 | return excludedFileNames.count 190 | default: 191 | fatalError("Unrecogniz tableView.") 192 | } 193 | } 194 | 195 | internal func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { 196 | guard let identifier = tableColumn?.identifier else { return nil } 197 | 198 | let cell = tableView.makeView(withIdentifier: identifier, owner: nil) as! NSTableCellView 199 | 200 | switch identifier.rawValue { 201 | case "FilePathCell_SID": 202 | cell.textField?.stringValue = excludePaths[row].absoluteString 203 | case "FileNameCell_SID": 204 | cell.textField?.stringValue = excludedFileNames[row] 205 | default: break 206 | } 207 | 208 | return cell 209 | } 210 | 211 | internal func selectionShouldChange(in tableView: NSTableView) -> Bool { 212 | switch tableView { 213 | case excludeFolderTableView: 214 | if tableView.clickedRow == -1 { 215 | excludedPathsSegmentControl.setEnabled(false, forSegment: 1) 216 | excludeFolderTableViewSelectedRow = nil 217 | }else{ 218 | excludedPathsSegmentControl.setEnabled(true, forSegment: 1) 219 | excludeFolderTableViewSelectedRow = tableView.clickedRow 220 | } 221 | case excludeFileNameTableView: 222 | if tableView.clickedRow == -1 { 223 | excludedFileNamesSegmentControl.setEnabled(false, forSegment: 1) 224 | excludeFileNameTableViewSelectedRow = nil 225 | }else{ 226 | excludedFileNamesSegmentControl.setEnabled(true, forSegment: 1) 227 | excludeFileNameTableViewSelectedRow = tableView.clickedRow 228 | } 229 | default: 230 | fatalError("Unrecogniz tableView.") 231 | } 232 | return true 233 | } 234 | } 235 | 236 | // MARK: - NSWindowDelegate 237 | extension SearchSettingsViewController: NSWindowDelegate { 238 | internal func windowWillClose(_ notification: Notification) { 239 | NSApp.terminate(self) 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /DuplicateFinder/WelcomeViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by NixonShih on 2016/10/20. 3 | // Copyright © 2019 Nixon. All rights reserved. 4 | // 5 | 6 | import Cocoa 7 | import Utils 8 | 9 | internal final class WelcomeViewController: NSViewController { 10 | @IBOutlet 11 | private weak var savePreferenceCheckBox: NSButton! { 12 | didSet { savePreferenceCheckBox.title = "save_preference_check_box_title".localized } 13 | } 14 | 15 | // MARK: - Override 16 | 17 | internal override func viewDidLoad() { 18 | super.viewDidLoad() 19 | prepareUI() 20 | } 21 | 22 | // MARK: - 23 | 24 | private func prepareUI() { 25 | let isPersistedEnabled = SearchPreference.isPersistedEnabled 26 | savePreferenceCheckBox.state = isPersistedEnabled ? .on : .off 27 | } 28 | 29 | @IBAction 30 | private func didClickPreferenceButton(_ sender: NSButton) { 31 | switch sender.state { 32 | case .on: SearchPreference.isPersistedEnabled = true 33 | case .off: SearchPreference.isPersistedEnabled = false 34 | default: assertionFailure("Unknown case") 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /DuplicateFinder/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Created by Nixon Shih on 2020/9/24. 3 | Copyright © 2020 Nixon Shih. All rights reserved. 4 | */ 5 | 6 | "save_preference_check_box_title" = "Save Search Preference"; 7 | 8 | // Settings Page 9 | "setup_page_choose_folder_text_field_placehodler" = "Choose a target folder"; 10 | "setup_page_title" = "Finds all duplicate files which have the same names"; 11 | "setup_page_check_button_title" = "Check"; 12 | "setup_page_excluded_folder_field_title" = "Excluded paths:"; 13 | "setup_page_excluded_names_field_title" = "Excluded names:"; 14 | "setup_page_excluded_folder_table_column_title" = "Path"; 15 | "setup_page_excluded_names_table_column_title" = "File Name"; 16 | "setup_page_excluded_names_text_field_placehodler" = "Enter the file name and extension. ex. fileName.cmd"; 17 | 18 | // Result Page 19 | "result_page_show_in_finder" = "Show in Finder"; 20 | "result_page_searching" = "Searching..."; 21 | "result_page_search_complete" = "Search Complete"; 22 | "result_page_search_failure" = "Search Failure"; 23 | "result_page_table_column_file_name" = "File Name"; 24 | "result_page_table_column_path" = "Path"; 25 | 26 | // FileExplorer State 27 | "file_explorer_state_idle" = "Idle"; 28 | "file_explorer_state_searching_group" = "Processing..."; 29 | "file_explorer_state_searching_check_duplicated" = "Checking..."; 30 | "file_explorer_state_searching_flat" = "Reorder..."; 31 | "file_explorer_state_searching_exclude" = "Almost complete..."; 32 | "file_explorer_state_finish" = "Search Complete"; 33 | 34 | // Error 35 | "targetFolderNotFound_error_description" = "Didn't select search path yet"; 36 | "targetFolderNotFound_recovery_suggestion" = "Please choose a target folder"; 37 | "pathNotFound_error_description" = "Path not found"; 38 | "pathNotFound_recovery_suggestion" = "This path is unavailable."; 39 | -------------------------------------------------------------------------------- /DuplicateFinder/en.lproj/Main.strings: -------------------------------------------------------------------------------- 1 | /* Class = "NSMenuItem"; title = "main_menu_main"; ObjectID = "1Xt-HY-uBw"; */ 2 | "1Xt-HY-uBw.title" = "Duplicate Finder"; 3 | 4 | /* Class = "NSMenuItem"; title = "main_menu_main_quit"; ObjectID = "4sb-4s-VLi"; */ 5 | "4sb-4s-VLi.title" = "Quit Duplicate Finder"; 6 | 7 | /* Class = "NSMenuItem"; title = "main_menu_main_about"; ObjectID = "5kV-Vb-QxS"; */ 8 | "5kV-Vb-QxS.title" = "About Duplicate Finder"; 9 | 10 | /* Class = "NSMenuItem"; title = "main_menu_window"; ObjectID = "aUF-d1-5bR"; */ 11 | "aUF-d1-5bR.title" = "Window"; 12 | 13 | /* Class = "NSMenuItem"; title = "main_menu_main_preference"; ObjectID = "BOF-NM-1cW"; */ 14 | "BOF-NM-1cW.title" = "Preferences..."; 15 | 16 | /* Class = "NSMenu"; title = "main_menu_main_services"; ObjectID = "hz9-B4-Xy5"; */ 17 | "hz9-B4-Xy5.title" = "Services"; 18 | 19 | /* Class = "NSWindow"; title = "main_panel_title"; ObjectID = "IQv-IB-iLA"; */ 20 | "IQv-IB-iLA.title" = "Duplicate Finder"; 21 | 22 | /* Class = "NSMenuItem"; title = "main_menu_main_show_all"; ObjectID = "Kd2-mp-pUS"; */ 23 | "Kd2-mp-pUS.title" = "Show All"; 24 | 25 | /* Class = "NSMenuItem"; title = "main_menu_window_bring_all_to_front"; ObjectID = "LE2-aR-0XJ"; */ 26 | "LE2-aR-0XJ.title" = "Bring All to Front"; 27 | 28 | /* Class = "NSMenuItem"; title = "main_menu_main_services"; ObjectID = "NMo-om-nkz"; */ 29 | "NMo-om-nkz.title" = "Services"; 30 | 31 | /* Class = "NSMenuItem"; title = "main_menu_main_hide"; ObjectID = "Olw-nP-bQN"; */ 32 | "Olw-nP-bQN.title" = "Hide Duplicate Finder"; 33 | 34 | /* Class = "NSMenuItem"; title = "main_menu_window_minimize"; ObjectID = "OY7-WF-poV"; */ 35 | "OY7-WF-poV.title" = "Minimize"; 36 | 37 | /* Class = "NSMenuItem"; title = "main_menu_window_zoom"; ObjectID = "R4o-n2-Eq4"; */ 38 | "R4o-n2-Eq4.title" = "Zoom"; 39 | 40 | /* Class = "NSMenu"; title = "main_menu_window"; ObjectID = "Td7-aD-5lo"; */ 41 | "Td7-aD-5lo.title" = "Window"; 42 | 43 | /* Class = "NSMenu"; title = "main_menu_main"; ObjectID = "uQy-DD-JDr"; */ 44 | "uQy-DD-JDr.title" = "Duplicate Finder"; 45 | 46 | /* Class = "NSMenuItem"; title = "main_menu_main_hide_others"; ObjectID = "Vdr-fp-XzO"; */ 47 | "Vdr-fp-XzO.title" = "Hide Others"; 48 | 49 | /* Class = "NSWindow"; title = "search_result_window_title"; ObjectID = "Y6I-ij-MdO"; */ 50 | "Y6I-ij-MdO.title" = "Search Result"; 51 | -------------------------------------------------------------------------------- /DuplicateFinder/zh-Hant.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Created by Nixon Shih on 2020/9/24. 3 | Copyright © 2020 Nixon Shih. All rights reserved. 4 | */ 5 | 6 | "save_preference_check_box_title" = "儲存搜尋設定"; 7 | 8 | // Setup Page 9 | "setup_page_choose_folder_text_field_placehodler" = "選擇資料夾"; 10 | "setup_page_title" = "檢查資料夾中是否有重複檔案名稱"; 11 | "setup_page_check_button_title" = "開始檢查"; 12 | "setup_page_excluded_folder_field_title" = "將下列路徑從搜尋中排除:"; 13 | "setup_page_excluded_names_field_title" = "將下列名稱從搜尋中排除:"; 14 | "setup_page_excluded_folder_table_column_title" = "路徑"; 15 | "setup_page_excluded_names_table_column_title" = "檔案名稱"; 16 | "setup_page_excluded_names_text_field_placehodler" = "請輸入檔案名稱(包含副檔名) ex. fileName.cmd"; 17 | 18 | // Result Page 19 | "result_page_show_in_finder" = "在 Finder 中顯示"; 20 | "result_page_searching" = "尋找中..."; 21 | "result_page_search_complete" = "搜尋完成"; 22 | "result_page_search_failure" = "搜尋失敗"; 23 | "result_page_table_column_file_name" = "檔名"; 24 | "result_page_table_column_path" = "路徑"; 25 | 26 | // FileExplorer State 27 | "file_explorer_state_idle" = "閒置"; 28 | "file_explorer_state_searching_group" = "處理中..."; 29 | "file_explorer_state_searching_check_duplicated" = "檢查中..."; 30 | "file_explorer_state_searching_flat" = "重新排序..."; 31 | "file_explorer_state_searching_exclude" = "快完成了..."; 32 | "file_explorer_state_finish" = "搜尋完成"; 33 | 34 | // Error 35 | "targetFolderNotFound_error_description" = "尚未選擇路徑"; 36 | "targetFolderNotFound_recovery_suggestion" = "請先選擇路徑在進行搜尋"; 37 | "pathNotFound_error_description" = "開啟錯誤"; 38 | "pathNotFound_recovery_suggestion" = "這個路徑已不存在"; 39 | -------------------------------------------------------------------------------- /DuplicateFinder/zh-Hant.lproj/Main.strings: -------------------------------------------------------------------------------- 1 | /* Class = "NSMenuItem"; title = "main_menu_main"; ObjectID = "1Xt-HY-uBw"; */ 2 | "1Xt-HY-uBw.title" = "Duplicate Finder"; 3 | 4 | /* Class = "NSMenuItem"; title = "main_menu_main_quit"; ObjectID = "4sb-4s-VLi"; */ 5 | "4sb-4s-VLi.title" = "結束 Duplicate Finder"; 6 | 7 | /* Class = "NSMenuItem"; title = "main_menu_main_about"; ObjectID = "5kV-Vb-QxS"; */ 8 | "5kV-Vb-QxS.title" = "關於 Duplicate Finder"; 9 | 10 | /* Class = "NSMenuItem"; title = "main_menu_window"; ObjectID = "aUF-d1-5bR"; */ 11 | "aUF-d1-5bR.title" = "視窗"; 12 | 13 | /* Class = "NSMenuItem"; title = "main_menu_main_preference"; ObjectID = "BOF-NM-1cW"; */ 14 | "BOF-NM-1cW.title" = "偏好設定"; 15 | 16 | /* Class = "NSMenu"; title = "main_menu_main_services"; ObjectID = "hz9-B4-Xy5"; */ 17 | "hz9-B4-Xy5.title" = "服務"; 18 | 19 | /* Class = "NSWindow"; title = "main_panel_title"; ObjectID = "IQv-IB-iLA"; */ 20 | "IQv-IB-iLA.title" = "Duplicate Finder"; 21 | 22 | /* Class = "NSMenuItem"; title = "main_menu_main_show_all"; ObjectID = "Kd2-mp-pUS"; */ 23 | "Kd2-mp-pUS.title" = "顯示全部"; 24 | 25 | /* Class = "NSMenuItem"; title = "main_menu_window_bring_all_to_front"; ObjectID = "LE2-aR-0XJ"; */ 26 | "LE2-aR-0XJ.title" = "將此程式所有視窗移至最前"; 27 | 28 | /* Class = "NSMenuItem"; title = "main_menu_main_services"; ObjectID = "NMo-om-nkz"; */ 29 | "NMo-om-nkz.title" = "服務"; 30 | 31 | /* Class = "NSMenuItem"; title = "main_menu_main_hide"; ObjectID = "Olw-nP-bQN"; */ 32 | "Olw-nP-bQN.title" = "隱藏 Duplicate Finder"; 33 | 34 | /* Class = "NSMenuItem"; title = "main_menu_window_minimize"; ObjectID = "OY7-WF-poV"; */ 35 | "OY7-WF-poV.title" = "縮到最小"; 36 | 37 | /* Class = "NSMenuItem"; title = "main_menu_window_zoom"; ObjectID = "R4o-n2-Eq4"; */ 38 | "R4o-n2-Eq4.title" = "縮放"; 39 | 40 | /* Class = "NSMenu"; title = "main_menu_window"; ObjectID = "Td7-aD-5lo"; */ 41 | "Td7-aD-5lo.title" = "視窗"; 42 | 43 | /* Class = "NSMenu"; title = "main_menu_main"; ObjectID = "uQy-DD-JDr"; */ 44 | "uQy-DD-JDr.title" = "Duplicate Finder"; 45 | 46 | /* Class = "NSMenuItem"; title = "main_menu_main_hide_others"; ObjectID = "Vdr-fp-XzO"; */ 47 | "Vdr-fp-XzO.title" = "隱藏其他"; 48 | 49 | /* Class = "NSWindow"; title = "search_result_window_title"; ObjectID = "Y6I-ij-MdO"; */ 50 | "Y6I-ij-MdO.title" = "搜尋結果"; 51 | -------------------------------------------------------------------------------- /DuplicateFinder/zh.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Created by Nixon Shih on 2020/9/24. 3 | Copyright © 2020 Nixon Shih. All rights reserved. 4 | */ 5 | 6 | "save_preference_check_box_title" = "儲存搜尋設定"; 7 | 8 | // Setup Page 9 | "setup_page_choose_folder_text_field_placehodler" = "選擇資料夾"; 10 | "setup_page_title" = "檢查資料夾中是否有重複檔案名稱"; 11 | "setup_page_check_button_title" = "開始檢查"; 12 | "setup_page_excluded_folder_field_title" = "將下列路徑從搜尋中排除:"; 13 | "setup_page_excluded_names_field_title" = "將下列名稱從搜尋中排除:"; 14 | "setup_page_excluded_folder_table_column_title" = "路徑"; 15 | "setup_page_excluded_names_table_column_title" = "檔案名稱"; 16 | "setup_page_excluded_names_text_field_placehodler" = "請輸入檔案名稱(包含副檔名) ex. fileName.cmd"; 17 | 18 | // Result Page 19 | "result_page_show_in_finder" = "在 Finder 中顯示"; 20 | "result_page_searching" = "尋找中..."; 21 | "result_page_search_complete" = "搜尋完成"; 22 | "result_page_search_failure" = "搜尋失敗"; 23 | "result_page_table_column_file_name" = "檔名"; 24 | "result_page_table_column_path" = "路徑"; 25 | 26 | // FileExplorer State 27 | "file_explorer_state_idle" = "閒置"; 28 | "file_explorer_state_searching_group" = "處理中..."; 29 | "file_explorer_state_searching_check_duplicated" = "檢查中..."; 30 | "file_explorer_state_searching_flat" = "重新排序..."; 31 | "file_explorer_state_searching_exclude" = "快完成了..."; 32 | "file_explorer_state_finish" = "搜尋完成"; 33 | 34 | // Error 35 | "targetFolderNotFound_error_description" = "尚未選擇路徑"; 36 | "targetFolderNotFound_recovery_suggestion" = "請先選擇路徑在進行搜尋"; 37 | "pathNotFound_error_description" = "開啟錯誤"; 38 | "pathNotFound_recovery_suggestion" = "這個路徑已不存在"; 39 | -------------------------------------------------------------------------------- /DuplicateFinder/zh.lproj/Main.strings: -------------------------------------------------------------------------------- 1 | /* Class = "NSMenuItem"; title = "main_menu_main"; ObjectID = "1Xt-HY-uBw"; */ 2 | "1Xt-HY-uBw.title" = "Duplicate Finder"; 3 | 4 | /* Class = "NSMenuItem"; title = "main_menu_main_quit"; ObjectID = "4sb-4s-VLi"; */ 5 | "4sb-4s-VLi.title" = "結束 Duplicate Finder"; 6 | 7 | /* Class = "NSMenuItem"; title = "main_menu_main_about"; ObjectID = "5kV-Vb-QxS"; */ 8 | "5kV-Vb-QxS.title" = "關於 Duplicate Finder"; 9 | 10 | /* Class = "NSMenuItem"; title = "main_menu_window"; ObjectID = "aUF-d1-5bR"; */ 11 | "aUF-d1-5bR.title" = "視窗"; 12 | 13 | /* Class = "NSMenuItem"; title = "main_menu_main_preference"; ObjectID = "BOF-NM-1cW"; */ 14 | "BOF-NM-1cW.title" = "偏好設定"; 15 | 16 | /* Class = "NSMenu"; title = "main_menu_main_services"; ObjectID = "hz9-B4-Xy5"; */ 17 | "hz9-B4-Xy5.title" = "服務"; 18 | 19 | /* Class = "NSWindow"; title = "main_panel_title"; ObjectID = "IQv-IB-iLA"; */ 20 | "IQv-IB-iLA.title" = "Duplicate Finder"; 21 | 22 | /* Class = "NSMenuItem"; title = "main_menu_main_show_all"; ObjectID = "Kd2-mp-pUS"; */ 23 | "Kd2-mp-pUS.title" = "顯示全部"; 24 | 25 | /* Class = "NSMenuItem"; title = "main_menu_window_bring_all_to_front"; ObjectID = "LE2-aR-0XJ"; */ 26 | "LE2-aR-0XJ.title" = "將此程式所有視窗移至最前"; 27 | 28 | /* Class = "NSMenuItem"; title = "main_menu_main_services"; ObjectID = "NMo-om-nkz"; */ 29 | "NMo-om-nkz.title" = "服務"; 30 | 31 | /* Class = "NSMenuItem"; title = "main_menu_main_hide"; ObjectID = "Olw-nP-bQN"; */ 32 | "Olw-nP-bQN.title" = "隱藏 Duplicate Finder"; 33 | 34 | /* Class = "NSMenuItem"; title = "main_menu_window_minimize"; ObjectID = "OY7-WF-poV"; */ 35 | "OY7-WF-poV.title" = "縮到最小"; 36 | 37 | /* Class = "NSMenuItem"; title = "main_menu_window_zoom"; ObjectID = "R4o-n2-Eq4"; */ 38 | "R4o-n2-Eq4.title" = "縮放"; 39 | 40 | /* Class = "NSMenu"; title = "main_menu_window"; ObjectID = "Td7-aD-5lo"; */ 41 | "Td7-aD-5lo.title" = "視窗"; 42 | 43 | /* Class = "NSMenu"; title = "main_menu_main"; ObjectID = "uQy-DD-JDr"; */ 44 | "uQy-DD-JDr.title" = "Duplicate Finder"; 45 | 46 | /* Class = "NSMenuItem"; title = "main_menu_main_hide_others"; ObjectID = "Vdr-fp-XzO"; */ 47 | "Vdr-fp-XzO.title" = "隱藏其他"; 48 | 49 | /* Class = "NSWindow"; title = "search_result_window_title"; ObjectID = "Y6I-ij-MdO"; */ 50 | "Y6I-ij-MdO.title" = "搜尋結果"; 51 | -------------------------------------------------------------------------------- /FileExplorer/.swiftpm/xcode/xcshareddata/xcschemes/FileExplorer.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 54 | 60 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /FileExplorer/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "FileExplorer", 6 | platforms: [ 7 | .macOS(.v10_15), 8 | ], 9 | products: [ 10 | .library(name: "FileExplorer", targets: ["FileExplorer"]), 11 | ], 12 | targets: [ 13 | .target(name: "FileExplorer", dependencies: []), 14 | .testTarget(name: "FileExplorerTests", dependencies: ["FileExplorer"]), 15 | ] 16 | ) 17 | -------------------------------------------------------------------------------- /FileExplorer/README.md: -------------------------------------------------------------------------------- 1 | # FileExplorer 2 | 3 | **FileExplorer** is an internal Swift Package. It provides the search functions to enumerate the whole specified directory and feedbacks the URLs which have the duplicated file names. 4 | 5 | ## Usage 6 | 7 | You could simply obtain the URLs which file names are duplicated by passing a directory URL. 8 | 9 | ``` swift 10 | FileExplorer().findDuplicatedFile(at: targetURL) { result in 11 | switch result { 12 | case .success(let duplicatedURLs): 13 | print(duplicatedURLs) 14 | case .failure(let error): 15 | print(error) 16 | } 17 | } 18 | ``` 19 | 20 | You could set the unneeded search files or directories through passing the `ExcludedInfo`. 21 | 22 | ``` swift 23 | FileExplorer(excludedInfo: ExcludedInfo(fileNames: "File.swift", directories: "file:///home/desktop")) 24 | 25 | ``` -------------------------------------------------------------------------------- /FileExplorer/Sources/FileExplorer/ExcludedInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExcludedInfo.swift 3 | // Created by Nixon Shih on 2020/9/21. 4 | // 5 | 6 | import Foundation 7 | 8 | /// A data structure that represents all the informations which need to be excluded 9 | public struct ExcludedInfo { 10 | /// A `String` set that includes all the file names which need to be excluded 11 | public var fileNames: Set = [] 12 | /// A `URL` set that includes all the paths of directories which need to be excluded 13 | public var directories: Set = [] 14 | 15 | public init(fileNames: Set = [], directories: Set = []) { 16 | self.fileNames = fileNames 17 | self.directories = directories 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /FileExplorer/Sources/FileExplorer/FileExplorer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileExplorer.swift 3 | // Created by Nixon Shih on 2020/9/20. 4 | // 5 | 6 | import Foundation 7 | 8 | /// The FileExplorer includes the feature that finds all duplicated file names 9 | public final class FileExplorer { 10 | /// A state that represents the working state of `FileExplorer` 11 | public enum State: Equatable { 12 | case idle 13 | case searching(SearchStage) 14 | case finish 15 | } 16 | 17 | /// A stage that represents the searching state of `FileExplorer` 18 | public enum SearchStage: Equatable { 19 | case group 20 | case checkDuplicated 21 | case flat 22 | case exclude 23 | } 24 | 25 | public private(set) var state: State { 26 | set { 27 | accessQueue.sync { _state = newValue } 28 | onStateChange?(_state) 29 | } 30 | get { 31 | accessQueue.sync { _state } 32 | } 33 | } 34 | 35 | public var onStateChange: ((State) -> Void)? 36 | 37 | private let pathValidator: PathValidator 38 | private var _state: State 39 | private let accessQueue: DispatchQueue 40 | 41 | /// Creates a `FileExplorer` with some settings. 42 | /// - Parameters: 43 | /// - excludedInfo: The informations that includes all file paths and directory paths which need to exclude. 44 | public init(excludedInfo: ExcludedInfo = ExcludedInfo()) { 45 | _state = .idle 46 | self.pathValidator = PathValidator(excludedInfo: excludedInfo) 47 | accessQueue = DispatchQueue(label: "net.nixondesign.DuplicateFinder.FileExplorer.access") 48 | } 49 | 50 | /// Finds all duplicated file names in the specific directory 51 | /// - Parameter directoryURL: A directory url that you want to search. 52 | /// - Parameter isSkipsHiddenFiles: Pass `true` to skip the hidden files. 53 | /// - Parameter completionHandler: The callback that will be called on the search completion. 54 | public func findDuplicatedFile(at directoryURL: URL, isSkipsHiddenFiles: Bool = true, completionHandler: @escaping (_ result: Result<[URL], FileExplorerError>) -> Void) { 55 | guard let pathIterator = DiskPathIterator(directoryURL: directoryURL, isSkipsHiddenFiles: isSkipsHiddenFiles) else { 56 | completionHandler(.failure(FileExplorerError.searchFail)) 57 | return 58 | } 59 | 60 | findDuplicatedFile(with: pathIterator, completionHandler: completionHandler) 61 | } 62 | 63 | /// Finds all duplicated file names in the specific directory 64 | /// - Parameter diskPathIterator: A path iterator that aids with enumerating the whole directory. 65 | /// - Parameter completionHandler: The callback that will be called on the search completion. 66 | public func findDuplicatedFile(with diskPathIterator: PathIterator, completionHandler: @escaping (_ result: Result<[URL], FileExplorerError>) -> Void) { 67 | guard state == .idle else { return } 68 | 69 | DispatchQueue.global().async { 70 | self.state = .searching(.group) 71 | let groupedList = self.groupListByName(with: diskPathIterator) 72 | 73 | self.state = .searching(.checkDuplicated) 74 | let duplicatedFiles = groupedList.filter { $0.value.count > 1 } 75 | 76 | self.state = .searching(.flat) 77 | let flatDuplicatedFiles = duplicatedFiles.flatMap { $0.value } 78 | 79 | self.state = .searching(.exclude) 80 | let result = flatDuplicatedFiles.filter { !self.pathValidator.verifyPathNeedExcluded($0) } 81 | 82 | self.state = .finish 83 | completionHandler(.success(result)) 84 | } 85 | } 86 | 87 | /// Interrupts the search 88 | public func stopSearch() { 89 | state = .finish 90 | } 91 | 92 | internal func groupListByName(with diskPathIterator: PathIterator) -> [String: Set] { 93 | var result: [String: Set] = [:] 94 | 95 | while let path = diskPathIterator.next() { 96 | guard pathValidator.verifyPathIsFile(path) else { continue } 97 | result[path.fileName, default: []].insert(path) 98 | } 99 | 100 | return result 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /FileExplorer/Sources/FileExplorer/FileExplorerError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileExplorerError.swift 3 | // Created by Nixon Shih on 2020/9/22. 4 | // 5 | 6 | import Foundation 7 | 8 | public enum FileExplorerError: LocalizedError { 9 | case searchFail 10 | } 11 | -------------------------------------------------------------------------------- /FileExplorer/Sources/FileExplorer/PathIterator/DiskPathIterator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DiskPathIterator.swift 3 | // Created by Nixon Shih on 2020/9/16. 4 | // 5 | 6 | import Foundation 7 | 8 | /// An Iterator that enumerates the contents of a directory. 9 | public struct DiskPathIterator: PathIterator { 10 | private let directoryEnumerator: FileManager.DirectoryEnumerator 11 | 12 | public init?(directoryURL: URL, isSkipsHiddenFiles: Bool, fileManager: FileManager = .default) { 13 | guard let directoryEnumerator = fileManager.createDirectoryEnumerator(for: directoryURL, isSkipsHiddenFiles: isSkipsHiddenFiles) else { return nil } 14 | self.directoryEnumerator = directoryEnumerator 15 | } 16 | 17 | /// The next element in the underlying sequence, if a next element 18 | /// exists; otherwise, `nil`. 19 | public func next() -> URL? { 20 | directoryEnumerator.nextObject() as? URL 21 | } 22 | } 23 | 24 | extension FileManager { 25 | fileprivate func createDirectoryEnumerator(for directoryURL: URL, isSkipsHiddenFiles: Bool) -> FileManager.DirectoryEnumerator? { 26 | let options: FileManager.DirectoryEnumerationOptions = isSkipsHiddenFiles ? .skipsHiddenFiles : [] 27 | 28 | guard let directoryEnumerator = enumerator( 29 | at: directoryURL, 30 | includingPropertiesForKeys: [URLResourceKey.isDirectoryKey], 31 | options: options 32 | ) else { return nil } 33 | 34 | return directoryEnumerator 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /FileExplorer/Sources/FileExplorer/PathIterator/PathIterator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PathIterator.swift 3 | // Created by Nixon Shih on 2020/9/16. 4 | // 5 | 6 | import Foundation 7 | 8 | public protocol PathIterator { 9 | func next() -> URL? 10 | } 11 | -------------------------------------------------------------------------------- /FileExplorer/Sources/FileExplorer/PathValidator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PathValidator.swift 3 | // Created by Nixon Shih on 2020/9/20. 4 | // 5 | 6 | import Foundation 7 | 8 | /// A validator that verifies the paths which are needed. 9 | internal struct PathValidator { 10 | /// The excluded informations, the `fileNames` also includes the `SystemFile` names. 11 | internal let excludedInfo: ExcludedInfo 12 | 13 | /// FilePathValidator initializer 14 | /// - Parameters: 15 | /// - excludedInfo: The excluded informations 16 | internal init(excludedInfo: ExcludedInfo) { 17 | var excludedInfo = excludedInfo 18 | excludedInfo.fileNames = SystemFile.fileNames.reduce(into: excludedInfo.fileNames, { $0.insert($1) }) 19 | self.excludedInfo = excludedInfo 20 | } 21 | 22 | /// Verifies the path is a file path. 23 | /// - Parameter path: A path which needs to verify 24 | /// - Returns: Return true, if the path is file path. 25 | internal func verifyPathIsFile(_ path: URL) -> Bool { 26 | path.isFileURL && !path.hasDirectoryPath 27 | } 28 | 29 | /// Verifies the path which needs to be excluded 30 | /// - Parameter path: A path which needs to verify 31 | /// - Returns: Return true, if the path need to be excluded 32 | internal func verifyPathNeedExcluded(_ path: URL) -> Bool { 33 | excludedInfo.fileNames.contains(path.fileName) || 34 | !excludedInfo.directories.filter({ path.absoluteString.hasPrefix($0.absoluteString) }).isEmpty 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /FileExplorer/Sources/FileExplorer/SystemFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SystemFile.swift 3 | // Created by Nixon Shih on 2020/9/16. 4 | // 5 | 6 | import Foundation 7 | 8 | /// The MacOS system file 9 | internal enum SystemFile: String, CaseIterable { 10 | static let fileNames: Set = { 11 | SystemFile.allCases.reduce(into: Set()) { $0.insert($1.rawValue) } 12 | }() 13 | 14 | case dsStore = ".DS_Store" 15 | case macOSX = "__MACOSX" 16 | } 17 | -------------------------------------------------------------------------------- /FileExplorer/Sources/FileExplorer/URL+fileName.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+fileName.swift 3 | // Created by Nixon Shih on 2020/9/19. 4 | // 5 | 6 | import Foundation 7 | 8 | public extension URL { 9 | /// Get the file name from URL 10 | var fileName: String { lastPathComponent } 11 | } 12 | -------------------------------------------------------------------------------- /FileExplorer/Tests/FileExplorerTests/FileExplorerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileExplorerTests.swift 3 | // Created by Nixon Shih on 2020/9/20. 4 | // 5 | 6 | @testable import FileExplorer 7 | import XCTest 8 | 9 | final class FileExplorerTests: XCTestCase { 10 | func testFindDuplicatedFile() { 11 | let input: [URL] = testData 12 | 13 | let pathIterator = MockPathIterator(urls: input) 14 | let explorer = FileExplorer() 15 | 16 | let searchExpectation = expectation(description: "The search should be done.") 17 | 18 | explorer.findDuplicatedFile(with: pathIterator) { result in 19 | switch result { 20 | case .success(let paths): 21 | let outputs = paths.reduce(into: Set()) { $0.insert($1) } 22 | let expectedResult: Set = [ 23 | URL(string: "file:///home/a.swift")!, 24 | URL(string: "file:///home/path/a.swift")!, 25 | URL(string: "file:///home/b.swift")!, 26 | URL(string: "file:///home/path/b.swift")!, 27 | URL(string: "file:///home/path/path/b.swift")!, 28 | ] 29 | XCTAssertEqual(outputs, expectedResult) 30 | 31 | case .failure: 32 | XCTFail("This case should be success.") 33 | } 34 | searchExpectation.fulfill() 35 | } 36 | 37 | waitForExpectations(timeout: 1) 38 | } 39 | 40 | func testFindDuplicatedFileWithExcludedFile() { 41 | let input: [URL] = testData 42 | 43 | let pathIterator = MockPathIterator(urls: input) 44 | let excludedInfo = ExcludedInfo(fileNames: ["a.swift"]) 45 | let explorer = FileExplorer(excludedInfo: excludedInfo) 46 | 47 | let searchExpectation = expectation(description: "The search should be done.") 48 | 49 | explorer.findDuplicatedFile(with: pathIterator) { result in 50 | switch result { 51 | case .success(let paths): 52 | let outputs = paths.reduce(into: Set()) { $0.insert($1) } 53 | let expectedResult: Set = [ 54 | URL(string: "file:///home/b.swift")!, 55 | URL(string: "file:///home/path/b.swift")!, 56 | URL(string: "file:///home/path/path/b.swift")!, 57 | ] 58 | XCTAssertEqual(outputs, expectedResult) 59 | 60 | case .failure: 61 | XCTFail("This case should be success.") 62 | } 63 | searchExpectation.fulfill() 64 | } 65 | 66 | waitForExpectations(timeout: 1) 67 | } 68 | 69 | func testFindDuplicatedFileWithExcludedPath() { 70 | let input: [URL] = testData 71 | 72 | let pathIterator = MockPathIterator(urls: input) 73 | let excludedInfo = ExcludedInfo(directories: [URL(string: "file:///home/path/")!]) 74 | let explorer = FileExplorer(excludedInfo: excludedInfo) 75 | 76 | let searchExpectation = expectation(description: "The search should be done.") 77 | 78 | explorer.findDuplicatedFile(with: pathIterator) { result in 79 | switch result { 80 | case .success(let paths): 81 | let outputs = paths.reduce(into: Set()) { $0.insert($1) } 82 | let expectedResult: Set = [ 83 | URL(string: "file:///home/a.swift")!, 84 | URL(string: "file:///home/b.swift")!, 85 | ] 86 | XCTAssertEqual(outputs, expectedResult) 87 | 88 | case .failure: 89 | XCTFail("This case should be success.") 90 | } 91 | searchExpectation.fulfill() 92 | } 93 | 94 | waitForExpectations(timeout: 1) 95 | } 96 | 97 | func testGroupListByName() { 98 | let groupedList: [String: Set] = [ 99 | "a.swift" : [ 100 | URL(string: "file:///home/a.swift")!, 101 | URL(string: "file:///home/path/a.swift")! 102 | ], 103 | "b.swift" : [ 104 | URL(string: "file:///home/b.swift")!, 105 | URL(string: "file:///home/path/b.swift")!, 106 | URL(string: "file:///home/path/path/b.swift")! 107 | ] 108 | ] 109 | 110 | let explorer = FileExplorer() 111 | let pathIterator = MockPathIterator(urls: groupedList.flatMap({ $0.value })) 112 | 113 | let result = explorer.groupListByName(with: pathIterator) 114 | 115 | XCTAssertEqual(result, groupedList) 116 | } 117 | 118 | var testData: [URL] { 119 | [ 120 | URL(string: "file:///home/path/0.swift")!, 121 | URL(string: "file:///home/a.swift")!, 122 | URL(string: "file:///home/path/a.swift")!, 123 | URL(string: "file:///home/b.swift")!, 124 | URL(string: "file:///home/path/b.swift")!, 125 | URL(string: "file:///home/path/path/b.swift")!, 126 | URL(string: "file:///home/path/path/c.swift")!, 127 | URL(string: "file:///home/path/path/d.swift")!, 128 | ] 129 | } 130 | } 131 | 132 | -------------------------------------------------------------------------------- /FileExplorer/Tests/FileExplorerTests/Helper/MockPathIterator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockPathIterator.swift 3 | // Created by Nixon Shih on 2020/9/20. 4 | // 5 | 6 | @testable import FileExplorer 7 | import Foundation 8 | 9 | final class MockPathIterator: PathIterator { 10 | var urlIterator: IndexingIterator<[URL]>? 11 | 12 | init(urls: [URL]) { 13 | urlIterator = urls.makeIterator() 14 | } 15 | 16 | func next() -> URL? { 17 | urlIterator?.next() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /FileExplorer/Tests/FileExplorerTests/PathValidatorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PathValidatorTests.swift 3 | // Created by Nixon Shih on 2020/9/21. 4 | // 5 | 6 | @testable import FileExplorer 7 | import XCTest 8 | 9 | final class PathValidatorTests: XCTestCase { 10 | func testURLIsSystemFile() { 11 | let urlA = URL(string: "file:///home/pathA/.DS_Store")! 12 | let urlB = URL(string: "file:///home/pathB/.DS_Store")! 13 | let pathValidator = PathValidator(excludedInfo: ExcludedInfo()) 14 | 15 | XCTAssertTrue(pathValidator.verifyPathNeedExcluded(urlA)) 16 | XCTAssertTrue(pathValidator.verifyPathNeedExcluded(urlB)) 17 | } 18 | 19 | func testURLIsExcludedPath() { 20 | let urlA = URL(string: "file:///home/pathA/file.swift")! 21 | let urlB = URL(string: "file:///home/pathB/file.swift")! 22 | 23 | let excludedInfo = ExcludedInfo(directories: [URL(string: "file:///home/pathB/")!]) 24 | let pathValidator = PathValidator(excludedInfo: excludedInfo) 25 | 26 | XCTAssertFalse(pathValidator.verifyPathNeedExcluded(urlA)) 27 | XCTAssertTrue(pathValidator.verifyPathNeedExcluded(urlB)) 28 | } 29 | 30 | func testURLIsExcludedFile() { 31 | let urlA = URL(string: "file:///home/path/fileA.swift")! 32 | let urlB = URL(string: "file:///home/path/fileB.swift")! 33 | 34 | let excludedInfo = ExcludedInfo(fileNames: ["fileB.swift"]) 35 | let pathValidator = PathValidator(excludedInfo: excludedInfo) 36 | 37 | XCTAssertFalse(pathValidator.verifyPathNeedExcluded(urlA)) 38 | XCTAssertTrue(pathValidator.verifyPathNeedExcluded(urlB)) 39 | } 40 | 41 | func testNotFileURL() { 42 | let url = URL(string: "https://www.test.com")! 43 | let pathValidator = PathValidator(excludedInfo: ExcludedInfo()) 44 | 45 | let result = pathValidator.verifyPathIsFile(url) 46 | 47 | XCTAssertFalse(result) 48 | } 49 | 50 | func testURLIsDirectory() { 51 | let url = URL(string: "file:///home/path/")! 52 | let pathValidator = PathValidator(excludedInfo: ExcludedInfo()) 53 | 54 | let result = pathValidator.verifyPathIsFile(url) 55 | 56 | XCTAssertFalse(result) 57 | } 58 | 59 | func testInitialier() { 60 | var excludedFiles: Set = ["FileA", "FileB"] 61 | let excludedPaths: Set = [ 62 | URL(string: "file:///home/path/A/")!, 63 | URL(string: "file:///home/path/B/")!, 64 | ] 65 | 66 | let excludedInfo = ExcludedInfo(fileNames: excludedFiles, directories: excludedPaths) 67 | let pathValidator = PathValidator(excludedInfo: excludedInfo) 68 | 69 | for systemFile in SystemFile.allCases { 70 | excludedFiles.insert(systemFile.rawValue) 71 | } 72 | 73 | XCTAssertEqual(pathValidator.excludedInfo.fileNames, excludedFiles) 74 | XCTAssertEqual(pathValidator.excludedInfo.directories, excludedPaths) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /FileExplorer/Tests/FileExplorerTests/URL+fileNameTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+fileNameTests.swift 3 | // Created by Nixon Shih on 2020/9/19. 4 | // 5 | 6 | import FileExplorer 7 | import XCTest 8 | 9 | final class URL_fileNameTests: XCTestCase { 10 | func testGetFileName() { 11 | let url = URL(string: "file:///home/pathA/file.swift")! 12 | XCTAssertEqual(url.fileName, "file.swift") 13 | } 14 | 15 | func testGetFileNameWithoutExtension() { 16 | let url = URL(string: "file:///home/pathA/file")! 17 | XCTAssertEqual(url.fileName, "file") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Nixon Shih 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![swift](https://img.shields.io/badge/language-swift-red.svg)](https://developer.apple.com/swift/) 2 | [![OSX](https://img.shields.io/badge/platform-MacOS-brown.svg)](https://developer.apple.com/swift/) 3 | [![](https://github.com/powerwolf543/DuplicateFinder/workflows/UnitTests/badge.svg)](https://github.com/powerwolf543/DuplicateFinder/actions?query=workflow%3AUnitTests) 4 | [![codecov](https://codecov.io/gh/powerwolf543/DuplicateFinder/branch/master/graph/badge.svg)](https://codecov.io/gh/powerwolf543/DuplicateFinder/) 5 | 6 | # Duplicate Finder 7 | 8 | ![duplicate_finder_screen_shot](https://user-images.githubusercontent.com/16394562/94992308-bc3bf000-05bb-11eb-95a9-907ec334c660.png) 9 | 10 | **Duplicate Finder** is a macOS application that is written by Swift. 11 | It's a useful tool that would help you find all duplicate files with the same names in the specific folder. 12 | 13 | - [Installation](#Installation) 14 | - [Usage](#Usage) 15 | - [Requirements](#Requirements) 16 | - [Author](#Author) 17 | - [License](#License) 18 | 19 | ## Installation 20 | Download the newest application on the [releases](https://github.com/powerwolf543/DuplicateFinder/releases) page. 21 | 22 | ## Usage 23 | ### Search 24 | 1. Click `"Choose a target folder"` field to choose a target folder 25 | 2. (Optional) Set the paths which you want to exclude 26 | 3. (Optional) Set the file names which you want to exclude 27 | 4. Click `"Check"` button 28 | 29 | ### Save Preference 30 | - Tick `"Save Search Preference"` 31 | 32 | ## Requirements 33 | 34 | - Xcode 12.2 35 | - Swift 5.3 36 | - MacOS 10.15+ 37 | 38 | ## Author 39 | 40 | Nixon Shih, powerwolf543@gmail.com 41 | 42 | ## License 43 | 44 | **Duplicate Finder** is available under the MIT license. See the LICENSE file for more info. 45 | -------------------------------------------------------------------------------- /Utils/.swiftpm/xcode/xcshareddata/xcschemes/Utils.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 54 | 60 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /Utils/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Utils", 6 | platforms: [ 7 | .macOS(.v10_15), 8 | ], 9 | products: [ 10 | .library(name: "Utils", targets: ["Utils"]), 11 | ], 12 | targets: [ 13 | .target(name: "Utils", dependencies: []), 14 | .testTarget(name: "UtilsTests", dependencies: ["Utils"]), 15 | ] 16 | ) 17 | -------------------------------------------------------------------------------- /Utils/README.md: -------------------------------------------------------------------------------- 1 | # Utils 2 | 3 | **Utils** is an internal Swift Package that includes some tools to aid the project developments. 4 | 5 | -------------------------------------------------------------------------------- /Utils/Sources/Utils/Extensions/String+Localized.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Localized.swift 3 | // Created by Nixon Shih on 2020/9/23. 4 | // 5 | 6 | import Foundation 7 | 8 | /// Convenience extension of localized string 9 | extension String { 10 | /// Uses this syntax to replace NSLocalizedString 11 | public var localized: String { 12 | NSLocalizedString(self, comment: "") 13 | } 14 | 15 | /** 16 | Uses this syntax to replace NSLocalizedString with format string. 17 | - parameter arguments: Arguments for temlpate 18 | - returns: The formatted localized string with arguments. 19 | */ 20 | public func localizedFormat(with arguments: CVarArg...) -> String { 21 | String(format: localized, arguments: arguments) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Utils/Sources/Utils/Storage/Storage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Storage.swift 3 | // Created by Nixon Shih on 2020/9/23. 4 | // 5 | 6 | import Foundation 7 | 8 | /// A property wrapper that helps to store and retrieve `Value`. 9 | @propertyWrapper 10 | public final class Storage where Value: Codable { 11 | public var wrappedValue: Value { 12 | get { 13 | guard let data = projectedValue.retrieve(for: key), 14 | let valueContainer = try? JSONDecoder().decode(WrappedValueContainer.self, from: data) 15 | else { return defaultValue } 16 | return valueContainer.value 17 | } 18 | set { 19 | let valueContainer = WrappedValueContainer(value: newValue) 20 | guard let data = try? JSONEncoder().encode(valueContainer) else { return } 21 | projectedValue.store(data, for: key) 22 | } 23 | } 24 | 25 | public let key: String 26 | public let defaultValue: Value 27 | public private(set) var projectedValue: StoredContainer 28 | 29 | /// Creates `Storage` to store the value. 30 | /// - Parameters: 31 | /// - key: A key with which to associate the value. 32 | /// - defaultValue: A default value 33 | /// - storedContainer: A underlying stored container which provides the storing and retrieving. 34 | public init(key: String, defaultValue: Value, storedContainer: StoredContainer = UserDefaults.standard) { 35 | self.key = key 36 | self.defaultValue = defaultValue 37 | self.projectedValue = storedContainer 38 | } 39 | } 40 | 41 | internal struct WrappedValueContainer: Codable where T: Codable { 42 | internal let value: T 43 | } 44 | -------------------------------------------------------------------------------- /Utils/Sources/Utils/Storage/StoredContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoredContainer.swift 3 | // Created by Nixon Shih on 2020/9/23. 4 | // 5 | 6 | import Foundation 7 | 8 | /// A protocol that defines the interface to store and retrieve value. 9 | public protocol StoredContainer { 10 | mutating func store(_ value: Data, for key: String) 11 | func retrieve(for key: String) -> Data? 12 | } 13 | -------------------------------------------------------------------------------- /Utils/Sources/Utils/Storage/UserDefaults+StoredContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaults+StoredContainer.swift 3 | // Created by Nixon Shih on 2020/9/23. 4 | // 5 | 6 | import Foundation 7 | 8 | extension UserDefaults: StoredContainer { 9 | public func store(_ value: Data, for key: String) { 10 | setValue(value, forKey: key) 11 | } 12 | 13 | public func retrieve(for key: String) -> Data? { 14 | data(forKey: key) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Utils/Tests/UtilsTests/Helpers/MockStoredContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockStoredContainer.swift 3 | // Created by Nixon Shih on 2020/9/21. 4 | // 5 | 6 | import Utils 7 | import XCTest 8 | 9 | final class MockStoredContainer: StoredContainer { 10 | var map: [String: Data] = [:] 11 | 12 | func store(_ value: Data, for key: String) { 13 | map[key] = value 14 | } 15 | 16 | func retrieve(for key: String) -> Data? { 17 | map[key] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Utils/Tests/UtilsTests/StorageTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StorageTests.swift 3 | // Created by Nixon Shih on 2020/9/21. 4 | // 5 | 6 | @testable import Utils 7 | import XCTest 8 | 9 | final class StorageTests: XCTestCase { 10 | func testStoreData() throws { 11 | let key = "key" 12 | let text = "test" 13 | let container = MockStoredContainer() 14 | 15 | let storage = Storage(key: key, defaultValue: "", storedContainer: container) 16 | storage.wrappedValue = text 17 | 18 | let expectedResult = try createExpectedResult(value: text) 19 | XCTAssertEqual(container.map[key], expectedResult) 20 | } 21 | 22 | func testRetrieveData() throws { 23 | let key = "key" 24 | let text = "test" 25 | let input = try createExpectedResult(value: text) 26 | 27 | let container = MockStoredContainer() 28 | container.map[key] = input 29 | 30 | let storage = Storage(key: key, defaultValue: "", storedContainer: container) 31 | let result = storage.wrappedValue 32 | 33 | XCTAssertEqual(result, text) 34 | } 35 | 36 | func testRetrieveDefaultValue() { 37 | let key = "key" 38 | let defaultValue = "default" 39 | let container = MockStoredContainer() 40 | let storage = Storage(key: key, defaultValue: defaultValue, storedContainer: container) 41 | let result = storage.wrappedValue 42 | 43 | XCTAssertEqual(result, defaultValue) 44 | } 45 | 46 | func createExpectedResult(value: T) throws -> Data where T: Codable { 47 | let container = WrappedValueContainer(value: value) 48 | return try JSONEncoder().encode(container) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: off 4 | patch: off 5 | 6 | ignore: 7 | - "FileExplorer/Sources/FileExplorer/PathIterator/DiskPathIterator.swift" 8 | - "FileExplorer/Tests/**/*" 9 | - "Utils/Tests/**/*" 10 | - "Utils/Sources/Utils/Storage/UserDefaults*.swift" 11 | --------------------------------------------------------------------------------