Stuff…
0
Base.lproj/Credits.rtf
Normal file → Executable file
59
Changelog.txt
Normal file → Executable file
@@ -1,32 +1,51 @@
|
||||
Version 1.0
|
||||
|
||||
- First Release
|
||||
- First Release
|
||||
|
||||
Version 1.1
|
||||
|
||||
Added:
|
||||
+ 'Try again' option for errors
|
||||
+ Options for tags not to be saved
|
||||
+ Option to use censored names
|
||||
+ Option for case (in)sensitivity
|
||||
+ Toolbar button to just save the artwork of the selected items
|
||||
Added:
|
||||
+ 'Try again' option for errors
|
||||
+ Options for tags not to be saved
|
||||
+ Option to use censored names
|
||||
+ Option for case (in)sensitivity
|
||||
+ Toolbar button to just save the artwork of the selected items
|
||||
|
||||
Fixed:
|
||||
* Added support for OS X 10.11 El Capitan
|
||||
* Error descriptions are now more understandable
|
||||
* Cancelling the saving process of tracks does not crash the application on slow network connections anymore
|
||||
Fixed:
|
||||
* Added support for OS X 10.11 El Capitan
|
||||
* Error descriptions are now more understandable
|
||||
* Cancelling the saving process of tracks does not crash the application on slow network connections anymore
|
||||
|
||||
Version 1.2
|
||||
|
||||
Added:
|
||||
+ Option for displayed number of search results
|
||||
+ Option to change the iTunes Store used to get tags
|
||||
+ Option to remove saved items
|
||||
Added:
|
||||
+ Option for displayed number of search results
|
||||
+ Option to change the iTunes Store used to get tags
|
||||
+ Option to remove saved items
|
||||
|
||||
1.2.1
|
||||
Version 1.2.1
|
||||
|
||||
Added:
|
||||
+ Option to use English tags when using a different iTunes Store
|
||||
Added:
|
||||
+ Option to use English tags when using a different iTunes Store
|
||||
|
||||
Fixed:
|
||||
* Fixed an issue where TagTunes would sometimes crash when the user selected an album or track in the list
|
||||
Fixed:
|
||||
* Fixed an issue where TagTunes would sometimes crash when the user selected an album or track in the list
|
||||
|
||||
Version 2.0
|
||||
|
||||
Added:
|
||||
+ Updated layout to match the Yosemite/El Capitan Look and Feel
|
||||
+ Search Results in a pop up
|
||||
+ Artworks are saved directly to iTunes
|
||||
+ Unsorted songs are displayed in a designated window
|
||||
+ It is possible to lookup bought and matched songs in the iTunes Store
|
||||
+ Release Date Tag
|
||||
+ Compilation Tag (not always returned by the Search API)
|
||||
|
||||
Removed:
|
||||
- Some Preferences that are now unneccessary (e.g. the destination for artworks)
|
||||
|
||||
Fixed:
|
||||
* All tags can now be cleared
|
||||
* A lot of internal changes
|
||||
* The saving process not shows correctly
|
||||
160
TagTunes.xcodeproj/project.pbxproj
Normal file → Executable file
@@ -7,29 +7,46 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
3B0DA0091C9B48CA004953C8 /* ImportedTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B0DA0071C9B48CA004953C8 /* ImportedTrack.swift */; };
|
||||
3B0DA00A1C9B48CA004953C8 /* TagTunesTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B0DA0081C9B48CA004953C8 /* TagTunesTrack.swift */; };
|
||||
3B1A99411C970496008CD6CA /* String+AEKeyword.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1A99401C970496008CD6CA /* String+AEKeyword.swift */; };
|
||||
3B2482921CBCECCD003DBC51 /* LookupPreparationOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2482911CBCECCD003DBC51 /* LookupPreparationOperation.swift */; };
|
||||
3B285DB81B9128C100F0A2F1 /* Preference Controllers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B285DB71B9128C100F0A2F1 /* Preference Controllers.swift */; };
|
||||
3B285DBF1B912AB700F0A2F1 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B285DBE1B912AB700F0A2F1 /* Preferences.swift */; };
|
||||
3B489DBF1B90B055002B7EB3 /* TrackTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DBD1B90B055002B7EB3 /* TrackTableCellView.swift */; };
|
||||
3B489DC31B90B116002B7EB3 /* Artwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DC01B90B116002B7EB3 /* Artwork.swift */; };
|
||||
3B489DC41B90B116002B7EB3 /* Album.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DC11B90B116002B7EB3 /* Album.swift */; };
|
||||
3B489DC51B90B116002B7EB3 /* Track.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DC21B90B116002B7EB3 /* Track.swift */; };
|
||||
3B4057CD1CA1AA33004F210E /* ActivityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B4057CC1CA1AA33004F210E /* ActivityViewController.swift */; };
|
||||
3B489DBF1B90B055002B7EB3 /* SongTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DBD1B90B055002B7EB3 /* SongTableCellView.swift */; };
|
||||
3B489DC41B90B116002B7EB3 /* AlbumItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DC11B90B116002B7EB3 /* AlbumItem.swift */; };
|
||||
3B489DC51B90B116002B7EB3 /* SongItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DC21B90B116002B7EB3 /* SongItem.swift */; };
|
||||
3B489DC71B90B38C002B7EB3 /* iTunes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DC61B90B38C002B7EB3 /* iTunes.swift */; };
|
||||
3B489DCB1B90B3E3002B7EB3 /* iTunes.m in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DCA1B90B3E3002B7EB3 /* iTunes.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; };
|
||||
3B489DD61B90E0D8002B7EB3 /* AlbumTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DD51B90E0D8002B7EB3 /* AlbumTableCellView.swift */; };
|
||||
3B49F84D1CB79B93004E2857 /* LookupOperationTmp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B49F84C1CB79B93004E2857 /* LookupOperationTmp.swift */; };
|
||||
3B49F8501CB7A56A004E2857 /* SaveTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B49F84F1CB7A56A004E2857 /* SaveTableCellView.swift */; };
|
||||
3B49F8521CB7AC43004E2857 /* SaveOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B49F8511CB7AC43004E2857 /* SaveOperation.swift */; };
|
||||
3B49F85F1CB7FA8B004E2857 /* LookupOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B49F85E1CB7FA8B004E2857 /* LookupOperation.swift */; };
|
||||
3B51AAEB1C89E9A700759F00 /* UnsortedTracksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B51AAEA1C89E9A700759F00 /* UnsortedTracksViewController.swift */; };
|
||||
3B76C7731B909B280025D550 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B76C7721B909B280025D550 /* AppDelegate.swift */; };
|
||||
3B76C7751B909B280025D550 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B76C7741B909B280025D550 /* MainViewController.swift */; };
|
||||
3B76C77A1B909B280025D550 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3B76C7781B909B280025D550 /* Main.storyboard */; };
|
||||
3B76C7851B909B280025D550 /* TagTunesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B76C7841B909B280025D550 /* TagTunesTests.swift */; };
|
||||
3B76C7901B909B280025D550 /* TagTunesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B76C78F1B909B280025D550 /* TagTunesUITests.swift */; };
|
||||
3B8546DC1C513BCD00931755 /* SearchAPI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B8546DB1C513BCD00931755 /* SearchAPI.framework */; };
|
||||
3B8546DD1C513BCD00931755 /* SearchAPI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B8546DB1C513BCD00931755 /* SearchAPI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
3B8546DF1C5142B600931755 /* TagTunesItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B8546DE1C5142B600931755 /* TagTunesItem.swift */; };
|
||||
3B8546E11C51685F00931755 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B8546E01C51685F00931755 /* Tag.swift */; };
|
||||
3B8546E31C51767300931755 /* ContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B8546E21C51767300931755 /* ContentViewController.swift */; };
|
||||
3B8546E51C517BDE00931755 /* OutlineContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B8546E41C517BDE00931755 /* OutlineContentViewController.swift */; };
|
||||
3B86A4021BA9E94F00B150AE /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 3B86A4041BA9E94F00B150AE /* Credits.rtf */; };
|
||||
3B96BD661B9CA24100CC4101 /* DescriptiveError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B96BD651B9CA24100CC4101 /* DescriptiveError.swift */; };
|
||||
3B9752721BA85C2F00E26515 /* AppKitPlus.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3BBF710A1B95E00F00BB1EDB /* AppKitPlus.framework */; };
|
||||
3B9752731BA85C2F00E26515 /* AppKitPlus.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3BBF710A1B95E00F00BB1EDB /* AppKitPlus.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
3BAD17CD1B9F0F6800FEF908 /* AlbumCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD17CC1B9F0F6800FEF908 /* AlbumCollection.swift */; };
|
||||
3BB1C97D1BA76F5F0083301F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3BB1C97C1BA76F5F0083301F /* Assets.xcassets */; settings = {ASSET_TAGS = (); }; };
|
||||
3BBF6FA01B946B7000BB1EDB /* SearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BBF6F9F1B946B7000BB1EDB /* SearchResult.swift */; };
|
||||
3BFDED621BA84AD1007E7F36 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3BFDED601BA84AD1007E7F36 /* Localizable.strings */; settings = {ASSET_TAGS = (); }; };
|
||||
3BFDED741BA855B8007E7F36 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 3BFDED761BA855B8007E7F36 /* Localizable.stringsdict */; };
|
||||
3BA23C5F1C8F7C4F0027691C /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 3BA23C611C8F7C4F0027691C /* Localizable.stringsdict */; };
|
||||
3BA23C641C8F931F0027691C /* MainWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA23C631C8F931F0027691C /* MainWindowController.swift */; };
|
||||
3BB1C97D1BA76F5F0083301F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3BB1C97C1BA76F5F0083301F /* Assets.xcassets */; };
|
||||
3BE5268F1C8CE75300DDA4E0 /* SearchController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BE5268E1C8CE75300DDA4E0 /* SearchController.swift */; };
|
||||
3BE526941C8D9C7600DDA4E0 /* LookupQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BE526921C8D9C7600DDA4E0 /* LookupQueue.swift */; };
|
||||
3BE526971C8D9EC700DDA4E0 /* UnsortedTracksController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BE526961C8D9EC700DDA4E0 /* UnsortedTracksController.swift */; };
|
||||
3BFDED621BA84AD1007E7F36 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3BFDED601BA84AD1007E7F36 /* Localizable.strings */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -57,6 +74,7 @@
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
3B9752731BA85C2F00E26515 /* AppKitPlus.framework in Embed Frameworks */,
|
||||
3B8546DD1C513BCD00931755 /* SearchAPI.framework in Embed Frameworks */,
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -64,18 +82,26 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
3B0DA0071C9B48CA004953C8 /* ImportedTrack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImportedTrack.swift; sourceTree = "<group>"; };
|
||||
3B0DA0081C9B48CA004953C8 /* TagTunesTrack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TagTunesTrack.swift; sourceTree = "<group>"; };
|
||||
3B1A99401C970496008CD6CA /* String+AEKeyword.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+AEKeyword.swift"; sourceTree = "<group>"; };
|
||||
3B2482911CBCECCD003DBC51 /* LookupPreparationOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LookupPreparationOperation.swift; sourceTree = "<group>"; };
|
||||
3B285DB71B9128C100F0A2F1 /* Preference Controllers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Preference Controllers.swift"; sourceTree = "<group>"; };
|
||||
3B285DBE1B912AB700F0A2F1 /* Preferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
|
||||
3B489DBD1B90B055002B7EB3 /* TrackTableCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = TrackTableCellView.swift; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
|
||||
3B489DC01B90B116002B7EB3 /* Artwork.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Artwork.swift; sourceTree = "<group>"; };
|
||||
3B489DC11B90B116002B7EB3 /* Album.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Album.swift; sourceTree = "<group>"; };
|
||||
3B489DC21B90B116002B7EB3 /* Track.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Track.swift; sourceTree = "<group>"; };
|
||||
3B4057CC1CA1AA33004F210E /* ActivityViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActivityViewController.swift; sourceTree = "<group>"; };
|
||||
3B489DBD1B90B055002B7EB3 /* SongTableCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = SongTableCellView.swift; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
|
||||
3B489DC11B90B116002B7EB3 /* AlbumItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlbumItem.swift; sourceTree = "<group>"; };
|
||||
3B489DC21B90B116002B7EB3 /* SongItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SongItem.swift; sourceTree = "<group>"; };
|
||||
3B489DC61B90B38C002B7EB3 /* iTunes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iTunes.swift; sourceTree = "<group>"; };
|
||||
3B489DC81B90B3E2002B7EB3 /* TagTunes-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "TagTunes-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
3B489DC91B90B3E3002B7EB3 /* iTunes.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = iTunes.h; sourceTree = "<group>"; };
|
||||
3B489DCA1B90B3E3002B7EB3 /* iTunes.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = iTunes.m; sourceTree = "<group>"; };
|
||||
3B489DD51B90E0D8002B7EB3 /* AlbumTableCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = AlbumTableCellView.swift; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
|
||||
3B4A0A931BD790CE00EF1BA0 /* de */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = de; path = de.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
3B49F84C1CB79B93004E2857 /* LookupOperationTmp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LookupOperationTmp.swift; sourceTree = "<group>"; };
|
||||
3B49F84F1CB7A56A004E2857 /* SaveTableCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SaveTableCellView.swift; sourceTree = "<group>"; };
|
||||
3B49F8511CB7AC43004E2857 /* SaveOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SaveOperation.swift; sourceTree = "<group>"; };
|
||||
3B49F85E1CB7FA8B004E2857 /* LookupOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LookupOperation.swift; sourceTree = "<group>"; };
|
||||
3B51AAEA1C89E9A700759F00 /* UnsortedTracksViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnsortedTracksViewController.swift; sourceTree = "<group>"; };
|
||||
3B76C76F1B909B280025D550 /* TagTunes.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TagTunes.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B76C7721B909B280025D550 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
3B76C7741B909B280025D550 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; };
|
||||
@@ -87,20 +113,26 @@
|
||||
3B76C78B1B909B280025D550 /* TagTunesUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TagTunesUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B76C78F1B909B280025D550 /* TagTunesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagTunesUITests.swift; sourceTree = "<group>"; };
|
||||
3B76C7911B909B280025D550 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
3B8546DB1C513BCD00931755 /* SearchAPI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = SearchAPI.framework; path = "/Users/Kim/Library/Developer/Xcode/DerivedData/TagTunes-ahlftzbggvvcneeglkkowfbohpzh/Build/Products/Debug/SearchAPI.framework"; sourceTree = "<absolute>"; };
|
||||
3B8546DE1C5142B600931755 /* TagTunesItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TagTunesItem.swift; sourceTree = "<group>"; };
|
||||
3B8546E01C51685F00931755 /* Tag.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
|
||||
3B8546E21C51767300931755 /* ContentViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentViewController.swift; sourceTree = "<group>"; };
|
||||
3B8546E41C517BDE00931755 /* OutlineContentViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutlineContentViewController.swift; sourceTree = "<group>"; };
|
||||
3B86A4001BA9E92F00B150AE /* de */ = {isa = PBXFileReference; fileEncoding = 10; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
3B86A4011BA9E92F00B150AE /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = de; path = de.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||
3B86A4031BA9E94F00B150AE /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; name = Base; path = Base.lproj/Credits.rtf; sourceTree = "<group>"; };
|
||||
3B86A4051BA9E95100B150AE /* de */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; name = de; path = de.lproj/Credits.rtf; sourceTree = "<group>"; };
|
||||
3B96BD651B9CA24100CC4101 /* DescriptiveError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DescriptiveError.swift; sourceTree = "<group>"; };
|
||||
3B97526A1BA85B5A00E26515 /* AppKitPlus.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKitPlus.framework; path = ../AppKitPlus/build/Release/AppKitPlus.framework; sourceTree = "<group>"; };
|
||||
3BAD17CC1B9F0F6800FEF908 /* AlbumCollection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlbumCollection.swift; sourceTree = "<group>"; };
|
||||
3BA23C601C8F7C4F0027691C /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = Base; path = Base.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||
3BA23C621C8F7C590027691C /* de */ = {isa = PBXFileReference; explicitFileType = text.plist.xml; name = de; path = de.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||
3BA23C631C8F931F0027691C /* MainWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainWindowController.swift; sourceTree = "<group>"; };
|
||||
3BB1C97C1BA76F5F0083301F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
3BB8C5421BA2EEE800031021 /* Changelog.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Changelog.txt; sourceTree = "<group>"; };
|
||||
3BBF6F9F1B946B7000BB1EDB /* SearchResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchResult.swift; sourceTree = "<group>"; };
|
||||
3BBF710A1B95E00F00BB1EDB /* AppKitPlus.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKitPlus.framework; path = "../../../../Library/Developer/Xcode/DerivedData/TagTunes-ahlftzbggvvcneeglkkowfbohpzh/Build/Products/Debug/AppKitPlus.framework"; sourceTree = "<group>"; };
|
||||
3BBF71161B98FB4200BB1EDB /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
|
||||
3BE5268E1C8CE75300DDA4E0 /* SearchController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchController.swift; sourceTree = "<group>"; };
|
||||
3BE526921C8D9C7600DDA4E0 /* LookupQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LookupQueue.swift; sourceTree = "<group>"; };
|
||||
3BE526961C8D9EC700DDA4E0 /* UnsortedTracksController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnsortedTracksController.swift; sourceTree = "<group>"; };
|
||||
3BFDED611BA84AD1007E7F36 /* Base */ = {isa = PBXFileReference; fileEncoding = 10; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
3BFDED751BA855B8007E7F36 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = Base; path = Base.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -109,6 +141,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
3B9752721BA85C2F00E26515 /* AppKitPlus.framework in Frameworks */,
|
||||
3B8546DC1C513BCD00931755 /* SearchAPI.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -134,10 +167,23 @@
|
||||
children = (
|
||||
3B489DC91B90B3E3002B7EB3 /* iTunes.h */,
|
||||
3B489DCA1B90B3E3002B7EB3 /* iTunes.m */,
|
||||
3B1A99401C970496008CD6CA /* String+AEKeyword.swift */,
|
||||
);
|
||||
name = Ultil;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3B49F84B1CB79B82004E2857 /* App Operations */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3BE526921C8D9C7600DDA4E0 /* LookupQueue.swift */,
|
||||
3B49F85E1CB7FA8B004E2857 /* LookupOperation.swift */,
|
||||
3B2482911CBCECCD003DBC51 /* LookupPreparationOperation.swift */,
|
||||
3B49F84C1CB79B93004E2857 /* LookupOperationTmp.swift */,
|
||||
3B49F8511CB7AC43004E2857 /* SaveOperation.swift */,
|
||||
);
|
||||
name = "App Operations";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3B76C7661B909B280025D550 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -168,6 +214,7 @@
|
||||
3B76C79D1B909B8C0025D550 /* Model */,
|
||||
3B76C79F1B909B960025D550 /* View */,
|
||||
3B76C79E1B909B910025D550 /* Controller */,
|
||||
3B49F84B1CB79B82004E2857 /* App Operations */,
|
||||
3BFDED631BA84ADB007E7F36 /* Resources */,
|
||||
3B489DC81B90B3E2002B7EB3 /* TagTunes-Bridging-Header.h */,
|
||||
);
|
||||
@@ -195,14 +242,15 @@
|
||||
3B76C79D1B909B8C0025D550 /* Model */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3B489DC11B90B116002B7EB3 /* Album.swift */,
|
||||
3BAD17CC1B9F0F6800FEF908 /* AlbumCollection.swift */,
|
||||
3B489DC01B90B116002B7EB3 /* Artwork.swift */,
|
||||
3B8546E01C51685F00931755 /* Tag.swift */,
|
||||
3B0DA0081C9B48CA004953C8 /* TagTunesTrack.swift */,
|
||||
3B0DA0071C9B48CA004953C8 /* ImportedTrack.swift */,
|
||||
3B8546DE1C5142B600931755 /* TagTunesItem.swift */,
|
||||
3B489DC11B90B116002B7EB3 /* AlbumItem.swift */,
|
||||
3B489DC21B90B116002B7EB3 /* SongItem.swift */,
|
||||
3B96BD651B9CA24100CC4101 /* DescriptiveError.swift */,
|
||||
3B489DC61B90B38C002B7EB3 /* iTunes.swift */,
|
||||
3B285DBE1B912AB700F0A2F1 /* Preferences.swift */,
|
||||
3BBF6F9F1B946B7000BB1EDB /* SearchResult.swift */,
|
||||
3B489DC21B90B116002B7EB3 /* Track.swift */,
|
||||
);
|
||||
name = Model;
|
||||
sourceTree = "<group>";
|
||||
@@ -211,8 +259,15 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3B76C7721B909B280025D550 /* AppDelegate.swift */,
|
||||
3B76C7741B909B280025D550 /* MainViewController.swift */,
|
||||
3B285DB71B9128C100F0A2F1 /* Preference Controllers.swift */,
|
||||
3BE526961C8D9EC700DDA4E0 /* UnsortedTracksController.swift */,
|
||||
3B51AAEA1C89E9A700759F00 /* UnsortedTracksViewController.swift */,
|
||||
3BA23C631C8F931F0027691C /* MainWindowController.swift */,
|
||||
3B76C7741B909B280025D550 /* MainViewController.swift */,
|
||||
3BE5268E1C8CE75300DDA4E0 /* SearchController.swift */,
|
||||
3B4057CC1CA1AA33004F210E /* ActivityViewController.swift */,
|
||||
3B8546E21C51767300931755 /* ContentViewController.swift */,
|
||||
3B8546E41C517BDE00931755 /* OutlineContentViewController.swift */,
|
||||
);
|
||||
name = Controller;
|
||||
sourceTree = "<group>";
|
||||
@@ -221,7 +276,8 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3B489DD51B90E0D8002B7EB3 /* AlbumTableCellView.swift */,
|
||||
3B489DBD1B90B055002B7EB3 /* TrackTableCellView.swift */,
|
||||
3B49F84F1CB7A56A004E2857 /* SaveTableCellView.swift */,
|
||||
3B489DBD1B90B055002B7EB3 /* SongTableCellView.swift */,
|
||||
);
|
||||
name = View;
|
||||
sourceTree = "<group>";
|
||||
@@ -229,7 +285,7 @@
|
||||
3BBF710C1B95E02E00BB1EDB /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3B97526A1BA85B5A00E26515 /* AppKitPlus.framework */,
|
||||
3B8546DB1C513BCD00931755 /* SearchAPI.framework */,
|
||||
3BBF710A1B95E00F00BB1EDB /* AppKitPlus.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
@@ -241,9 +297,9 @@
|
||||
3B76C7781B909B280025D550 /* Main.storyboard */,
|
||||
3BB1C97C1BA76F5F0083301F /* Assets.xcassets */,
|
||||
3BFDED601BA84AD1007E7F36 /* Localizable.strings */,
|
||||
3BA23C611C8F7C4F0027691C /* Localizable.stringsdict */,
|
||||
3B76C77B1B909B280025D550 /* Info.plist */,
|
||||
3B86A4041BA9E94F00B150AE /* Credits.rtf */,
|
||||
3BFDED761BA855B8007E7F36 /* Localizable.stringsdict */,
|
||||
);
|
||||
name = Resources;
|
||||
sourceTree = "<group>";
|
||||
@@ -358,7 +414,7 @@
|
||||
3B76C77A1B909B280025D550 /* Main.storyboard in Resources */,
|
||||
3BB1C97D1BA76F5F0083301F /* Assets.xcassets in Resources */,
|
||||
3B86A4021BA9E94F00B150AE /* Credits.rtf in Resources */,
|
||||
3BFDED741BA855B8007E7F36 /* Localizable.stringsdict in Resources */,
|
||||
3BA23C5F1C8F7C4F0027691C /* Localizable.stringsdict in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -383,20 +439,35 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
3BBF6FA01B946B7000BB1EDB /* SearchResult.swift in Sources */,
|
||||
3B489DC31B90B116002B7EB3 /* Artwork.swift in Sources */,
|
||||
3B8546E11C51685F00931755 /* Tag.swift in Sources */,
|
||||
3B8546E31C51767300931755 /* ContentViewController.swift in Sources */,
|
||||
3B49F8501CB7A56A004E2857 /* SaveTableCellView.swift in Sources */,
|
||||
3B76C7751B909B280025D550 /* MainViewController.swift in Sources */,
|
||||
3BE526941C8D9C7600DDA4E0 /* LookupQueue.swift in Sources */,
|
||||
3BE526971C8D9EC700DDA4E0 /* UnsortedTracksController.swift in Sources */,
|
||||
3B76C7731B909B280025D550 /* AppDelegate.swift in Sources */,
|
||||
3B489DBF1B90B055002B7EB3 /* TrackTableCellView.swift in Sources */,
|
||||
3BAD17CD1B9F0F6800FEF908 /* AlbumCollection.swift in Sources */,
|
||||
3B489DBF1B90B055002B7EB3 /* SongTableCellView.swift in Sources */,
|
||||
3B4057CD1CA1AA33004F210E /* ActivityViewController.swift in Sources */,
|
||||
3BA23C641C8F931F0027691C /* MainWindowController.swift in Sources */,
|
||||
3B0DA0091C9B48CA004953C8 /* ImportedTrack.swift in Sources */,
|
||||
3B285DBF1B912AB700F0A2F1 /* Preferences.swift in Sources */,
|
||||
3B489DC41B90B116002B7EB3 /* Album.swift in Sources */,
|
||||
3B489DC51B90B116002B7EB3 /* Track.swift in Sources */,
|
||||
3B489DC41B90B116002B7EB3 /* AlbumItem.swift in Sources */,
|
||||
3B489DC51B90B116002B7EB3 /* SongItem.swift in Sources */,
|
||||
3B51AAEB1C89E9A700759F00 /* UnsortedTracksViewController.swift in Sources */,
|
||||
3B8546E51C517BDE00931755 /* OutlineContentViewController.swift in Sources */,
|
||||
3BE5268F1C8CE75300DDA4E0 /* SearchController.swift in Sources */,
|
||||
3B489DC71B90B38C002B7EB3 /* iTunes.swift in Sources */,
|
||||
3B8546DF1C5142B600931755 /* TagTunesItem.swift in Sources */,
|
||||
3B285DB81B9128C100F0A2F1 /* Preference Controllers.swift in Sources */,
|
||||
3B1A99411C970496008CD6CA /* String+AEKeyword.swift in Sources */,
|
||||
3B96BD661B9CA24100CC4101 /* DescriptiveError.swift in Sources */,
|
||||
3B49F8521CB7AC43004E2857 /* SaveOperation.swift in Sources */,
|
||||
3B49F85F1CB7FA8B004E2857 /* LookupOperation.swift in Sources */,
|
||||
3B489DCB1B90B3E3002B7EB3 /* iTunes.m in Sources */,
|
||||
3B0DA00A1C9B48CA004953C8 /* TagTunesTrack.swift in Sources */,
|
||||
3B489DD61B90E0D8002B7EB3 /* AlbumTableCellView.swift in Sources */,
|
||||
3B49F84D1CB79B93004E2857 /* LookupOperationTmp.swift in Sources */,
|
||||
3B2482921CBCECCD003DBC51 /* LookupPreparationOperation.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -436,7 +507,6 @@
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
3B76C7791B909B280025D550 /* Base */,
|
||||
3B4A0A931BD790CE00EF1BA0 /* de */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
@@ -451,6 +521,15 @@
|
||||
path = ..;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3BA23C611C8F7C4F0027691C /* Localizable.stringsdict */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
3BA23C601C8F7C4F0027691C /* Base */,
|
||||
3BA23C621C8F7C590027691C /* de */,
|
||||
);
|
||||
name = Localizable.stringsdict;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3BFDED601BA84AD1007E7F36 /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
@@ -460,15 +539,6 @@
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3BFDED761BA855B8007E7F36 /* Localizable.stringsdict */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
3BFDED751BA855B8007E7F36 /* Base */,
|
||||
3B86A4011BA9E92F00B150AE /* de */,
|
||||
);
|
||||
name = Localizable.stringsdict;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
|
||||
0
TagTunes.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file → Executable file
30
TagTunes.xcodeproj/project.xcworkspace/xcshareddata/TagTunes.xcscmblueprint
Executable file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"DVTSourceControlWorkspaceBlueprintPrimaryRemoteRepositoryKey" : "F774C9974FDC4186CD9EAC17E9D53637171260A1",
|
||||
"DVTSourceControlWorkspaceBlueprintWorkingCopyRepositoryLocationsKey" : {
|
||||
|
||||
},
|
||||
"DVTSourceControlWorkspaceBlueprintWorkingCopyStatesKey" : {
|
||||
"1212072DD9F3632F85477B6F74F796972EF39599" : 0,
|
||||
"F774C9974FDC4186CD9EAC17E9D53637171260A1" : 0
|
||||
},
|
||||
"DVTSourceControlWorkspaceBlueprintIdentifierKey" : "BC04FE54-1FBA-4CE6-A0A4-AAED36E7734A",
|
||||
"DVTSourceControlWorkspaceBlueprintWorkingCopyPathsKey" : {
|
||||
"1212072DD9F3632F85477B6F74F796972EF39599" : "AppKitPlus\/",
|
||||
"F774C9974FDC4186CD9EAC17E9D53637171260A1" : "TagTunes\/"
|
||||
},
|
||||
"DVTSourceControlWorkspaceBlueprintNameKey" : "TagTunes",
|
||||
"DVTSourceControlWorkspaceBlueprintVersion" : 204,
|
||||
"DVTSourceControlWorkspaceBlueprintRelativePathToProjectKey" : "TagTunes.xcodeproj",
|
||||
"DVTSourceControlWorkspaceBlueprintRemoteRepositoriesKey" : [
|
||||
{
|
||||
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "https:\/\/bitbucket.org\/Codello\/appkitplus.git",
|
||||
"DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git",
|
||||
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "1212072DD9F3632F85477B6F74F796972EF39599"
|
||||
},
|
||||
{
|
||||
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "https:\/\/bitbucket.org\/Codello\/tagtunes.git",
|
||||
"DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git",
|
||||
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "F774C9974FDC4186CD9EAC17E9D53637171260A1"
|
||||
}
|
||||
]
|
||||
}
|
||||
0
TagTunes.xcodeproj/project.xcworkspace/xcuserdata/Kim.xcuserdatad/UserInterfaceState.xcuserstate
generated
Normal file → Executable file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Bucket
|
||||
type = "1"
|
||||
version = "2.0">
|
||||
</Bucket>
|
||||
0
TagTunes.xcodeproj/xcuserdata/Kim.xcuserdatad/xcschemes/TagTunes.xcscheme
Normal file → Executable file
0
TagTunes.xcodeproj/xcuserdata/Kim.xcuserdatad/xcschemes/xcschememanagement.plist
Normal file → Executable file
176
TagTunes/ActivityViewController.swift
Executable file
@@ -0,0 +1,176 @@
|
||||
//
|
||||
// ActivityViewController.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 22.03.16.
|
||||
// Copyright © 2016 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import SearchAPI
|
||||
import AppKitPlus
|
||||
|
||||
// TODO: Order Fields, Documentation
|
||||
|
||||
let saveQueue = OperationQueue()
|
||||
|
||||
// TODO: Better Name
|
||||
private var operationsContext = 0
|
||||
|
||||
class ActivityViewController: NSViewController, OperationQueueDelegate {
|
||||
|
||||
private struct Groups {
|
||||
static let Lookup: AnyObject = "LookupGroup"
|
||||
static let Save: AnyObject = "SaveGroup"
|
||||
}
|
||||
|
||||
private static let LookupItem: AnyObject = "LookupActivityItem"
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
@IBOutlet weak var outlineView: NSOutlineView!
|
||||
|
||||
private var saveOperations = [SaveOperation]() {
|
||||
didSet {
|
||||
noteOutlineContentDidChange()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
override init?(nibName nibNameOrNil: String?, bundle nibBundleOrNil: NSBundle?) {
|
||||
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
||||
registerObservers()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
registerObservers()
|
||||
}
|
||||
|
||||
private func registerObservers() {
|
||||
saveQueue.addObserver(self, forKeyPath: "operations", options: [], context: &operationsContext)
|
||||
LookupQueue.globalQueue.delegate = self
|
||||
}
|
||||
|
||||
// MARK: Obersers and Delegates
|
||||
|
||||
private func noteOutlineContentDidChange() {
|
||||
// TODO: Optimize this to reload single rows
|
||||
self.outlineView?.reloadData()
|
||||
self.outlineView?.expandItem(nil, expandChildren: true)
|
||||
}
|
||||
|
||||
override func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer<Void>) {
|
||||
if context == &operationsContext {
|
||||
let operations = saveQueue.operations.flatMap { $0 as? SaveOperation }
|
||||
dispatch_async(dispatch_get_main_queue()) {
|
||||
self.saveOperations = operations
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func operationQueue(operationQueue: OperationQueue, willAddOperation operation: NSOperation) {
|
||||
if operationQueue === LookupQueue.globalQueue {
|
||||
noteOutlineContentDidChange()
|
||||
}
|
||||
}
|
||||
|
||||
func operationQueue(operationQueue: OperationQueue, operationDidFinish operation: NSOperation, withErrors errors: [NSError]) {
|
||||
// TODO: Why is this not on main thread and willAdd... is?
|
||||
dispatch_sync(dispatch_get_main_queue()) {
|
||||
if operationQueue === LookupQueue.globalQueue {
|
||||
self.noteOutlineContentDidChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ActivityViewController: NSOutlineViewDataSource, NSOutlineViewDelegate {
|
||||
|
||||
func outlineView(outlineView: NSOutlineView, numberOfChildrenOfItem item: AnyObject?) -> Int {
|
||||
if item == nil {
|
||||
return 2
|
||||
}
|
||||
if item === Groups.Lookup {
|
||||
return 0 // TODO: Return sometimes 1
|
||||
}
|
||||
if item === Groups.Save {
|
||||
return saveOperations.count
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func outlineView(outlineView: NSOutlineView, child index: Int, ofItem item: AnyObject?) -> AnyObject {
|
||||
if item == nil {
|
||||
return [Groups.Lookup, Groups.Save][index]
|
||||
}
|
||||
if item === Groups.Lookup {
|
||||
return ActivityViewController.LookupItem
|
||||
}
|
||||
if item === Groups.Save {
|
||||
return saveOperations[index]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func outlineView(outlineView: NSOutlineView, isItemExpandable item: AnyObject) -> Bool {
|
||||
return self.outlineView(outlineView, isGroupItem: item)
|
||||
}
|
||||
|
||||
func outlineView(outlineView: NSOutlineView, isGroupItem item: AnyObject) -> Bool {
|
||||
return item === Groups.Lookup || item === Groups.Save
|
||||
}
|
||||
|
||||
func outlineView(outlineView: NSOutlineView, shouldShowOutlineCellForItem item: AnyObject) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func outlineView(outlineView: NSOutlineView, heightOfRowByItem item: AnyObject) -> CGFloat {
|
||||
if self.outlineView(outlineView, isGroupItem: item) {
|
||||
return 24
|
||||
} else {
|
||||
return 42
|
||||
}
|
||||
}
|
||||
|
||||
func outlineView(outlineView: NSOutlineView, viewForTableColumn tableColumn: NSTableColumn?, item: AnyObject) -> NSView? {
|
||||
if item === Groups.Lookup {
|
||||
let view = outlineView.makeViewWithIdentifier("GroupCell", owner: nil) as? NSTableCellView
|
||||
// TODO: Localize
|
||||
view?.textField?.stringValue = "iTunes Match"
|
||||
return view
|
||||
}
|
||||
if item === Groups.Save {
|
||||
let view = outlineView.makeViewWithIdentifier("GroupCell", owner: nil) as? NSTableCellView
|
||||
// TODO: Localize
|
||||
view?.textField?.stringValue = "Saving"
|
||||
return view
|
||||
}
|
||||
print(item)
|
||||
if item === ActivityViewController.LookupItem {
|
||||
var view = outlineView.makeViewWithIdentifier("LookupCell", owner: nil) as? AdvancedTableCellView
|
||||
if view == nil {
|
||||
view = AdvancedTableCellView()
|
||||
view?.style = .Subtitle
|
||||
let progressIndicator = NSProgressIndicator()
|
||||
progressIndicator.controlSize = .RegularControlSize
|
||||
view?.leftAccessoryView = progressIndicator
|
||||
}
|
||||
// TODO: Localize
|
||||
view?.textField?.stringValue = "Looking up n tracks"
|
||||
view?.secondaryTextField?.stringValue = "m tracks in queue"
|
||||
return view
|
||||
}
|
||||
if let saveOperation = item as? SaveOperation {
|
||||
var view = outlineView.makeViewWithIdentifier("SaveCell", owner: nil) as? SaveTableCellView
|
||||
if view == nil {
|
||||
view = SaveTableCellView()
|
||||
}
|
||||
view?.progress = saveOperation.progress
|
||||
view?.leftAccessoryView = nil
|
||||
return view
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
||||
77
TagTunes/AlbumItem.swift
Executable file
@@ -0,0 +1,77 @@
|
||||
// AlbumItem.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 29.05.15.
|
||||
// Copyright (c) 2015 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import SearchAPI
|
||||
|
||||
/// Represents an `Album` from the Search API.
|
||||
public class AlbumItem: TagTunesGroupItem {
|
||||
|
||||
/// Returns the `entity` as an `Album`.
|
||||
public var album: Album {
|
||||
return entity as! Album
|
||||
}
|
||||
|
||||
public override var children: [TagTunesEntityItem] {
|
||||
didSet {
|
||||
super.children.sortInPlace { (item1: TagTunesEntityItem, item2: TagTunesEntityItem) in
|
||||
let song1 = (item1 as! SongItem).song
|
||||
let song2 = (item2 as! SongItem).song
|
||||
return song1.discNumber < song2.discNumber || (song1.discNumber == song2.discNumber && song1.trackNumber < song2.trackNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override func addAssociatedTracks<S : SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S) -> Set<TagTunesTrack> {
|
||||
var unmatchedTracks = Set<TagTunesTrack>()
|
||||
for track in tracks {
|
||||
var inserted = false
|
||||
for child in children where child is SongItem {
|
||||
let songItem = child as! SongItem
|
||||
if (track.discNumber == songItem.song.discNumber || track.discNumber == 0) && track.trackNumber == songItem.song.trackNumber {
|
||||
songItem.addAssociatedTracks([track])
|
||||
inserted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inserted {
|
||||
unmatchedTracks.insert(track)
|
||||
}
|
||||
}
|
||||
return unmatchedTracks
|
||||
}
|
||||
|
||||
public override func addAssociatedTrack(track: TagTunesTrack, forChildEntity childEntity: SearchAPIEntity) {
|
||||
for child in children where child.entity.id == childEntity.id {
|
||||
child.addAssociatedTracks([track])
|
||||
return
|
||||
}
|
||||
let child = SongItem(entity: childEntity, parentItem: self)
|
||||
child.addAssociatedTracks([track])
|
||||
children.append(child)
|
||||
}
|
||||
|
||||
public override var hasCommonArtist: Bool {
|
||||
for child in children where child is SongItem {
|
||||
if album.artist != (child as! SongItem).song.artist {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override class var childEntityType: SearchAPIRequest.EntityType {
|
||||
return .Song
|
||||
}
|
||||
|
||||
override func processLookupResult(result: SearchAPIResult) {
|
||||
for song in result.contentsOfCollectionWithID(album.id) where !children.contains({ $0.entity == song }) {
|
||||
let item = SongItem(entity: song, parentItem: self)
|
||||
children.append(item)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
76
TagTunes/AlbumTableCellView.swift
Normal file → Executable file
@@ -8,9 +8,9 @@
|
||||
|
||||
import Cocoa
|
||||
import AppKitPlus
|
||||
import SearchAPI
|
||||
|
||||
/// A table cell view to represent an `Album`. This view can be initialized using
|
||||
/// `initWithFrame`.
|
||||
/// A table cell view to represent an `Album`.
|
||||
public class AlbumTableCellView: AdvancedTableCellView {
|
||||
|
||||
// MARK: Types
|
||||
@@ -55,19 +55,6 @@ public class AlbumTableCellView: AdvancedTableCellView {
|
||||
return loadingIndicator
|
||||
}()
|
||||
|
||||
/// Intended to be used as accessory view.
|
||||
///
|
||||
/// Displayed for search results.
|
||||
@IBOutlet public lazy var button: NSButton! = {
|
||||
let button = NSButton()
|
||||
button.setButtonType(NSButtonType.MomentaryPushInButton)
|
||||
button.bezelStyle = NSBezelStyle.RoundedBezelStyle
|
||||
button.controlSize = .SmallControlSize
|
||||
button.setContentHuggingPriority(NSLayoutPriorityDefaultHigh, forOrientation: .Horizontal)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
return button
|
||||
}()
|
||||
|
||||
@IBOutlet public lazy var errorButton: NSButton! = {
|
||||
let errorButton = NSButton()
|
||||
errorButton.setButtonType(NSButtonType.MomentaryChangeButton)
|
||||
@@ -114,22 +101,23 @@ public class AlbumTableCellView: AdvancedTableCellView {
|
||||
/// Configures the receiver to display the specified `album`.
|
||||
///
|
||||
/// - parameters:
|
||||
/// - album: The album to be displayed.
|
||||
/// - loading: `true` if a loading indicator should be displayed at the
|
||||
/// album view.
|
||||
public func setupForAlbum(album: Album, loading: Bool, error: NSError?) {
|
||||
textField?.stringValue = Preferences.sharedPreferences.useCensoredNames ? album.censoredName : album.name
|
||||
secondaryTextField?.stringValue = album.artistName
|
||||
asyncImageView.downloadImageFromURL(album.artwork.displayImageURL)
|
||||
if loading {
|
||||
/// - albumItem: The album to be displayed.
|
||||
/// - height: The height the cell view is going to have. This is used to
|
||||
/// apropriately scale the album's artwork.
|
||||
public func setupForAlbumItem(albumItem: AlbumItem, height: CGFloat) {
|
||||
textField?.stringValue = Preferences.sharedPreferences.useCensoredNames ? albumItem.album.censoredName : albumItem.album.name
|
||||
secondaryTextField?.stringValue = albumItem.album.artist.name
|
||||
asyncImageView.downloadImageFromURL(albumItem.album.artwork.optimalArtworkURLForImageSize(height))
|
||||
switch albumItem.loadingState {
|
||||
case .Loading:
|
||||
textField?.textColor = NSColor.disabledControlTextColor()
|
||||
rightAccessoryView = loadingIndicator
|
||||
} else if error != nil {
|
||||
case .Error:
|
||||
textField?.textColor = NSColor.redColor()
|
||||
rightAccessoryView = errorButton
|
||||
} else {
|
||||
case .Normal:
|
||||
textField?.textColor = NSColor.controlTextColor()
|
||||
if album.saved {
|
||||
if albumItem.saved {
|
||||
let aspectRatioConstraint = NSLayoutConstraint(
|
||||
item: secondaryImageView,
|
||||
attribute: .Width,
|
||||
@@ -145,33 +133,31 @@ public class AlbumTableCellView: AdvancedTableCellView {
|
||||
toItem: nil,
|
||||
attribute: .Width,
|
||||
multiplier: 1,
|
||||
constant: 17)
|
||||
constant: Constants.secondaryImageViewWidth)
|
||||
setRightAccessoryView(secondaryImageView, withConstraints: [aspectRatioConstraint, widthConstraint])
|
||||
} else {
|
||||
rightAccessoryView = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures the receiver to display the specified `searchResult`.
|
||||
|
||||
/// Configures the cell to display the specified `album`. This method should
|
||||
/// be used to display search results.
|
||||
///
|
||||
/// - parameters:
|
||||
/// - searchResult: The search result to be displayed.
|
||||
/// - selectable: `true` if the search result can be selected, `false`
|
||||
/// otherwise.
|
||||
public func setupForSearchResult(searchResult: SearchResult, selectable: Bool) {
|
||||
textField?.stringValue = Preferences.sharedPreferences.useCensoredNames ? searchResult.censoredName : searchResult.name
|
||||
/// - album: The `Album` to be displayed.
|
||||
/// - enabled: Wether the cell should appear *enabled*. If not it draws its
|
||||
/// text in a gray color.
|
||||
/// - height: The height the cell view is going to have. This is used to
|
||||
/// appropriately scale the album's artwork.
|
||||
public func setupForAlbum(album: Album, enabled: Bool, height: CGFloat) {
|
||||
textField?.stringValue = Preferences.sharedPreferences.useCensoredNames ? album.censoredName : album.name
|
||||
textField?.textColor = NSColor.controlTextColor()
|
||||
secondaryTextField?.stringValue = searchResult.artistName
|
||||
asyncImageView.downloadImageFromURL(searchResult.artwork.displayImageURL)
|
||||
if selectable {
|
||||
button.title = NSLocalizedString("Select", comment: "Button title for 'selecting a search result'")
|
||||
button.enabled = true
|
||||
} else {
|
||||
button.title = NSLocalizedString("Added", comment: "Button title for a search result that is already present")
|
||||
button.enabled = false
|
||||
}
|
||||
rightAccessoryView = button
|
||||
secondaryTextField?.stringValue = album.artist.name
|
||||
textField?.textColor = enabled ? NSColor.textColor() : NSColor.disabledControlTextColor()
|
||||
secondaryTextField?.textColor = enabled ? NSColor.secondaryLabelColor() : NSColor.disabledControlTextColor()
|
||||
asyncImageView.downloadImageFromURL(album.artwork.optimalArtworkURLForImageSize(height))
|
||||
asyncImageView.enabled = enabled
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
30
TagTunes/AppDelegate.swift
Normal file → Executable file
@@ -11,13 +11,43 @@ import Cocoa
|
||||
@NSApplicationMain
|
||||
internal class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
/// The app's unsorted tracks window. This should be initalized when the app
|
||||
/// launches.
|
||||
var unsortedTracksWindowController: NSWindowController!
|
||||
|
||||
/// The app's unsorted tracks controller.
|
||||
weak var unsortedTracksController: UnsortedTracksController!
|
||||
|
||||
internal func applicationDidFinishLaunching(aNotification: NSNotification) {
|
||||
Preferences.sharedPreferences.initializeDefaultValues()
|
||||
let storyboard = NSStoryboard(name: "Main", bundle: nil)
|
||||
unsortedTracksWindowController = storyboard.instantiateControllerWithIdentifier("unsortedTracksWindowController") as? NSWindowController
|
||||
unsortedTracksController.visible = true
|
||||
}
|
||||
|
||||
internal func applicationShouldTerminateAfterLastWindowClosed(sender: NSApplication) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
@IBAction func toggleUnsortedTracks(sender: AnyObject?) {
|
||||
unsortedTracksController.visible = !unsortedTracksController.visible
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension NSApplication {
|
||||
|
||||
/// The app`s unsorted tracks controller. When a `UnsortedTracksController`
|
||||
/// is initialized it should this property to itself.
|
||||
///
|
||||
/// The value of this property is not retained.
|
||||
var unsortedTracksController: UnsortedTracksController! {
|
||||
set {
|
||||
(delegate as? AppDelegate)?.unsortedTracksController = newValue
|
||||
}
|
||||
get {
|
||||
return (delegate as? AppDelegate)?.unsortedTracksController
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
//
|
||||
// Artwork.swift
|
||||
// Tag for iTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 30.05.15.
|
||||
// Copyright (c) 2015 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
/// Represents an Artwork from the
|
||||
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
|
||||
public class Artwork: iTunesType {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The URL for the artwork, sized to 60x60 pixels.
|
||||
public let url60: NSURL
|
||||
|
||||
/// The URL for the artwork, sized to 100x100 pixels.
|
||||
public let url100: NSURL
|
||||
|
||||
/// The URL for the artwork with full resolution.
|
||||
///
|
||||
/// - note: This URL is aquired by using an unofficial hack. If Apple changes
|
||||
/// the way the full resolution artworks are stored this URL may be
|
||||
/// `nil`.
|
||||
public let hiResURL: NSURL!
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
public required init(data: [iTunesAPI.Field: AnyObject]) {
|
||||
url60 = NSURL(string: data[.ArtworkUrl60] as! String)!
|
||||
url100 = NSURL(string: data[.ArtworkUrl100] as! String)!
|
||||
var hiResURLString = (data[.ArtworkUrl100] as! String)
|
||||
let hotSpotRange = hiResURLString.rangeOfString("100x100", options: .BackwardsSearch, range: nil, locale: nil)!
|
||||
hiResURLString.replaceRange(hotSpotRange, with: "1500x1500")
|
||||
hiResURL = NSURL(string: hiResURLString)
|
||||
}
|
||||
|
||||
public static var requiredFields: [iTunesAPI.Field] {
|
||||
return [.ArtworkUrl60, .ArtworkUrl100]
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Saves the high resolution artwork to the specified URL. If there is
|
||||
/// alread a file at the specified URL it will be overwritten.
|
||||
///
|
||||
/// - parameters:
|
||||
/// - url: The URL of the directory the artwork is going to be saved to.
|
||||
/// This must be a valid file URL.
|
||||
/// - filename: The filename to be used (without the file extension).
|
||||
public func saveToURL(url: NSURL, filename: String) throws {
|
||||
let directory = url.filePathURL!.path!
|
||||
let filePath = directory.stringByAppendingString("/\(filename).tiff")
|
||||
|
||||
if !Preferences.sharedPreferences.overwriteExistingFiles && NSFileManager.defaultManager().fileExistsAtPath(filePath) {
|
||||
return
|
||||
}
|
||||
|
||||
try NSFileManager.defaultManager().createDirectoryAtPath(directory, withIntermediateDirectories: true, attributes: nil)
|
||||
let _ = NSFileManager.defaultManager().createFileAtPath(filePath, contents: saveImage?.TIFFRepresentation, attributes: nil)
|
||||
}
|
||||
|
||||
// MARK: Calculated Properties
|
||||
|
||||
private var cachedImage60: NSImage?
|
||||
|
||||
/// Returns an `NSImage` instance of the image located at `url60`.
|
||||
///
|
||||
/// - attention: The first time this method is called the current thread will
|
||||
/// be blocked until the image is loaded.
|
||||
public var image60: NSImage? {
|
||||
if cachedImage60 == nil {
|
||||
cachedImage60 = NSImage(byReferencingURL: url60)
|
||||
}
|
||||
return cachedImage60
|
||||
}
|
||||
|
||||
private var cachedImage100: NSImage?
|
||||
|
||||
/// Returns an `NSImage` instance of the image located at `url100`.
|
||||
///
|
||||
/// - attention: The first time this method is called the current thread will
|
||||
/// be blocked until the image is loaded.
|
||||
public var image100: NSImage? {
|
||||
if cachedImage100 == nil {
|
||||
cachedImage100 = NSImage(byReferencingURL: url100)
|
||||
}
|
||||
return cachedImage100
|
||||
}
|
||||
|
||||
private var cachedHiResImage: NSImage?
|
||||
|
||||
/// Returns an `NSImage` instance of the image located at `hiResURL`.
|
||||
///
|
||||
/// - attention: The first time this method is called the current thread will
|
||||
/// be blocked until the image is loaded.
|
||||
public var hiResImage: NSImage? {
|
||||
if hiResURL == nil {
|
||||
return nil
|
||||
}
|
||||
if cachedHiResImage == nil {
|
||||
cachedHiResImage = NSImage(byReferencingURL: hiResURL)
|
||||
}
|
||||
return cachedHiResImage
|
||||
}
|
||||
|
||||
/// Returns the url of an image that should be used to display this artwork
|
||||
/// with respect to the user's preferences.
|
||||
public var displayImageURL: NSURL {
|
||||
if !Preferences.sharedPreferences.useLowResolutionArtwork && hiResURL != nil {
|
||||
return hiResURL
|
||||
}
|
||||
return url100
|
||||
}
|
||||
|
||||
/// Returns the image that should be used to save this artwork.
|
||||
public var saveImage: NSImage? {
|
||||
return hiResImage != nil ? hiResImage : image100
|
||||
}
|
||||
|
||||
}
|
||||
0
TagTunes/Assets.xcassets/AppIcon.appiconset/128x128.png
Normal file → Executable file
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
0
TagTunes/Assets.xcassets/AppIcon.appiconset/128x128@2x.png
Normal file → Executable file
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
0
TagTunes/Assets.xcassets/AppIcon.appiconset/16x16.png
Normal file → Executable file
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
0
TagTunes/Assets.xcassets/AppIcon.appiconset/16x16@2x.png
Normal file → Executable file
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
0
TagTunes/Assets.xcassets/AppIcon.appiconset/256x256.png
Normal file → Executable file
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
0
TagTunes/Assets.xcassets/AppIcon.appiconset/256x256@2x.png
Normal file → Executable file
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 206 KiB |
0
TagTunes/Assets.xcassets/AppIcon.appiconset/32x32.png
Normal file → Executable file
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
0
TagTunes/Assets.xcassets/AppIcon.appiconset/32x32@2x.png
Normal file → Executable file
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
0
TagTunes/Assets.xcassets/AppIcon.appiconset/512x512.png
Normal file → Executable file
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 206 KiB |
0
TagTunes/Assets.xcassets/AppIcon.appiconset/512x512@2x.png
Normal file → Executable file
|
Before Width: | Height: | Size: 694 KiB After Width: | Height: | Size: 694 KiB |
0
TagTunes/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file → Executable file
0
TagTunes/Assets.xcassets/Contents.json
Normal file → Executable file
0
TagTunes/Assets.xcassets/Cross.imageset/Contents.json
vendored
Normal file → Executable file
0
TagTunes/Assets.xcassets/Cross.imageset/Cross.pdf
vendored
Normal file → Executable file
0
TagTunes/Assets.xcassets/Note.imageset/Contents.json
vendored
Normal file → Executable file
0
TagTunes/Assets.xcassets/Note.imageset/Note.pdf
vendored
Normal file → Executable file
0
TagTunes/Assets.xcassets/PreferenceStore.imageset/Contents.json
vendored
Normal file → Executable file
BIN
TagTunes/Assets.xcassets/PreferenceStore.imageset/PreferenceStore.png
vendored
Executable file
|
After Width: | Height: | Size: 2.2 KiB |
0
TagTunes/Assets.xcassets/PreferenceStore.imageset/PreferenceStore@2x.png
vendored
Normal file → Executable file
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
0
TagTunes/Assets.xcassets/PreferenceTags.imageset/Contents.json
vendored
Normal file → Executable file
0
TagTunes/Assets.xcassets/PreferenceTags.imageset/PreferencesTags-1.png
vendored
Normal file → Executable file
|
Before Width: | Height: | Size: 558 KiB After Width: | Height: | Size: 558 KiB |
0
TagTunes/Assets.xcassets/PreferenceTags.imageset/PreferencesTags-2.png
vendored
Normal file → Executable file
|
Before Width: | Height: | Size: 558 KiB After Width: | Height: | Size: 558 KiB |
0
TagTunes/Assets.xcassets/PreferenceTags.imageset/PreferencesTags.png
vendored
Normal file → Executable file
|
Before Width: | Height: | Size: 558 KiB After Width: | Height: | Size: 558 KiB |
0
TagTunes/Assets.xcassets/Save.imageset/Contents.json
vendored
Normal file → Executable file
0
TagTunes/Assets.xcassets/Save.imageset/Save.pdf
vendored
Normal file → Executable file
0
TagTunes/Assets.xcassets/SaveArtwork.imageset/Contents.json
vendored
Normal file → Executable file
0
TagTunes/Assets.xcassets/SaveArtwork.imageset/SaveArtwork.pdf
vendored
Normal file → Executable file
0
TagTunes/Assets.xcassets/Tick.imageset/Contents.json
vendored
Normal file → Executable file
0
TagTunes/Assets.xcassets/Tick.imageset/Tick.pdf
vendored
Normal file → Executable file
BIN
TagTunes/Base.lproj/Localizable.strings
Normal file → Executable file
72
TagTunes/Base.lproj/Localizable.stringsdict
Normal file → Executable file
@@ -1,22 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>%d artworks could not be saved.</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@artworks@ could not be saved</string>
|
||||
<key>artworks</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>One artwork</string>
|
||||
<key>other</key>
|
||||
<string>%d artworks</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>Preparing Lookup for %d Tracks…</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>Preparing Lookup for %#@tracks@…</string>
|
||||
<key>tracks</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>One Track</string>
|
||||
<key>other</key>
|
||||
<string>%d Tracks</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>Looking up %d tracks…</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>Looking up %#@tracks@…</string>
|
||||
<key>tracks</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>one track</string>
|
||||
<key>other</key>
|
||||
<string>%d tracks</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>%d Tracks Pending</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@tracks@ Pending</string>
|
||||
<key>tracks</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>zero</key>
|
||||
<string>No Tracks</string>
|
||||
<key>one</key>
|
||||
<string>One Track</string>
|
||||
<key>other</key>
|
||||
<string>%d Tracks</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
728
TagTunes/Base.lproj/Main.storyboard
Normal file → Executable file
124
TagTunes/ContentViewController.swift
Executable file
@@ -0,0 +1,124 @@
|
||||
//
|
||||
// ContentViewController.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 21.01.16.
|
||||
// Copyright © 2016 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import SearchAPI
|
||||
|
||||
/// A `ContentViewController` displays a set of `TagTunesItem`s. It is
|
||||
/// responsible for allowing the user to associate `TagTunesTrack`s with
|
||||
/// `TagTunesEntityItem`s.
|
||||
///
|
||||
/// The expected structure of the content view controller is a tree structure in
|
||||
/// the following form:
|
||||
///
|
||||
/// 1. At the root level there are `TagTunesGroupItem`s (groups) and
|
||||
/// `TagTunesEntityItem`s (entities).
|
||||
/// 2. Groups can contain other entities (children).
|
||||
/// 3. Entities can have tracks associated with them. A track should not be
|
||||
/// associated with multiple entities.
|
||||
public class ContentViewController: NSViewController {
|
||||
|
||||
override public func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
NSNotificationCenter.defaultCenter().addObserver(self, selector: #selector(ContentViewController.itemDidChangeState(_:)), name: TagTunesGroupItem.StateChangedNotificationName, object: nil)
|
||||
}
|
||||
|
||||
/// Called when the `TagTunesGroupItem.StateChangedNotification` is posted.
|
||||
private dynamic func itemDidChangeState(notification: NSNotification) {
|
||||
if let item = notification.object as? TagTunesItem {
|
||||
updateItem(item)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
NSNotificationCenter.defaultCenter().removeObserver(self)
|
||||
}
|
||||
|
||||
/// Returns all selected items. This property should remove duplicate items
|
||||
/// from the selection following these three rules:
|
||||
///
|
||||
/// 1. If an entity is selected (and thus contained in the returned array)
|
||||
/// none of its `associatedTracks` should be contained in the returned
|
||||
/// array.
|
||||
/// 2. If a group is selected none of its children should be contained in the
|
||||
/// returned array.
|
||||
/// 3. If a track is selected and (1) and (2) do not appy it should be
|
||||
/// contained in the returned array.
|
||||
///
|
||||
/// This value is not cached. If you need to access this multiple times in a
|
||||
/// row, you should consider caching it yourself in a local variable. The
|
||||
/// order in which the selected objects occur in the returned array may be
|
||||
/// different from the order in which they are displayed.
|
||||
///
|
||||
/// This method must be implemented by subclasses.
|
||||
public var selectedItems: [AnyObject] {
|
||||
fatalError("Must override property selectedItems")
|
||||
}
|
||||
|
||||
/// Returns the item the user clicked on. This property should be used
|
||||
/// instead of the `selectedItems` for any context menu related actions. This
|
||||
/// property should behave the same way that `selectedItems` does.
|
||||
///
|
||||
/// This method must be implemented by subclasses.
|
||||
public var clickedItems: [AnyObject] {
|
||||
fatalError("Must override property clickedItem")
|
||||
}
|
||||
|
||||
/// Returns the item that represents the specified `entity`. If no such item
|
||||
/// exists in the content view controller `nil` is returned.
|
||||
///
|
||||
/// This method must be implemented by subclasses.
|
||||
public func itemForEntity(entity: SearchAPIEntity) -> TagTunesItem? {
|
||||
fatalError("Must override itemForEntity(_:)")
|
||||
}
|
||||
|
||||
/// Adds the specified `item` to the view controller. This should insert the
|
||||
/// `item` at the root level of the controller. If the controller already
|
||||
/// contains an item that represents the same entity as the specified `item`
|
||||
/// this method should do nothing.
|
||||
///
|
||||
/// This method must be implemented by subclasses.
|
||||
public func addItem(item: TagTunesItem) {
|
||||
fatalError("Must override addItem(_:)")
|
||||
}
|
||||
|
||||
/// Updates the specified `item`. This method should be called if the
|
||||
/// properties of the `item` changed in any way and the view should be
|
||||
/// notified about it. If the `item` has not previously been added to the
|
||||
/// view controller this method should do nothing.
|
||||
///
|
||||
/// This method must be implemented by subclasses.
|
||||
public func updateItem(item: TagTunesItem) {
|
||||
fatalError("Must override updateItem(_:)")
|
||||
}
|
||||
|
||||
/// Updates all items. If many items in the view controller changed it may be
|
||||
/// more efficient to use this method instead of `updateItem(_:)`.
|
||||
///
|
||||
/// This method must be implemented by subclasses.
|
||||
public func updateAllItems() {
|
||||
fatalError("Must override updateAllItems()")
|
||||
}
|
||||
|
||||
/// Removes the selected items from the view controller. By default the
|
||||
/// implementation just calls `removeItems(selectedItems` but subclasses may
|
||||
/// override this method to optimize the behavior.
|
||||
public func removeSelectedItems() {
|
||||
removeItems(selectedItems)
|
||||
}
|
||||
|
||||
/// Removes the specified items from the view controller and updates the view
|
||||
/// accordingly.
|
||||
///
|
||||
/// This method must be implemented by subclasses.
|
||||
public func removeItems(items: [AnyObject]) {
|
||||
fatalError("Must override removeItems(_:)")
|
||||
}
|
||||
|
||||
}
|
||||
6
TagTunes/DescriptiveError.swift
Normal file → Executable file
@@ -9,7 +9,7 @@
|
||||
import Foundation
|
||||
|
||||
/// A custom error class that wraps another error to display a more descriptive
|
||||
/// error description than for example "The operation couldn't be completed."
|
||||
/// error description than for example "The operation couldn't be completed".
|
||||
public class DescriptiveError: NSError {
|
||||
|
||||
/// Initializes the receiver with the specified `underlyingError`.
|
||||
@@ -34,6 +34,8 @@ public class DescriptiveError: NSError {
|
||||
override public var localizedDescription: String {
|
||||
if domain == NSURLErrorDomain {
|
||||
switch code {
|
||||
case NSURLErrorCannotConnectToHost:
|
||||
fallthrough
|
||||
case NSURLErrorNotConnectedToInternet:
|
||||
return NSLocalizedString("You are not connected to the internet.", comment: "Error message informing the user that he is not connected to the internet.")
|
||||
case NSURLErrorTimedOut:
|
||||
@@ -47,6 +49,8 @@ public class DescriptiveError: NSError {
|
||||
override public var localizedRecoverySuggestion: String? {
|
||||
if domain == NSURLErrorDomain {
|
||||
switch code {
|
||||
case NSURLErrorCannotConnectToHost:
|
||||
fallthrough
|
||||
case NSURLErrorNotConnectedToInternet:
|
||||
return NSLocalizedString("Please check your network connection and try again.", comment: "Error recovery suggestion for 'not connected to the internet' error.")
|
||||
case NSURLErrorTimedOut:
|
||||
|
||||
91
TagTunes/ImportedTrack.swift
Executable file
@@ -0,0 +1,91 @@
|
||||
//
|
||||
// ImportedTrack.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 17.03.16.
|
||||
// Copyright © 2016 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import SearchAPI
|
||||
|
||||
public class ImportedTrack: TagTunesTrack {
|
||||
|
||||
/// The `iTunesTrack` represented by this instance.
|
||||
private var track: iTunesTrack
|
||||
|
||||
public override func readTrackID() throws -> SearchAPIID {
|
||||
guard let fileTrack = self.track as? iTunesFileTrack, fileURL = fileTrack.location where fileURL.checkResourceIsReachableAndReturnError(nil) else {
|
||||
throw TagTunesTrackErrors.FileNotFound
|
||||
}
|
||||
let fileHandle = try? NSFileHandle(forReadingFromURL: fileTrack.location)
|
||||
// Read the first 1024 bytes from the file. The file's ID seems to always
|
||||
// be at around 600-700 bytes so we don't need to read more than that.
|
||||
guard let data = fileHandle?.readDataOfLength(1024) else {
|
||||
throw TagTunesTrackErrors.FileNotReadable
|
||||
}
|
||||
let dataToFind = "song".dataUsingEncoding(NSASCIIStringEncoding)!
|
||||
let dataRange = data.rangeOfData(dataToFind, options: [], range: NSRange(location: 0, length: data.length))
|
||||
guard dataRange.location != NSNotFound else {
|
||||
throw TagTunesTrackErrors.NoIDFound
|
||||
}
|
||||
var rawID: UInt32 = 0
|
||||
data.getBytes(&rawID, range: NSRange(location: NSMaxRange(dataRange), length: 4))
|
||||
return SearchAPIID(UInt32(bigEndian: rawID))
|
||||
}
|
||||
|
||||
/// Initializes the track with the specified `iTunesTrack`.
|
||||
public init(track: iTunesTrack) {
|
||||
self.track = track
|
||||
super.init()
|
||||
}
|
||||
|
||||
public required init?(coder aDecoder: NSCoder) {
|
||||
if let track = aDecoder.decodeObjectOfClass(iTunesTrack.self, forKey: "track") {
|
||||
self.track = track
|
||||
super.init()
|
||||
} else {
|
||||
track = iTunesTrack()
|
||||
super.init()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public override func encodeWithCoder(aCoder: NSCoder) {
|
||||
aCoder.encodeObject(track, forKey: "track")
|
||||
}
|
||||
|
||||
public override func valueForTag(tag: Tag) -> AnyObject! {
|
||||
return track.valueForKey(tag.rawValue)
|
||||
}
|
||||
|
||||
public override func reveal() {
|
||||
track.reveal()
|
||||
iTunes.activate()
|
||||
}
|
||||
|
||||
public override var supportsBatchSaving: Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
public override func saveValue(value: AnyObject?, forTag tag: Tag) throws {
|
||||
if tag == .Artwork {
|
||||
track.artworks().removeAllObjects()
|
||||
if let artwork = value as? NSImage {
|
||||
track.artworks().objectAtIndex(0).propertyWithCode(AEKeyword("pPCT")).setTo(artwork)
|
||||
}
|
||||
} else if let theValue = value {
|
||||
track.propertyWithCode(tag.code).setTo(theValue)
|
||||
} else {
|
||||
track.propertyWithCode(tag.code).setTo("")
|
||||
}
|
||||
}
|
||||
|
||||
public override var hashValue: Int {
|
||||
return track.id()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public func ==(lhs: ImportedTrack, rhs: ImportedTrack) -> Bool {
|
||||
return lhs.track.id() == rhs.track.id()
|
||||
}
|
||||
4
TagTunes/Info.plist
Normal file → Executable file
@@ -17,11 +17,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.1</string>
|
||||
<string>2.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>37</string>
|
||||
<string>2674</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.music</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
|
||||
73
TagTunes/LookupOperation.swift
Executable file
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// LookupOperation.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 08.04.16.
|
||||
// Copyright © 2016 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import SearchAPI
|
||||
import AppKitPlus
|
||||
|
||||
|
||||
class LookupOperation: Operation {
|
||||
|
||||
/// The `NSURLSession` used to perform lookup tasks.
|
||||
private let urlSession = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration(), delegate: nil, delegateQueue: NSOperationQueue.mainQueue())
|
||||
|
||||
let tracks: [TagTunesTrack]
|
||||
|
||||
private var lookupTask: NSURLSessionTask!
|
||||
private var lookupData: NSData?
|
||||
private var lookupError: ErrorType?
|
||||
|
||||
init<S: SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S) {
|
||||
self.tracks = Array(tracks)
|
||||
var request = SearchAPIRequest(lookupRequestWithIDs: tracks.flatMap { $0.id })
|
||||
request.entity = .Album
|
||||
request.country = Preferences.sharedPreferences.iTunesStore
|
||||
if Preferences.sharedPreferences.useEnglishTags {
|
||||
request.language = .English
|
||||
}
|
||||
super.init()
|
||||
self.lookupTask = urlSession.dataTaskWithURL(request.URL, completionHandler: downloadFinishedWithData)
|
||||
addCondition(MutuallyExclusive<LookupOperation>())
|
||||
}
|
||||
|
||||
private func downloadFinishedWithData(data: NSData?, response: NSURLResponse?, error: NSError?) {
|
||||
lookupData = data
|
||||
lookupError = error
|
||||
}
|
||||
|
||||
override func willEnqueue() {
|
||||
let operation = URLSessionTaskOperation(task: lookupTask)
|
||||
addDependency(operation)
|
||||
produceOperation(operation)
|
||||
super.willEnqueue()
|
||||
}
|
||||
|
||||
override func execute() {
|
||||
// TODO: This must be executed after the url data task finished. Is that the case?
|
||||
do {
|
||||
if let data = lookupData {
|
||||
let result = try SearchAPIResult(data: data)
|
||||
for track in tracks where track.id != nil {
|
||||
if let resultTrack = result.entityForID(track.id!) as? Track {
|
||||
track.lookupState = .Found(resultTrack)
|
||||
} else {
|
||||
track.lookupState = .NotFound
|
||||
}
|
||||
}
|
||||
finish()
|
||||
} else {
|
||||
throw lookupError!
|
||||
}
|
||||
} catch {
|
||||
for track in tracks {
|
||||
track.lookupState = .Error(error)
|
||||
}
|
||||
finishWithError(error as NSError?)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
141
TagTunes/LookupOperationTmp.swift
Executable file
@@ -0,0 +1,141 @@
|
||||
////
|
||||
//// LookupOperation.swift
|
||||
//// TagTunes
|
||||
////
|
||||
//// Created by Kim Wittenburg on 08.04.16.
|
||||
//// Copyright © 2016 Kim Wittenburg. All rights reserved.
|
||||
////
|
||||
//
|
||||
//import AppKitPlus
|
||||
//
|
||||
//class LookupOperation: Operation {
|
||||
//
|
||||
// /// The `NSURLSession` used to perform lookup tasks.
|
||||
// private let urlSession = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration(), delegate: nil, delegateQueue: NSOperationQueue.mainQueue())
|
||||
//
|
||||
// /// The task of the current lookup. If there is currently no lookup being
|
||||
// /// processed this is `nil`.
|
||||
// ///
|
||||
// /// If you need to know whether there is currently a active lookup you should
|
||||
// /// use the `lookupActive` property instead.
|
||||
// private var lookupTask: NSURLSessionTask?
|
||||
//
|
||||
// var lookupActive = false
|
||||
//
|
||||
// /// The tracks that are currently being looked up. These tracks do not
|
||||
// /// contain the tracks enqueued for lookup.
|
||||
// private var lookupTracks = [SearchAPIID: TagTunesTrack]()
|
||||
//
|
||||
// private var queue = [TagTunesTrack]() {
|
||||
// didSet {
|
||||
// lookupActivity.progress.localizedAdditionalDescription = String(format: NSLocalizedString("%d Tracks Pending", comment: "Additional description format fpr the iTunes Match lookup."), queue.count)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func enqueueTracksForLookup<S : SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S) {
|
||||
// queue.appendContentsOf(tracks)
|
||||
// if !lookupActive {
|
||||
// beginLookup()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// @IBAction func cancelLookup(sender: AnyObject) {
|
||||
// cancelLookup()
|
||||
// }
|
||||
//
|
||||
// func cancelLookup() {
|
||||
// lookupTask?.cancel()
|
||||
// lookupTask = nil
|
||||
// var tracks = queue
|
||||
// tracks.appendContentsOf(lookupTracks.values)
|
||||
// for track in tracks {
|
||||
// track.lookupState = .Error(NSCocoaError.UserCancelledError)
|
||||
// }
|
||||
// lookupDelegate?.lookupController(self, completedLookupForTracks: tracks)
|
||||
// queue = []
|
||||
// lookupTracks = [:]
|
||||
// lookupDelegate?.lookupControllerDidFinishLookup(self)
|
||||
//
|
||||
// lookupActive = false
|
||||
// }
|
||||
//
|
||||
// private func beginLookup() {
|
||||
// lookupActive = true
|
||||
// lookupDelegate?.lookupController(self, willBeginLookupForTracks: queue)
|
||||
//
|
||||
// let tracks = queue
|
||||
// dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) { self.performLookupForTracks(tracks) }
|
||||
// queue = []
|
||||
// }
|
||||
//
|
||||
// /// Actually starts the lookup for the specified tracks.
|
||||
// ///
|
||||
// /// This method does not execute the network request (but it starts it).
|
||||
// /// Still it is recommended not to invoke this method on the main thread
|
||||
// /// since it can block the current thread for quite a while (depending on the
|
||||
// /// location of the tracks).
|
||||
// private func performLookupForTracks(tracks: [TagTunesTrack]) {
|
||||
// dispatch_sync(dispatch_get_main_queue()) {
|
||||
// self.lookupActivity.progress.totalUnitCount = -1
|
||||
// self.lookupActivity.progress.localizedDescription = String(format: NSLocalizedString("Preparing Lookup for %d Tracks…", comment: "Description format for the iTunes Match lookup preparation."), tracks.count)
|
||||
// }
|
||||
// var invalidTracks = [TagTunesTrack]()
|
||||
// lookupTracks = [:]
|
||||
// for track in tracks {
|
||||
// track.lookupState = .Preparing
|
||||
// if let id = track.id {
|
||||
// lookupTracks[id] = track
|
||||
// track.lookupState = .Searching
|
||||
// } else {
|
||||
// invalidTracks.append(track)
|
||||
// track.lookupState = .Unqualified
|
||||
// }
|
||||
// }
|
||||
// dispatch_sync(dispatch_get_main_queue()) {
|
||||
// if !invalidTracks.isEmpty {
|
||||
// self.lookupDelegate?.lookupController(self, completedLookupForTracks: invalidTracks)
|
||||
// }
|
||||
// self.lookupActivity.progress.localizedDescription = String(format: NSLocalizedString("Looking up %d tracks…", comment: "Description format for the iTunes Match lookup."), self.lookupTracks.count)
|
||||
// }
|
||||
// var request = SearchAPIRequest(lookupRequestWithIDs: lookupTracks.keys)
|
||||
// request.entity = .Album
|
||||
// request.country = Preferences.sharedPreferences.iTunesStore
|
||||
// if Preferences.sharedPreferences.useEnglishTags {
|
||||
// request.language = .English
|
||||
// }
|
||||
// NSThread.sleepForTimeInterval(2)
|
||||
// lookupTask = urlSession.dataTaskWithURL(request.URL, completionHandler: finishedTrackLookupWithData)
|
||||
// lookupTask?.resume()
|
||||
// }
|
||||
//
|
||||
// /// Invoked after the lookup network request has completed. This method
|
||||
// /// processes the raw lookup data and send delegate messages accordingly.
|
||||
// /// This method may send multiple delegate messages depending on the
|
||||
// /// characteristics of the `data`.
|
||||
// ///
|
||||
// /// This method must be called on the main thread.
|
||||
// private func finishedTrackLookupWithData(data: NSData?, response: NSURLResponse?, error: NSError?) {
|
||||
// assert(NSThread.isMainThread())
|
||||
// defer {
|
||||
// lookupTask = nil
|
||||
// }
|
||||
// let result = (try? data.map(SearchAPIResult.init)) ?? nil
|
||||
// for (id, track) in lookupTracks {
|
||||
// if let error = error {
|
||||
// track.lookupState = .Error(error)
|
||||
// } else if let resultTrack = result?.entityForID(id) as? Track {
|
||||
// track.lookupState = .Found(resultTrack)
|
||||
// } else {
|
||||
// track.lookupState = .NotFound
|
||||
// }
|
||||
// }
|
||||
// lookupDelegate?.lookupController(self, completedLookupForTracks: Array(lookupTracks.values))
|
||||
// if queue.isEmpty {
|
||||
// lookupDelegate?.lookupControllerDidFinishLookup(self)
|
||||
// lookupActive = false
|
||||
// } else {
|
||||
// beginLookup()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
//}
|
||||
27
TagTunes/LookupPreparationOperation.swift
Executable file
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// LookupPreparationOperation.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 12.04.16.
|
||||
// Copyright © 2016 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import AppKitPlus
|
||||
|
||||
// TODO: Documentation
|
||||
|
||||
class LookupPreparationOperation: Operation {
|
||||
|
||||
let track: TagTunesTrack
|
||||
|
||||
init(track: TagTunesTrack) {
|
||||
self.track = track
|
||||
}
|
||||
|
||||
override func execute() {
|
||||
// TODO: Is it ok to block the thread?
|
||||
track.updateTrackID()
|
||||
finish()
|
||||
}
|
||||
|
||||
}
|
||||
181
TagTunes/LookupQueue.swift
Executable file
@@ -0,0 +1,181 @@
|
||||
//
|
||||
// LookupController.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 07.03.16.
|
||||
// Copyright © 2016 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import SearchAPI
|
||||
import AppKitPlus
|
||||
|
||||
/// The state of a lookup process.
|
||||
public enum LookupState {
|
||||
|
||||
/// The track has not been processed for lookup. This is the initial state.
|
||||
case Unprocessed
|
||||
|
||||
/// The track is being prepared for lookup. During preparation the track's id
|
||||
/// is read.
|
||||
case Preparing
|
||||
|
||||
/// The lookup is currently searching for the track's metadata.
|
||||
case Searching
|
||||
|
||||
/// The track was found. The associated `Track` represents the found metadata
|
||||
/// of the track.
|
||||
case Found(Track)
|
||||
|
||||
/// The track has been looked up but was not found.
|
||||
case NotFound
|
||||
|
||||
/// The track is not qualified for lookup because its id could not be read. The
|
||||
/// associated value contains an error description identifying the reason why
|
||||
/// the track's id could not be read.
|
||||
case Unqualified(ErrorType)
|
||||
|
||||
/// During the lookup an error occured. This state is also used if the lookup
|
||||
/// was cancelled by the user.
|
||||
case Error(ErrorType?)
|
||||
|
||||
}
|
||||
|
||||
// TODO: Documentation
|
||||
|
||||
|
||||
// DOCUMENTATION: All delegate methods are executed on main thread.
|
||||
protocol LookupQueueDelegate: class {
|
||||
|
||||
/// Invoked before the `lookupController` will process the specified
|
||||
/// `tracks`. This method may be invoked multiple times before
|
||||
/// `lookupControllerDiDFinishLookup(_:)` is invoked, but it is guaranteed
|
||||
/// that it will be invoked at least once. For more information see the
|
||||
/// documentation for `LookupController`.
|
||||
func lookupQueue(lookupQueue: LookupQueue, willBeginLookupForTracks tracks: [TagTunesTrack])
|
||||
|
||||
/// Invoked when the `lookupController` completes the lookup process for the
|
||||
/// specified `tracks`. This method is called for tracks that could be
|
||||
/// successfully looked up or that could not be looked up. There can also be
|
||||
/// both in one invocation of this method. Use the `lookupState` of the
|
||||
/// `TagTunesTrack` instances to get information about the lookup result for
|
||||
/// individual tracks.
|
||||
///
|
||||
/// This method may be called multiple times in a row. Every track that has
|
||||
/// been part of the `tracks` array of a
|
||||
/// `lookupController(_:willBeginLookupForTracks:)` message will be part of
|
||||
/// a invocation of this method exactly once.
|
||||
///
|
||||
/// - parameters:
|
||||
/// - lookupController: The controller that did the lookup.
|
||||
/// - tracks: The tracks that have been processed by the
|
||||
/// `lookupController`. The order of the tracks has no meaning.
|
||||
func lookupQueue(lookupQueue: LookupQueue, completedLookupForTracks tracks: [TagTunesTrack])
|
||||
|
||||
/// Invoked after the `lookupController` finished looking up all enqueued
|
||||
/// tracks.
|
||||
func lookupQueueDidFinishLookup(lookupQueue: LookupQueue)
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// The lookup controller looks up tracks' metadata online based on the iTunes
|
||||
/// store ID embedded into the a file. To be notified about the lookup progress
|
||||
/// set the controller's `delegate` property. There are some valid assumptions
|
||||
/// you can make about the order in which the delgate methods are invoked:
|
||||
///
|
||||
/// 1. `lookupController(_:willBeginLookupForTracks:)` is the first of the
|
||||
/// methods to be invoked.
|
||||
/// 2. Every `TagTunesTrack` that is part of the `tracks` in
|
||||
/// `lookupController(_:willBeginLookupForTracks:)` will be part of the
|
||||
/// `tracks` of `lookupController(_:completedLookupForTracks:)` or.
|
||||
/// 4. `lookupControllerDidFinishLookup(_:)` may only be called after (1) and (2)
|
||||
/// are satisfied.
|
||||
/// 5. After `lookupControllerDidFinishLookup(_:)` the next delegate message will
|
||||
/// be `lookupController(_:willBeginLookupForTracks:)`.
|
||||
///
|
||||
/// The calls to `lookupController(_:willBeginLookupForTracks:)` and
|
||||
/// `lookupControllerDidFinishLookup(_:)` are **not** balanced. There may be
|
||||
/// multiple invokations of the former with only a single invokation of the
|
||||
/// latter.
|
||||
class LookupQueue: OperationQueue {
|
||||
|
||||
static let globalQueue = LookupQueue()
|
||||
|
||||
// TODO: Is there an alternative to two delegates
|
||||
/// The lookup controller's delegate.
|
||||
weak var lookupDelegate: LookupQueueDelegate?
|
||||
|
||||
var lookupOperation: LookupOperation?
|
||||
|
||||
private var aggregatedTracks = [TagTunesTrack]()
|
||||
|
||||
/// Enqueues the specified `tracks` for lookup. The point in time where the
|
||||
/// `tracks` will actually be looked up is not defined. It may be when this
|
||||
/// method returns or at some later point. To get notified when the lookup
|
||||
/// actually starts implement the delgate method
|
||||
/// `lookupController(_:willBeginLookupForTracks:)`.
|
||||
///
|
||||
/// - note: There is no correspondance between the specified `tracks` an the
|
||||
/// tracks passed to the delegate methods. If there is currently a
|
||||
/// lookup in progress the lookup controller might collect the
|
||||
/// enqueued tracks and batch-process them.
|
||||
func enqueueTracksForLookup<S: SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S) {
|
||||
aggregatedTracks.appendContentsOf(tracks)
|
||||
for track in tracks {
|
||||
let preparationOperation = LookupPreparationOperation(track: track)
|
||||
addOperation(preparationOperation)
|
||||
}
|
||||
beginLookupIfPossible()
|
||||
}
|
||||
|
||||
// DOCUMENTATION: Must execute on main thread
|
||||
// FIXME: ? Can there be two lookup operations?
|
||||
private func beginLookupIfPossible() {
|
||||
// TODO: "Finished Lookup" delegate call
|
||||
if lookupOperation == nil && !aggregatedTracks.isEmpty {
|
||||
lookupOperation = LookupOperation(tracks: aggregatedTracks)
|
||||
lookupOperation?.addObserver(BlockObserver(startHandler: { operation in
|
||||
dispatch_sync(dispatch_get_main_queue()) {
|
||||
// TODO: Is dispatch_sync ok?
|
||||
let lookupOperation = operation as! LookupOperation
|
||||
self.lookupDelegate?.lookupQueue(self, willBeginLookupForTracks: lookupOperation.tracks)
|
||||
}
|
||||
}, produceHandler: nil, finishHandler: { (operation, errors) in
|
||||
dispatch_sync(dispatch_get_main_queue()) {
|
||||
// TODO: Is this a retain cycle?
|
||||
// TODO: Is dispatch_sync ok?
|
||||
self.lookupOperation(operation, finishedWithErrors: errors)
|
||||
}
|
||||
}))
|
||||
aggregatedTracks = []
|
||||
for operation in operations where operation is LookupPreparationOperation {
|
||||
lookupOperation?.addDependency(operation)
|
||||
}
|
||||
addOperation(lookupOperation!)
|
||||
}
|
||||
}
|
||||
|
||||
// DOCUMENTATION: Must execute on main thread.
|
||||
private func lookupOperation(operation: Operation, finishedWithErrors errors: [ErrorType]) {
|
||||
let lookupOperation = operation as! LookupOperation
|
||||
self.lookupDelegate?.lookupQueue(self, completedLookupForTracks: lookupOperation.tracks)
|
||||
self.lookupOperation = nil
|
||||
if aggregatedTracks.isEmpty {
|
||||
lookupDelegate?.lookupQueueDidFinishLookup(self)
|
||||
} else {
|
||||
beginLookupIfPossible()
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels the lookup. This will do three things:
|
||||
///
|
||||
/// 1. Stop any running or pending network request. Set the `lookupState` of
|
||||
/// all tracks to `NSCocoaError.UserCancelledError`.
|
||||
/// 2. Send the delegate a `lookupController(_:completedLookupForTracks:)`
|
||||
/// message.
|
||||
/// 3. Send the delegate a `lookupControllerDidFinishLookup(_:)` message.
|
||||
func cancelLookup() {
|
||||
// TODO: Implementation
|
||||
}
|
||||
|
||||
}
|
||||
1103
TagTunes/MainViewController.swift
Normal file → Executable file
66
TagTunes/MainWindowController.swift
Executable file
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// MainWindowController.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 09.03.16.
|
||||
// Copyright © 2016 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import AppKitPlus
|
||||
|
||||
class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
|
||||
dynamic let searchController = SearchController()
|
||||
|
||||
@IBOutlet weak var searchField: PopUpSearchField! {
|
||||
set {
|
||||
searchController.searchField = newValue
|
||||
}
|
||||
get {
|
||||
return searchController.searchField
|
||||
}
|
||||
}
|
||||
|
||||
dynamic var activityViewController: ActivityViewController!
|
||||
|
||||
@IBOutlet weak var activityProgressButton: ProgressButton!
|
||||
|
||||
let activityPopover = NSPopover()
|
||||
|
||||
override var contentViewController: NSViewController? {
|
||||
didSet {
|
||||
searchController.delegate = mainViewController
|
||||
}
|
||||
}
|
||||
|
||||
var mainViewController: MainViewController! {
|
||||
return contentViewController as? MainViewController
|
||||
}
|
||||
|
||||
override func windowDidLoad() {
|
||||
super.windowDidLoad()
|
||||
|
||||
activityViewController = storyboard?.instantiateControllerWithIdentifier("ActivityController") as! ActivityViewController
|
||||
|
||||
searchController.delegate = mainViewController
|
||||
LookupQueue.globalQueue.lookupDelegate = mainViewController
|
||||
|
||||
window?.titleVisibility = NSWindowTitleVisibility.Hidden
|
||||
activityProgressButton.target = self
|
||||
activityProgressButton.action = #selector(MainWindowController.showActivityView(_:))
|
||||
|
||||
activityPopover.contentViewController = activityViewController
|
||||
activityPopover.behavior = .ApplicationDefined
|
||||
}
|
||||
|
||||
@IBAction internal func beginSearch(sender: AnyObject?) {
|
||||
searchController.beginSearch()
|
||||
}
|
||||
|
||||
@objc @IBAction internal func showActivityView(sender: AnyObject?) {
|
||||
if let view = sender as? NSView {
|
||||
activityPopover.showRelativeToRect(view.bounds, ofView: view, preferredEdge: .MaxY)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
421
TagTunes/OutlineContentViewController.swift
Executable file
@@ -0,0 +1,421 @@
|
||||
//
|
||||
// OutlineContentViewController.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 21.01.16.
|
||||
// Copyright © 2016 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AppKitPlus
|
||||
import SearchAPI
|
||||
|
||||
class OutlineContentViewController: ContentViewController {
|
||||
|
||||
// MARK: Types
|
||||
|
||||
private struct OutlineViewConstants {
|
||||
struct ViewIdentifiers {
|
||||
/// The identifier used for the outline view rows representing
|
||||
/// `TagTunesTrack`s.
|
||||
static let SimpleTableCellViewIdentifier = "SimpleTableCellViewIdentifier"
|
||||
|
||||
/// The identifier used for the outline view rows representing
|
||||
/// `AlbumItem`s.
|
||||
static let AlbumTableCellViewIdentifier = "AlbumTableCellViewIdentifier"
|
||||
|
||||
/// The identifier used for the outline view rows representing
|
||||
/// `SongItem`s.
|
||||
static let TrackTableCellViewIdentifier = "TrackTableCellViewIdentifier"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: IBOutlets
|
||||
|
||||
@IBOutlet private weak var outlineView: NSOutlineView!
|
||||
|
||||
// MARK: Outline View Content
|
||||
|
||||
/// All items in the outline view.
|
||||
internal private(set) var items = [TagTunesItem]()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
outlineView.registerForDraggedTypes([TrackPboardType])
|
||||
}
|
||||
|
||||
override var selectedItems: [AnyObject] {
|
||||
var selectedGroups = Set<TagTunesGroupItem>()
|
||||
var selectedEntities = Set<TagTunesEntityItem>()
|
||||
var selectedTracks = Set<TagTunesTrack>()
|
||||
|
||||
for row in outlineView.selectedRowIndexes {
|
||||
let item = outlineView.itemAtRow(row)
|
||||
if let album = item as? TagTunesGroupItem {
|
||||
selectedGroups.insert(album)
|
||||
} else if let track = item as? TagTunesEntityItem {
|
||||
selectedEntities.insert(track)
|
||||
} else if let track = item as? TagTunesTrack {
|
||||
selectedTracks.insert(track)
|
||||
}
|
||||
}
|
||||
|
||||
for group in selectedGroups {
|
||||
for entity in group.children {
|
||||
for track in entity.associatedTracks {
|
||||
selectedTracks.remove(track)
|
||||
}
|
||||
selectedEntities.remove(entity)
|
||||
}
|
||||
}
|
||||
|
||||
for entity in selectedEntities {
|
||||
for track in entity.associatedTracks {
|
||||
selectedTracks.remove(track)
|
||||
}
|
||||
}
|
||||
|
||||
var selectedItems = [AnyObject]()
|
||||
selectedItems.appendContentsOf(Array(selectedGroups) as [AnyObject])
|
||||
selectedItems.appendContentsOf(Array(selectedEntities) as [AnyObject])
|
||||
selectedItems.appendContentsOf(Array(selectedTracks) as [AnyObject])
|
||||
return selectedItems
|
||||
}
|
||||
|
||||
override var clickedItems: [AnyObject] {
|
||||
if outlineView.clickedRow < 0 {
|
||||
return []
|
||||
} else if outlineView.selectedRowIndexes.contains(outlineView.clickedRow) {
|
||||
return selectedItems
|
||||
} else {
|
||||
return [outlineView.itemAtRow(outlineView.clickedRow)!]
|
||||
}
|
||||
}
|
||||
|
||||
override func addItem(item: TagTunesItem) {
|
||||
if itemForEntity(item.entity) == nil {
|
||||
items.append(item)
|
||||
outlineView.insertItemsAtIndexes(NSIndexSet(index: items.count-1), inParent: nil, withAnimation: NSTableViewAnimationOptions.EffectNone)
|
||||
}
|
||||
}
|
||||
|
||||
override func itemForEntity(entity: SearchAPIEntity) -> TagTunesItem? {
|
||||
for item in items {
|
||||
if item.entity == entity {
|
||||
return item
|
||||
}
|
||||
if let group = item as? TagTunesGroupItem {
|
||||
for child in group.children where child.entity == entity {
|
||||
return child
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
override func updateItem(item: TagTunesItem) {
|
||||
outlineView.reloadDataForRowIndexes(NSIndexSet(index: outlineView.rowForItem(item)), columnIndexes: NSIndexSet(index: 0))
|
||||
outlineView.reloadItem(item, reloadChildren: true)
|
||||
}
|
||||
|
||||
override func updateAllItems() {
|
||||
outlineView.reloadData()
|
||||
}
|
||||
|
||||
override func removeItems(objects: [AnyObject]) {
|
||||
outlineView.beginUpdates()
|
||||
for object in objects {
|
||||
if let item = object as? TagTunesItem {
|
||||
item.clearAssociatedTracks()
|
||||
if let index = items.indexOf({ $0 == item }) {
|
||||
items.removeAtIndex(index)
|
||||
outlineView.removeItemsAtIndexes(NSIndexSet(index: index), inParent: nil, withAnimation: .EffectNone)
|
||||
}
|
||||
} else if let track = object as? TagTunesTrack {
|
||||
if let entity = track.entity {
|
||||
entity.associatedTracks.remove(track)
|
||||
let row = outlineView.rowForItem(entity)
|
||||
outlineView.reloadDataForRowIndexes(NSIndexSet(index: row), columnIndexes: NSIndexSet(index: 0))
|
||||
outlineView.reloadItem(entity, reloadChildren: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
outlineView.endUpdates()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Outline View Data Source & Delegate
|
||||
|
||||
extension OutlineContentViewController: NSOutlineViewDataSource, NSOutlineViewDelegate {
|
||||
|
||||
func outlineView(outlineView: NSOutlineView, numberOfChildrenOfItem item: AnyObject?) -> Int {
|
||||
if item == nil {
|
||||
return items.count
|
||||
} else if let group = item as? TagTunesGroupItem {
|
||||
if case .Normal = group.loadingState {
|
||||
return group.children.count
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
} else if let entity = item as? TagTunesEntityItem {
|
||||
return entity.associatedTracks.count
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func outlineView(outlineView: NSOutlineView, child index: Int, ofItem item: AnyObject?) -> AnyObject {
|
||||
if item == nil {
|
||||
return items[index]
|
||||
} else if let group = item as? TagTunesGroupItem {
|
||||
return group.children[index]
|
||||
} else if let entity = item as? TagTunesEntityItem {
|
||||
return Array(entity.associatedTracks)[index]
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func outlineView(outlineView: NSOutlineView, isItemExpandable item: AnyObject) -> Bool {
|
||||
return self.outlineView(outlineView, numberOfChildrenOfItem: item) > 0
|
||||
}
|
||||
|
||||
func outlineView(outlineView: NSOutlineView, heightOfRowByItem item: AnyObject) -> CGFloat {
|
||||
if item is AlbumItem {
|
||||
return 39
|
||||
} else if let entity = item as? TagTunesEntityItem {
|
||||
return (entity.parentItem != nil && entity.parentItem!.hasCommonArtist) ? 24 : 31
|
||||
} else {
|
||||
return 17
|
||||
}
|
||||
}
|
||||
|
||||
func outlineView(outlineView: NSOutlineView, viewForTableColumn tableColumn: NSTableColumn?, item: AnyObject) -> NSView? {
|
||||
if let albumItem = item as? AlbumItem {
|
||||
var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.AlbumTableCellViewIdentifier, owner: nil) as? AlbumTableCellView
|
||||
if view == nil {
|
||||
view = AlbumTableCellView()
|
||||
view?.identifier = OutlineViewConstants.ViewIdentifiers.AlbumTableCellViewIdentifier
|
||||
}
|
||||
let height = self.outlineView(outlineView, heightOfRowByItem: item)
|
||||
view?.setupForAlbumItem(albumItem, height: height)
|
||||
view?.errorButton?.target = self
|
||||
view?.errorButton.action = #selector(OutlineContentViewController.showErrorDetails(_:))
|
||||
return view
|
||||
}
|
||||
if let songItem = item as? SongItem {
|
||||
var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.TrackTableCellViewIdentifier, owner: nil) as? SongTableCellView
|
||||
if view == nil {
|
||||
view = SongTableCellView()
|
||||
view?.identifier = OutlineViewConstants.ViewIdentifiers.TrackTableCellViewIdentifier
|
||||
}
|
||||
view?.setupForSongItem(songItem)
|
||||
return view
|
||||
}
|
||||
if let track = item as? TagTunesTrack {
|
||||
var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.SimpleTableCellViewIdentifier, owner: nil) as? AdvancedTableCellView
|
||||
if view == nil {
|
||||
view = AdvancedTableCellView()
|
||||
view?.identifier = OutlineViewConstants.ViewIdentifiers.SimpleTableCellViewIdentifier
|
||||
}
|
||||
view?.style = .Simple
|
||||
view?.textField?.font = NSFont.systemFontOfSize(0)
|
||||
view?.textField?.textColor = NSColor.textColor()
|
||||
let nameFont = NSFont.labelFontOfSize(13)
|
||||
if let name = track.name {
|
||||
view?.textField?.stringValue = name
|
||||
view?.textField?.textColor = NSColor.textColor()
|
||||
view?.textField?.font = nameFont
|
||||
} else {
|
||||
let italicFont = NSFontManager.sharedFontManager().convertFont(nameFont, toHaveTrait: .ItalicFontMask)
|
||||
view?.textField?.stringValue = NSLocalizedString("Unnamed Track", comment: "Default name for a track that has no name associated with it.")
|
||||
view?.textField?.textColor = NSColor.disabledControlTextColor()
|
||||
view?.textField?.font = italicFont
|
||||
}
|
||||
return view
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: Drag and Drop
|
||||
|
||||
func outlineView(outlineView: NSOutlineView, writeItems items: [AnyObject], toPasteboard pasteboard: NSPasteboard) -> Bool {
|
||||
if items.count > 1 {
|
||||
for item in items {
|
||||
if item is TagTunesGroupItem {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var draggedItems = [NSPasteboardItem]()
|
||||
let addDraggedTrack: (TagTunesTrack, row: Int) -> Void = { track, row in
|
||||
let item = NSPasteboardItem()
|
||||
item.setData(NSKeyedArchiver.archivedDataWithRootObject(track), forType: TrackPboardType)
|
||||
item.setData(NSKeyedArchiver.archivedDataWithRootObject(row), forType: IndexPboardType)
|
||||
draggedItems.append(item)
|
||||
}
|
||||
for item in items {
|
||||
if let draggedItem = item as? TagTunesItem {
|
||||
let row = outlineView.rowForItem(draggedItem)
|
||||
for track in draggedItem.associatedTracks {
|
||||
addDraggedTrack(track, row: row)
|
||||
}
|
||||
} else if let track = item as? TagTunesTrack, entity = track.entity {
|
||||
if !items.contains({ $0 === entity }) {
|
||||
let row = outlineView.rowForItem(entity)
|
||||
addDraggedTrack(track, row: row)
|
||||
}
|
||||
}
|
||||
}
|
||||
pasteboard.clearContents()
|
||||
pasteboard.writeObjects(draggedItems)
|
||||
return !draggedItems.isEmpty
|
||||
}
|
||||
|
||||
func outlineView(outlineView: NSOutlineView, draggingSession session: NSDraggingSession, endedAtPoint screenPoint: NSPoint, operation: NSDragOperation) {
|
||||
if let pointInWindow = view.window?.convertRectFromScreen(NSRect(origin: screenPoint, size: NSSize.zero)).origin {
|
||||
let pointInView = view.window!.contentView!.convertPoint(pointInWindow, fromView: nil)
|
||||
let targetView = view.window!.contentView!.hitTest(pointInView)
|
||||
if targetView?.isDescendantOf(outlineView) ?? false {
|
||||
return
|
||||
}
|
||||
}
|
||||
if let draggedItems = session.draggingPasteboard.pasteboardItems where operation != .None {
|
||||
outlineView.beginUpdates()
|
||||
let draggedTracks = draggedItems.map { ($0.dataForType(IndexPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as! Int, $0.dataForType(TrackPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as! TagTunesTrack) }
|
||||
for (row, track) in draggedTracks {
|
||||
let item = outlineView.itemAtRow(row) as! TagTunesItem
|
||||
if item is TagTunesGroupItem {
|
||||
item.clearAssociatedTracks()
|
||||
} else if let entity = item as? TagTunesEntityItem {
|
||||
entity.associatedTracks.remove(track)
|
||||
}
|
||||
outlineView.reloadDataForRowIndexes(NSIndexSet(index: row), columnIndexes: NSIndexSet(index: 0))
|
||||
outlineView.reloadItem(item, reloadChildren: true)
|
||||
}
|
||||
outlineView.endUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
func outlineView(outlineView: NSOutlineView, validateDrop info: NSDraggingInfo, proposedItem item: AnyObject?, proposedChildIndex index: Int) -> NSDragOperation {
|
||||
// Validate pasteboard contents
|
||||
guard info.draggingPasteboard().canReadItemWithDataConformingToTypes([TrackPboardType]) else {
|
||||
return .None
|
||||
}
|
||||
// Drop onto empty outline view
|
||||
if item == nil && index == NSOutlineViewDropOnItemIndex {
|
||||
return .None
|
||||
}
|
||||
// Drop on TagTunesTrack item or between items
|
||||
if index != NSOutlineViewDropOnItemIndex || item is TagTunesTrack {
|
||||
return .None
|
||||
}
|
||||
// Drop on loading (or failed) album
|
||||
if let group = item as? TagTunesGroupItem {
|
||||
guard case .Normal = group.loadingState else {
|
||||
return .None
|
||||
}
|
||||
}
|
||||
return .Move
|
||||
}
|
||||
|
||||
func outlineView(outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: AnyObject?, childIndex index: Int) -> Bool {
|
||||
guard let draggedItems = info.draggingPasteboard().pasteboardItems, targetItem = item as? TagTunesItem else {
|
||||
return false
|
||||
}
|
||||
outlineView.beginUpdates()
|
||||
let draggedTracks = draggedItems.map { ($0.dataForType(IndexPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as? Int, $0.dataForType(TrackPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as? TagTunesTrack, $0) }
|
||||
|
||||
if info.draggingSource() === outlineView {
|
||||
for (row, track, _) in draggedTracks {
|
||||
let item = outlineView.itemAtRow(row!) as! TagTunesItem
|
||||
if item is TagTunesGroupItem {
|
||||
item.clearAssociatedTracks()
|
||||
} else if let entity = item as? TagTunesEntityItem, theTrack = track {
|
||||
entity.associatedTracks.remove(theTrack)
|
||||
}
|
||||
outlineView.reloadDataForRowIndexes(NSIndexSet(index: row!), columnIndexes: NSIndexSet(index: 0))
|
||||
outlineView.reloadItem(item, reloadChildren: true)
|
||||
}
|
||||
}
|
||||
|
||||
var remainingItems = [NSPasteboardItem]()
|
||||
for (_, track, item) in draggedTracks {
|
||||
if let theTrack = track {
|
||||
if !targetItem.addAssociatedTracks([theTrack]).isEmpty {
|
||||
remainingItems.append(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
NSApp.unsortedTracksController.returnDraggedItems(remainingItems)
|
||||
|
||||
outlineView.reloadDataForRowIndexes(NSIndexSet(index: outlineView.rowForItem(targetItem)), columnIndexes: NSIndexSet(index: 0))
|
||||
outlineView.reloadItem(targetItem, reloadChildren: true)
|
||||
outlineView.endUpdates()
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Error Handling
|
||||
|
||||
extension OutlineContentViewController {
|
||||
|
||||
/// Action that should be triggered from a view inside the outline view. If
|
||||
/// `sender` is not an `NSError` instance the item at the row associated with
|
||||
/// the `sender` (as determined by `NSOutlineView.rowForView`) should be a
|
||||
/// `NSError` or `Album` instance for this method to work correctly.
|
||||
@objc @IBAction private func showErrorDetails(sender: AnyObject?) {
|
||||
var error: NSError
|
||||
if let theError = sender as? NSError {
|
||||
error = theError
|
||||
} else if let view = sender as? NSView {
|
||||
let row = outlineView.rowForView(view)
|
||||
let item = outlineView.itemAtRow(row)
|
||||
if let group = item as? TagTunesGroupItem {
|
||||
if case let .Error(theError) = group.loadingState {
|
||||
error = theError as NSError
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
presentError(error, modalForWindow: view.window!, delegate: nil, didPresentSelector: nil, contextInfo: nil)
|
||||
}
|
||||
|
||||
override func willPresentError(error: NSError) -> NSError {
|
||||
let recoveryOptions = [
|
||||
NSLocalizedString("OK", comment: "Button title"),
|
||||
NSLocalizedString("Try Again", comment: "Button title for error alerts offering the user to try again.")
|
||||
]
|
||||
return DescriptiveError(underlyingError: error, userInfo: [NSRecoveryAttempterErrorKey: self, NSLocalizedRecoveryOptionsErrorKey: recoveryOptions])
|
||||
}
|
||||
|
||||
override func attemptRecoveryFromError(error: NSError, optionIndex recoveryOptionIndex: Int, delegate: AnyObject?, didRecoverSelector: Selector, contextInfo: UnsafeMutablePointer<Void>) {
|
||||
let didRecover = attemptRecoveryFromError(error, optionIndex: recoveryOptionIndex)
|
||||
delegate?.performSelector(didRecoverSelector, withObject: didRecover, withObject: contextInfo as! AnyObject)
|
||||
}
|
||||
|
||||
override func attemptRecoveryFromError(error: NSError, optionIndex recoveryOptionIndex: Int) -> Bool {
|
||||
if recoveryOptionIndex == 0 {
|
||||
return true
|
||||
}
|
||||
for item in items where item is TagTunesGroupItem {
|
||||
let group = item as! TagTunesGroupItem
|
||||
if case let .Error(loadingError as NSError) = group.loadingState where error == loadingError || error.userInfo[NSUnderlyingErrorKey] === loadingError {
|
||||
group.beginLoadingChildren()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
80
TagTunes/Preference Controllers.swift
Normal file → Executable file
@@ -8,43 +8,10 @@
|
||||
|
||||
import Cocoa
|
||||
|
||||
internal class GeneralPreferencesViewController: NSViewController {
|
||||
|
||||
// MARK: IBOutlets
|
||||
|
||||
@IBOutlet internal weak var artworkPathControl: NSPathControl!
|
||||
@IBOutlet internal weak var chooseArtworkButton: NSButton!
|
||||
|
||||
override internal func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
artworkPathControl.URL = Preferences.sharedPreferences.artworkTarget
|
||||
}
|
||||
|
||||
@IBAction internal func saveArtworkStateChanged(sender: AnyObject) {
|
||||
if Preferences.sharedPreferences.saveArtwork && Preferences.sharedPreferences.artworkTarget == nil {
|
||||
chooseArtworkPath(sender)
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction internal func chooseArtworkPath(sender: AnyObject) {
|
||||
let openPanel = NSOpenPanel()
|
||||
openPanel.canChooseDirectories = true
|
||||
openPanel.canChooseFiles = false
|
||||
openPanel.canCreateDirectories = true
|
||||
openPanel.prompt = NSLocalizedString("Choose…", comment: "Button title in an open dialog prompting the user to choose a directory")
|
||||
openPanel.beginSheetModalForWindow(view.window!) {
|
||||
result in
|
||||
if result == NSModalResponseOK {
|
||||
Preferences.sharedPreferences.artworkTarget = openPanel.URL!.filePathURL!
|
||||
} else if Preferences.sharedPreferences.artworkTarget == nil {
|
||||
Preferences.sharedPreferences.saveArtwork = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GeneralPreferencesViewController: NSViewController {
|
||||
}
|
||||
|
||||
internal class StorePreferencesViewController: NSViewController {
|
||||
class StorePreferencesViewController: NSViewController {
|
||||
|
||||
dynamic var iTunesStores: [String] {
|
||||
return NSLocale.ISOCountryCodes().map { NSLocale.currentLocale().displayNameForKey(NSLocaleCountryCode, value: $0)! }
|
||||
@@ -62,7 +29,7 @@ internal class StorePreferencesViewController: NSViewController {
|
||||
|
||||
}
|
||||
|
||||
internal class TagsPreferencesViewController: NSViewController, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate {
|
||||
class TagsPreferencesViewController: NSViewController, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate {
|
||||
|
||||
// MARK: Types
|
||||
|
||||
@@ -84,12 +51,12 @@ internal class TagsPreferencesViewController: NSViewController, NSTableViewDataS
|
||||
|
||||
// MARK: Table View
|
||||
|
||||
internal func numberOfRowsInTableView(tableView: NSTableView) -> Int {
|
||||
return Track.Tag.allTags.count
|
||||
func numberOfRowsInTableView(tableView: NSTableView) -> Int {
|
||||
return Tag.allTags.count
|
||||
}
|
||||
|
||||
func tableView(tableView: NSTableView, viewForTableColumn tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||
let tag = Track.Tag.allTags[row]
|
||||
let tag = Tag.allTags[row]
|
||||
if tableColumn?.identifier == TableViewConstants.tagTableColumnIdentifier {
|
||||
let view = tableView.makeViewWithIdentifier(TableViewConstants.textTableCellViewIdentifier, owner: nil) as? NSTableCellView
|
||||
view?.textField?.stringValue = tag.localizedName
|
||||
@@ -100,9 +67,7 @@ internal class TagsPreferencesViewController: NSViewController, NSTableViewDataS
|
||||
if tag.isReturnedBySearchAPI {
|
||||
popupButton?.addItemWithTitle(NSLocalizedString("Save", comment: "Menu item title for a tag that is going to be saved"))
|
||||
}
|
||||
if tag.clearable {
|
||||
popupButton?.addItemWithTitle(NSLocalizedString("Clear", comment: "Menu item title for a tag that is going to be cleared"))
|
||||
}
|
||||
popupButton?.addItemWithTitle(NSLocalizedString("Clear", comment: "Menu item title for a tag that is going to be cleared"))
|
||||
popupButton?.addItemWithTitle(NSLocalizedString("Ignore", comment: "Menu item title for a tag that is not going to be saved"))
|
||||
|
||||
var selectedIndex: Int
|
||||
@@ -111,17 +76,11 @@ internal class TagsPreferencesViewController: NSViewController, NSTableViewDataS
|
||||
selectedIndex = 0
|
||||
case .Clear:
|
||||
selectedIndex = 1
|
||||
if !tag.isReturnedBySearchAPI {
|
||||
--selectedIndex
|
||||
}
|
||||
case .Ignore:
|
||||
selectedIndex = 2
|
||||
if !tag.isReturnedBySearchAPI {
|
||||
--selectedIndex
|
||||
}
|
||||
if !tag.clearable {
|
||||
--selectedIndex
|
||||
}
|
||||
}
|
||||
if !tag.isReturnedBySearchAPI {
|
||||
selectedIndex -= 1
|
||||
}
|
||||
popupButton?.selectItemAtIndex(selectedIndex)
|
||||
return popupButton
|
||||
@@ -130,27 +89,14 @@ internal class TagsPreferencesViewController: NSViewController, NSTableViewDataS
|
||||
}
|
||||
|
||||
@IBAction private func savingBehaviorChanged(sender: NSPopUpButton) {
|
||||
let tag = Track.Tag.allTags[tableView.rowForView(sender)]
|
||||
let tag = Tag.allTags[tableView.rowForView(sender)]
|
||||
let selectedIndex = sender.indexOfItem(sender.selectedItem!)
|
||||
var savingBehavior = Preferences.sharedPreferences.tagSavingBehaviors[tag]!
|
||||
switch selectedIndex {
|
||||
case 0:
|
||||
if tag.isReturnedBySearchAPI {
|
||||
savingBehavior = .Save
|
||||
} else if tag.clearable {
|
||||
savingBehavior = .Clear
|
||||
} else {
|
||||
savingBehavior = .Ignore
|
||||
}
|
||||
savingBehavior = tag.isReturnedBySearchAPI ? .Save : .Clear
|
||||
case 1:
|
||||
if tag.isReturnedBySearchAPI {
|
||||
if tag.clearable {
|
||||
savingBehavior = .Clear
|
||||
} else {
|
||||
savingBehavior = .Ignore
|
||||
}
|
||||
}
|
||||
savingBehavior = .Ignore
|
||||
savingBehavior = tag.isReturnedBySearchAPI ? .Clear : .Ignore
|
||||
case 2:
|
||||
savingBehavior = .Ignore
|
||||
default:
|
||||
|
||||
113
TagTunes/Preferences.swift
Normal file → Executable file
@@ -27,14 +27,6 @@ import Cocoa
|
||||
// MARK: Types
|
||||
|
||||
internal struct UserDefaultsConstants {
|
||||
|
||||
static let saveArtworkKey = "Save Artwork"
|
||||
|
||||
static let artworkTargetKey = "Artwork Target"
|
||||
|
||||
static let overwriteExistingFilesKey = "Overwrite Existing Files"
|
||||
|
||||
static let keepSearchResultsKey = "Keep Search Results"
|
||||
|
||||
static let numberOfSearchResultsKey = "Number of Search Results"
|
||||
|
||||
@@ -42,18 +34,12 @@ import Cocoa
|
||||
|
||||
static let useEnglishTagsKey = "Use English Tags"
|
||||
|
||||
static let useLowResolutionArtworkKey = "Use Low Resolution Artwork"
|
||||
|
||||
static let removeSavedItemsKey = "Remove Saved Items"
|
||||
|
||||
static let keepSavedAlbumsKey = "Keep Saved Albums"
|
||||
|
||||
static let useCensoredNamesKey = "Use Censored Names"
|
||||
|
||||
static let caseSensitiveKey = "Case Sensitive"
|
||||
|
||||
static let clearArtworksKey = "Clear Artworks"
|
||||
|
||||
static let tagSavingBehaviorsKey = "Tag Saving Behaviors"
|
||||
}
|
||||
|
||||
@@ -81,74 +67,27 @@ import Cocoa
|
||||
/// overridden.
|
||||
public func initializeDefaultValues() {
|
||||
NSUserDefaults.standardUserDefaults().registerDefaults([
|
||||
UserDefaultsConstants.saveArtworkKey: false,
|
||||
UserDefaultsConstants.overwriteExistingFilesKey: false,
|
||||
UserDefaultsConstants.keepSearchResultsKey: false,
|
||||
UserDefaultsConstants.numberOfSearchResultsKey: 10,
|
||||
UserDefaultsConstants.iTunesStoreKey: NSLocale.currentLocale().objectForKey(NSLocaleCountryCode)!,
|
||||
UserDefaultsConstants.useEnglishTagsKey: false,
|
||||
UserDefaultsConstants.useLowResolutionArtworkKey: false,
|
||||
UserDefaultsConstants.removeSavedItemsKey: false,
|
||||
UserDefaultsConstants.keepSavedAlbumsKey: false,
|
||||
UserDefaultsConstants.useCensoredNamesKey: false,
|
||||
UserDefaultsConstants.caseSensitiveKey: true,
|
||||
UserDefaultsConstants.clearArtworksKey: false
|
||||
UserDefaultsConstants.caseSensitiveKey: true
|
||||
])
|
||||
if NSUserDefaults.standardUserDefaults().dictionaryForKey(UserDefaultsConstants.tagSavingBehaviorsKey) == nil {
|
||||
var savingBehaviors: [Track.Tag: TagSavingBehavior] = [:]
|
||||
for tag in Track.Tag.allTags {
|
||||
tagSavingBehaviors = [:]
|
||||
}
|
||||
var savingBehaviors = tagSavingBehaviors
|
||||
for tag in Tag.allTags {
|
||||
if savingBehaviors[tag] == nil {
|
||||
savingBehaviors[tag] = tag.isReturnedBySearchAPI ? .Save : .Clear
|
||||
}
|
||||
tagSavingBehaviors = savingBehaviors
|
||||
}
|
||||
tagSavingBehaviors = savingBehaviors
|
||||
}
|
||||
|
||||
// MARK: General Preferences
|
||||
|
||||
/// If `true` the album artwork should be saved to the `artworkTarget` URL
|
||||
/// when an item is saved.
|
||||
public dynamic var saveArtwork: Bool {
|
||||
set {
|
||||
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: UserDefaultsConstants.saveArtworkKey)
|
||||
}
|
||||
get {
|
||||
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.saveArtworkKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL of the folder album artwork is saved to.
|
||||
///
|
||||
/// The URL must be a valid file URL pointing to a directory.
|
||||
public dynamic var artworkTarget: NSURL? {
|
||||
set {
|
||||
NSUserDefaults.standardUserDefaults().setURL(newValue, forKey: UserDefaultsConstants.artworkTargetKey)
|
||||
}
|
||||
get {
|
||||
return NSUserDefaults.standardUserDefaults().URLForKey(UserDefaultsConstants.artworkTargetKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// If `true` any existing files will be overwritten when artworks are saved.
|
||||
public dynamic var overwriteExistingFiles: Bool {
|
||||
set {
|
||||
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: UserDefaultsConstants.overwriteExistingFilesKey)
|
||||
}
|
||||
get {
|
||||
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.overwriteExistingFilesKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// If `true` the search results are not removed from the main outline view
|
||||
/// when the user selects a result.
|
||||
public dynamic var keepSearchResults: Bool {
|
||||
set {
|
||||
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: UserDefaultsConstants.keepSearchResultsKey)
|
||||
}
|
||||
get {
|
||||
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.keepSearchResultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of search results that should be displayed.
|
||||
public dynamic var numberOfSearchResults: Int {
|
||||
set {
|
||||
@@ -181,17 +120,6 @@ import Cocoa
|
||||
}
|
||||
}
|
||||
|
||||
/// If `true` the main table view will use 100x100 artworks instead of full
|
||||
/// sized images. This option does not affect saving.
|
||||
public dynamic var useLowResolutionArtwork: Bool {
|
||||
set {
|
||||
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: UserDefaultsConstants.useLowResolutionArtworkKey)
|
||||
}
|
||||
get {
|
||||
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.useLowResolutionArtworkKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// If `true` all saved items are removed from the list after saving.
|
||||
public dynamic var removeSavedItems: Bool {
|
||||
set {
|
||||
@@ -201,17 +129,6 @@ import Cocoa
|
||||
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.removeSavedItemsKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// If `true` and `removeSavedItems` is also `true` albums are not removed on
|
||||
/// saving.
|
||||
public dynamic var keepSavedAlbums: Bool {
|
||||
set {
|
||||
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: UserDefaultsConstants.keepSavedAlbumsKey)
|
||||
}
|
||||
get {
|
||||
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.keepSavedAlbumsKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Tag Preferences
|
||||
|
||||
@@ -235,26 +152,16 @@ import Cocoa
|
||||
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.caseSensitiveKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// If `true` TagTunes clears the artworsk of saved tracks.
|
||||
public dynamic var clearArtworks: Bool {
|
||||
set {
|
||||
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: UserDefaultsConstants.clearArtworksKey)
|
||||
}
|
||||
get {
|
||||
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.clearArtworksKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// The ways different tags are saved (or not saved).
|
||||
public var tagSavingBehaviors: [Track.Tag: TagSavingBehavior] {
|
||||
public var tagSavingBehaviors: [Tag: TagSavingBehavior] {
|
||||
set {
|
||||
let savableData = newValue.map { ($0.rawValue, $1.rawValue) }
|
||||
NSUserDefaults.standardUserDefaults().setObject(savableData, forKey: UserDefaultsConstants.tagSavingBehaviorsKey)
|
||||
}
|
||||
get {
|
||||
let savableData = NSUserDefaults.standardUserDefaults().dictionaryForKey(UserDefaultsConstants.tagSavingBehaviorsKey)!
|
||||
return savableData.map { (Track.Tag(rawValue: $0)!, TagSavingBehavior(rawValue: $1 as! String)!) }
|
||||
return savableData.map { (Tag(rawValue: $0)!, TagSavingBehavior(rawValue: $1 as! String)!) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
99
TagTunes/SaveOperation.swift
Executable file
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// iTunesSaveOperation.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 08.04.16.
|
||||
// Copyright © 2016 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import AppKitPlus
|
||||
|
||||
// TODO: Documentation
|
||||
|
||||
class SaveOperation<TrackType: TagTunesTrack>: Operation, NSProgressReporting {
|
||||
|
||||
// TODO: Support Cancellation
|
||||
let progress = NSProgress(parent: nil, userInfo: nil)
|
||||
|
||||
let track: TrackType
|
||||
let entity: TagTunesEntityItem
|
||||
let parentEntity: TagTunesGroupItem?
|
||||
|
||||
init(track: TrackType, entity: TagTunesEntityItem) {
|
||||
self.track = track
|
||||
self.entity = entity
|
||||
self.parentEntity = entity.parentItem
|
||||
super.init()
|
||||
|
||||
progress.cancellable = false
|
||||
progress.localizedDescription = entity.entity.name
|
||||
progress.localizedAdditionalDescription = "Pending…" // TODO: Localize
|
||||
addCondition(MutuallyExclusive<TrackType>())
|
||||
}
|
||||
|
||||
override func execute() {
|
||||
dispatch_sync(dispatch_get_main_queue()) {
|
||||
self.progress.totalUnitCount = Int64(Tag.allTags.count)
|
||||
self.progress.completedUnitCount = 0
|
||||
}
|
||||
if track.supportsBatchSaving {
|
||||
executeBatchSave()
|
||||
} else {
|
||||
executeNormalSave()
|
||||
}
|
||||
}
|
||||
|
||||
private func executeBatchSave() {
|
||||
dispatch_sync(dispatch_get_main_queue()) {
|
||||
self.progress.localizedAdditionalDescription = "Saving…" // TODO: Localize
|
||||
}
|
||||
var tags = [Tag: AnyObject?]()
|
||||
for tag in Tag.allTags {
|
||||
do {
|
||||
switch Preferences.sharedPreferences.tagSavingBehaviors[tag]! {
|
||||
case .Save:
|
||||
tags[tag] = try entity.valueForTag(tag)
|
||||
case .Clear:
|
||||
tags[tag] = nil
|
||||
case .Ignore:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
logError(error as NSError)
|
||||
}
|
||||
}
|
||||
progress.becomeCurrentWithPendingUnitCount(progress.totalUnitCount)
|
||||
do {
|
||||
try track.saveTags(tags)
|
||||
finish()
|
||||
} catch {
|
||||
finishWithError(error as NSError)
|
||||
}
|
||||
progress.resignCurrent()
|
||||
}
|
||||
|
||||
private func executeNormalSave() {
|
||||
for tag in Tag.allTags {
|
||||
dispatch_sync(dispatch_get_main_queue()) {
|
||||
self.progress.localizedAdditionalDescription = String(format: "Saving %@…", tag.localizedName) // TODO: Localize
|
||||
}
|
||||
do {
|
||||
switch Preferences.sharedPreferences.tagSavingBehaviors[tag]! {
|
||||
case .Save:
|
||||
try track.saveValue(entity.valueForTag(tag), forTag: tag)
|
||||
case .Clear:
|
||||
try track.saveValue(nil, forTag: tag)
|
||||
case .Ignore:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
logError(error as NSError)
|
||||
}
|
||||
dispatch_sync(dispatch_get_main_queue()) {
|
||||
self.progress.completedUnitCount += 1
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
}
|
||||
63
TagTunes/SaveTableCellView.swift
Executable file
@@ -0,0 +1,63 @@
|
||||
//
|
||||
// SaveTableCellView.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 08.04.16.
|
||||
// Copyright © 2016 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import AppKitPlus
|
||||
|
||||
// TODO: Generalize for AppKitPlus
|
||||
|
||||
class SaveTableCellView: AdvancedTableCellView {
|
||||
|
||||
let progressIndicator: NSProgressIndicator = {
|
||||
let indicator = NSProgressIndicator()
|
||||
indicator.style = .SpinningStyle
|
||||
indicator.controlSize = .SmallControlSize
|
||||
indicator.minValue = 0
|
||||
indicator.maxValue = 1
|
||||
indicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
return indicator
|
||||
}()
|
||||
|
||||
let cancelButton: NSButton = {
|
||||
let button = NSButton()
|
||||
button.setButtonType(.MomentaryChangeButton)
|
||||
button.bezelStyle = .ShadowlessSquareBezelStyle
|
||||
button.bordered = false
|
||||
button.imagePosition = .ImageOnly
|
||||
button.image = NSImage(named: NSImageNameStopProgressFreestandingTemplate)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
return button
|
||||
}()
|
||||
|
||||
dynamic var progress: NSProgress?
|
||||
|
||||
override func setupView() {
|
||||
style = .Subtitle
|
||||
|
||||
cancelButton.target = self
|
||||
cancelButton.action = #selector(SaveTableCellView.cancel(_:))
|
||||
let stackView = NSStackView(views: [progressIndicator, cancelButton])
|
||||
stackView.orientation = .Horizontal
|
||||
|
||||
rightAccessoryView = stackView
|
||||
|
||||
// Setup bindings
|
||||
primaryTextField?.bind(NSValueBinding, toObject: self, withKeyPath: "progress.localizedDescription", options: nil)
|
||||
secondaryTextField?.bind(NSValueBinding, toObject: self, withKeyPath: "progress.localizedAdditionalDescription", options: nil)
|
||||
cancelButton.bind(NSEnabledBinding, toObject: self, withKeyPath: "progress.cancellable", options: nil)
|
||||
progressIndicator.bind(NSValueBinding, toObject: self, withKeyPath: "progress.fractionCompleted", options: nil)
|
||||
progressIndicator.bind(NSIsIndeterminateBinding, toObject: self, withKeyPath: "progress.indeterminate", options: nil)
|
||||
progressIndicator.bind(NSAnimateBinding, toObject: self, withKeyPath: "progress.indeterminate", options: nil)
|
||||
|
||||
super.setupView()
|
||||
}
|
||||
|
||||
func cancel(sender: AnyObject?) {
|
||||
progress?.cancel()
|
||||
}
|
||||
|
||||
}
|
||||
182
TagTunes/SearchController.swift
Executable file
@@ -0,0 +1,182 @@
|
||||
//
|
||||
// SearchController.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 06.03.16.
|
||||
// Copyright © 2016 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import SearchAPI
|
||||
import AppKitPlus
|
||||
|
||||
/// A search delegate is notified when a `SearchController` selects an item.
|
||||
protocol SearchDelegate: class {
|
||||
|
||||
/// The view controller displaying the content.
|
||||
var contentViewController: ContentViewController! { get }
|
||||
|
||||
/// Invoked when the `searchController` selected a item. In the
|
||||
/// implementation the selected `searchResult` should be added to the
|
||||
/// `contentViewController`.
|
||||
func searchController(searchController: SearchController, didSelectSearchResult searchResult: TagTunesItem)
|
||||
|
||||
}
|
||||
|
||||
/// Manages the user interface for search results.
|
||||
public class SearchController: NSObject, PopUpSearchFieldDelegate {
|
||||
|
||||
/// This struct contains *magic numbers* like references to storyboard
|
||||
/// identifiers.
|
||||
private struct Constants {
|
||||
|
||||
/// The identifier used for the rows in the search results pop up.
|
||||
static let AlbumTableCellViewIdentifier = "AlbumTableCellViewIdentifier"
|
||||
|
||||
/// The identifier used for the `No Results` row in the search results
|
||||
/// pop up.
|
||||
static let NoResultsTableCellViewIdentifier = "NoResultsTableCellViewIdentifier"
|
||||
|
||||
}
|
||||
|
||||
weak var delegate: SearchDelegate?
|
||||
|
||||
/// The search field. This should be set by the view controller containing
|
||||
/// the user interface for searching.
|
||||
public weak var searchField: PopUpSearchField! {
|
||||
willSet {
|
||||
searchField?.popUpDelegate = nil
|
||||
}
|
||||
didSet {
|
||||
searchField?.popUpDelegate = self
|
||||
}
|
||||
}
|
||||
|
||||
/// Used for searching and loading search results
|
||||
private let urlSession = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration(), delegate: nil, delegateQueue: NSOperationQueue.mainQueue())
|
||||
|
||||
/// The URL task currently loading the search results.
|
||||
private var searchTask: NSURLSessionTask? {
|
||||
didSet {
|
||||
searching = searchTask != nil
|
||||
}
|
||||
}
|
||||
|
||||
public private(set) dynamic var searching: Bool = false
|
||||
|
||||
/// The search results for the current search term.
|
||||
private var searchResults = [Album]() {
|
||||
didSet {
|
||||
searchField.updatePopUp()
|
||||
}
|
||||
}
|
||||
|
||||
/// The error that occured during searching, if any.
|
||||
private var searchError: NSError?
|
||||
|
||||
/// Begins searching for the `searchField`'s `stringValue`.
|
||||
public func beginSearch() {
|
||||
let searchString = searchField.stringValue
|
||||
cancelSearch()
|
||||
if searchString == "" {
|
||||
searchResults = []
|
||||
} else {
|
||||
var request = SearchAPIRequest(searchRequestWithTerm: searchString)
|
||||
request.mediaType = .Music
|
||||
request.entity = .Album
|
||||
request.maximumNumberOfResults = UInt(Preferences.sharedPreferences.numberOfSearchResults)
|
||||
if Preferences.sharedPreferences.useEnglishTags {
|
||||
request.language = .English
|
||||
}
|
||||
request.country = Preferences.sharedPreferences.iTunesStore
|
||||
searchTask = urlSession.dataTaskWithURL(request.URL, completionHandler: processSearchResults)
|
||||
searchTask!.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels the current search (if there is one) and removes the search
|
||||
/// results.
|
||||
private func cancelSearch() {
|
||||
searchTask?.cancel()
|
||||
}
|
||||
|
||||
/// Processes the data returned from a network request into the
|
||||
/// `searchResults` array.
|
||||
private func processSearchResults(data: NSData?, response: NSURLResponse?, error: NSError?) {
|
||||
searchTask = nil
|
||||
var newResults = [Album]()
|
||||
if let theData = data where error == nil {
|
||||
do {
|
||||
let result = try SearchAPIResult(data: theData)
|
||||
newResults = result.resultEntities.flatMap{ $0 as? Album }
|
||||
} catch let error as NSError {
|
||||
searchError = error
|
||||
}
|
||||
} else if let theError = error {
|
||||
searchError = theError
|
||||
}
|
||||
searchResults = newResults
|
||||
}
|
||||
|
||||
public func popUpSearchFieldWillHidePopUp(searchField: PopUpSearchField) {
|
||||
cancelSearch()
|
||||
}
|
||||
|
||||
public func popUpSearchFieldShouldShowPopUp(searchField: PopUpSearchField) -> Bool {
|
||||
return !searchField.stringValue.isEmpty
|
||||
}
|
||||
|
||||
public func popUpSearchField(searchField: PopUpSearchField, didSelectPopUpEntryAtRow row: Int) {
|
||||
let searchResult = searchResults[row]
|
||||
let albumItem = AlbumItem(entity: searchResult)
|
||||
albumItem.beginLoadingChildren()
|
||||
delegate?.searchController(self, didSelectSearchResult: albumItem)
|
||||
searchField.reloadPopUpRow(row)
|
||||
}
|
||||
|
||||
private var shouldDisplayNoResults: Bool {
|
||||
return searchResults.isEmpty && !searchField.stringValue.isEmpty && searchTask == nil
|
||||
}
|
||||
|
||||
public func numberOfRowsInTableView(tableView: NSTableView) -> Int {
|
||||
if shouldDisplayNoResults {
|
||||
return 1
|
||||
}
|
||||
return searchResults.count
|
||||
}
|
||||
|
||||
public func tableView(tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
|
||||
return 39
|
||||
}
|
||||
|
||||
public func tableView(tableView: NSTableView, viewForTableColumn tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||
if shouldDisplayNoResults {
|
||||
var view = tableView.makeViewWithIdentifier(Constants.NoResultsTableCellViewIdentifier, owner: nil) as? CenteredTableCellView
|
||||
if view == nil {
|
||||
view = CenteredTableCellView()
|
||||
view?.identifier = Constants.NoResultsTableCellViewIdentifier
|
||||
}
|
||||
view?.setupForNoResults()
|
||||
return view
|
||||
} else {
|
||||
var view = tableView.makeViewWithIdentifier(Constants.AlbumTableCellViewIdentifier, owner: nil) as? AlbumTableCellView
|
||||
if view == nil {
|
||||
view = AlbumTableCellView()
|
||||
view?.identifier = Constants.AlbumTableCellViewIdentifier
|
||||
}
|
||||
let searchResult = searchResults[row]
|
||||
let height = self.tableView(tableView, heightOfRow: row)
|
||||
let albumEnabled = delegate?.contentViewController.itemForEntity(searchResult) == nil
|
||||
view?.setupForAlbum(searchResult, enabled: albumEnabled, height: height)
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
||||
public func tableView(tableView: NSTableView, shouldSelectRow row: Int) -> Bool {
|
||||
if shouldDisplayNoResults {
|
||||
return false
|
||||
} else {
|
||||
return delegate?.contentViewController?.itemForEntity(searchResults[row]) == nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
71
TagTunes/SongItem.swift
Executable file
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// SongItem.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 30.05.15.
|
||||
// Copyright (c) 2015 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import SearchAPI
|
||||
|
||||
/// Represents a `Song` from the Search API.
|
||||
public class SongItem: TagTunesEntityItem {
|
||||
|
||||
/// Returns the `entity` as a `Song`.
|
||||
public var song: Song {
|
||||
return entity as! Song
|
||||
}
|
||||
|
||||
public var album: Album {
|
||||
return parentItem?.entity as! Album
|
||||
}
|
||||
|
||||
override public var saved: Bool {
|
||||
let components = NSCalendar.currentCalendar().components(.Year, fromDate: song.releaseDate)
|
||||
for track in associatedTracks {
|
||||
let trackName = Preferences.sharedPreferences.useCensoredNames ? song.censoredName : song.name
|
||||
let albumName = Preferences.sharedPreferences.useCensoredNames ? album.censoredName : album.name
|
||||
let options = Preferences.sharedPreferences.caseSensitive ? [] : NSStringCompareOptions.CaseInsensitiveSearch
|
||||
if let name = track.name where name.compare(trackName, options: options, range: nil, locale: nil) != .OrderedSame {
|
||||
return false
|
||||
}
|
||||
if let album = track.album where album.compare(albumName, options: options, range: nil, locale: nil) != .OrderedSame {
|
||||
return false
|
||||
}
|
||||
guard track.artist == song.artist.name && track.year == components.year && track.trackNumber == song.trackNumber && track.trackCount == song.trackCountOnDisc && track.discNumber == song.discNumber && track.discCount == song.discCount && track.genre == song.primaryGenre && track.albumArtist == album.artist.name && track.composer == "" else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
public override func valueForTag(tag: Tag) throws -> AnyObject! {
|
||||
let censoredNames = Preferences.sharedPreferences.useCensoredNames
|
||||
switch tag {
|
||||
case .Name: return censoredNames ? song.censoredName : song.name
|
||||
case .Artist: return censoredNames ? song.artist.censoredName : song.artist.name
|
||||
case .Year:
|
||||
let components = NSCalendar.currentCalendar().components(.Year, fromDate: song.releaseDate)
|
||||
return components.year
|
||||
case .TrackNumber: return song.trackNumber
|
||||
case .TrackCount: return song.trackCountOnDisc
|
||||
case .DiscNumber: return song.discNumber
|
||||
case .DiscCount: return song.discCount
|
||||
case .Genre: return song.primaryGenre
|
||||
case .AlbumName: return censoredNames ? album.censoredName : album.name
|
||||
case .AlbumArtist: return album.artist.name
|
||||
case .Compilation: return album.compilation
|
||||
case .ReleaseDate: return song.releaseDate
|
||||
// TODO: Artwork download error!
|
||||
case .Artwork: return album.artwork.optimalArtworkImageForSize(CGFloat.max)
|
||||
case .SortName,
|
||||
.SortArtist,
|
||||
.SortAlbumName,
|
||||
.SortAlbumArtist,
|
||||
.Composer,
|
||||
.SortComposer,
|
||||
.Comment: return nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
140
TagTunes/SongTableCellView.swift
Executable file
@@ -0,0 +1,140 @@
|
||||
//
|
||||
// TrackTableCellView.swift
|
||||
// Harmony
|
||||
//
|
||||
// Created by Kim Wittenburg on 21.01.15.
|
||||
// Copyright (c) 2015 Das Code Kollektiv. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import AppKitPlus
|
||||
|
||||
/// A table cell view to display information for a `SongItem`.
|
||||
public class SongTableCellView: AdvancedTableCellView {
|
||||
|
||||
// MARK: Types
|
||||
|
||||
private struct Images {
|
||||
|
||||
/// Caches the tick image for track cells so that it does not need to be
|
||||
/// reloaded every time a cell is configured.
|
||||
static let tickImage = NSImage(named: "Tick")?.imageByMaskingWithColor(NSColor.clearColor())
|
||||
|
||||
/// Caches the gray tick image for track cells so that it does not need to be
|
||||
/// reloaded every time a cell is configured.
|
||||
static let grayTickImage = NSImage(named: "Tick")?.imageByMaskingWithColor(NSColor.lightGrayColor())
|
||||
|
||||
}
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// An outlet do display the track number. This acts as a secondary label.
|
||||
/// The text color is automatically adjusted based on the `backgroundStyle`
|
||||
/// of the receiver.
|
||||
///
|
||||
/// Intended to be used as accessory view
|
||||
@IBOutlet public lazy var trackNumberTextField: NSTextField? = {
|
||||
let trackNumberTextField = NSTextField()
|
||||
trackNumberTextField.bordered = false
|
||||
trackNumberTextField.drawsBackground = false
|
||||
trackNumberTextField.selectable = false
|
||||
trackNumberTextField.lineBreakMode = .ByTruncatingTail
|
||||
trackNumberTextField.font = NSFont.systemFontOfSize(0)
|
||||
trackNumberTextField.textColor = NSColor.secondaryLabelColor()
|
||||
trackNumberTextField.translatesAutoresizingMaskIntoConstraints = false
|
||||
return trackNumberTextField
|
||||
}()
|
||||
|
||||
/// Intended to be used as accessory view.
|
||||
///
|
||||
/// This property replaces `imageView`.
|
||||
@IBOutlet public lazy var savedImageView: NSImageView! = {
|
||||
let secondaryImageView = NSImageView()
|
||||
secondaryImageView.imageScaling = .ScaleProportionallyDown
|
||||
secondaryImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
return secondaryImageView
|
||||
}()
|
||||
|
||||
// MARK: Intitializers
|
||||
|
||||
override public func setupView() {
|
||||
leftAccessoryView = trackNumberTextField
|
||||
super.setupView()
|
||||
}
|
||||
|
||||
// MARK: Overrides
|
||||
|
||||
override public var backgroundStyle: NSBackgroundStyle {
|
||||
get {
|
||||
return super.backgroundStyle
|
||||
}
|
||||
set {
|
||||
super.backgroundStyle = newValue
|
||||
let trackNumberCell = self.trackNumberTextField?.cell as? NSTextFieldCell
|
||||
trackNumberCell?.backgroundStyle = newValue
|
||||
|
||||
switch newValue {
|
||||
case .Light:
|
||||
trackNumberTextField?.textColor = NSColor.secondaryLabelColor()
|
||||
case .Dark:
|
||||
trackNumberTextField?.textColor = NSColor.secondarySelectedControlColor()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override public var imageView: NSImageView? {
|
||||
set {
|
||||
savedImageView = newValue
|
||||
}
|
||||
get {
|
||||
return savedImageView
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Sets up the receiver to display the specified `songItem`.
|
||||
public func setupForSongItem(songItem: SongItem) {
|
||||
style = (songItem.parentItem != nil && songItem.parentItem!.hasCommonArtist) ? .Simple : .CompactSubtitle
|
||||
|
||||
textField?.stringValue = Preferences.sharedPreferences.useCensoredNames ? songItem.song.censoredName : songItem.song.name
|
||||
if songItem.associatedTracks.isEmpty {
|
||||
textField?.textColor = NSColor.disabledControlTextColor()
|
||||
} else if songItem.associatedTracks.count > 1 || songItem.associatedTracks.first!.name?.compare(songItem.song.name, options: Preferences.sharedPreferences.caseSensitive ? [] : .CaseInsensitiveSearch, range: nil, locale: nil) != .OrderedSame {
|
||||
textField?.textColor = NSColor.redColor()
|
||||
} else {
|
||||
textField?.textColor = NSColor.controlTextColor()
|
||||
}
|
||||
secondaryTextField?.stringValue = songItem.song.artist.name
|
||||
trackNumberTextField?.stringValue = "\(songItem.song.trackNumber)"
|
||||
if songItem.associatedTracks.isEmpty {
|
||||
imageView?.image = SongTableCellView.Images.grayTickImage
|
||||
} else {
|
||||
imageView?.image = SongTableCellView.Images.tickImage
|
||||
}
|
||||
if songItem.saved {
|
||||
let aspectRatioConstraint = NSLayoutConstraint(
|
||||
item: savedImageView,
|
||||
attribute: .Width,
|
||||
relatedBy: .Equal,
|
||||
toItem: savedImageView,
|
||||
attribute: .Height,
|
||||
multiplier: 1,
|
||||
constant: 0)
|
||||
let widthConstraint = NSLayoutConstraint(
|
||||
item: savedImageView,
|
||||
attribute: .Width,
|
||||
relatedBy: .Equal,
|
||||
toItem: nil,
|
||||
attribute: .Width,
|
||||
multiplier: 1,
|
||||
constant: 17)
|
||||
setRightAccessoryView(savedImageView, withConstraints: [aspectRatioConstraint, widthConstraint])
|
||||
} else {
|
||||
rightAccessoryView = nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
26
TagTunes/String+AEKeyword.swift
Executable file
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// String+AEKeyword.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 14.03.16.
|
||||
// Copyright © 2016 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension AEKeyword {
|
||||
|
||||
/// Initializes the keyword with the specified string. This is intended to
|
||||
/// replace the C-style literal keywords.
|
||||
public init(_ string: String) {
|
||||
var result : UInt = 0
|
||||
if let data = string.dataUsingEncoding(NSMacOSRomanStringEncoding) {
|
||||
let bytes = UnsafePointer<UInt8>(data.bytes)
|
||||
for i in 0..<data.length {
|
||||
result = result << 8 + UInt(bytes[i])
|
||||
}
|
||||
}
|
||||
self.init(result)
|
||||
}
|
||||
|
||||
}
|
||||
78
TagTunes/Tag.swift
Executable file
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// Tag.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 21.01.16.
|
||||
// Copyright © 2016 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// This enum contains cases for the different tags supported by TagTunes. Tags
|
||||
/// may or may not be returned by the iTunes Store. Use the
|
||||
/// `isReturnedBySearchAPI` property to query this information.
|
||||
///
|
||||
/// The `Tag` enum supports localization for displaying the name of a tag in the
|
||||
/// user interface. Use the `localizedName` property for this purpose.
|
||||
public enum Tag: String {
|
||||
case Name = "name", Artist = "artist", Year = "year", TrackNumber = "trackNumber", TrackCount = "trackCount", DiscNumber = "discNumber", DiscCount = "discCount", Genre = "genre", AlbumName = "album", AlbumArtist = "albumArtist", ReleaseDate = "releaseDate", Compilation = "compilation"
|
||||
case Artwork = "artwork"
|
||||
case SortName = "sortName", SortArtist = "sortArtist", SortAlbumName = "sortAlbum", SortAlbumArtist = "sortAlbumArtist", Composer = "composer", SortComposer = "sortComposer", Comment = "comment"
|
||||
|
||||
/// Returns `true` for tags that are returned from the
|
||||
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
|
||||
public var isReturnedBySearchAPI: Bool {
|
||||
switch self {
|
||||
case Name, Artist, Year, TrackNumber, TrackCount, DiscNumber, DiscCount, Genre, AlbumName, AlbumArtist, Compilation, ReleaseDate:
|
||||
return true
|
||||
case Artwork:
|
||||
return true
|
||||
case SortName, SortArtist, SortAlbumName, SortAlbumArtist, Composer, SortComposer, Comment:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a `AEKeyword` associated with the tag. This code can be used to
|
||||
/// work with Apple-Event properties of the `iTunesTrack` class.
|
||||
internal var code: AEKeyword {
|
||||
let keyword: String
|
||||
switch self {
|
||||
case Name: keyword = "pnam"
|
||||
case Artist: keyword = "pArt"
|
||||
case Year: keyword = "pYr "
|
||||
case TrackNumber: keyword = "pTrN"
|
||||
case TrackCount: keyword = "pTrC"
|
||||
case DiscNumber: keyword = "pDsN"
|
||||
case DiscCount: keyword = "pDsC"
|
||||
case Genre: keyword = "pGen"
|
||||
case AlbumName: keyword = "pAlb"
|
||||
case AlbumArtist: keyword = "pAlA"
|
||||
case Compilation: keyword = "pAnt"
|
||||
case ReleaseDate: keyword = "pRlD"
|
||||
case Artwork: keyword = "cArt"
|
||||
case SortName: keyword = "pSNm"
|
||||
case SortArtist: keyword = "pSAr"
|
||||
case SortAlbumName: keyword = "pSAl"
|
||||
case SortAlbumArtist: keyword = "pSAA"
|
||||
case Composer: keyword = "pCmp"
|
||||
case SortComposer: keyword = "pSCm"
|
||||
case Comment: keyword = "pCmt"
|
||||
}
|
||||
return AEKeyword(keyword)
|
||||
}
|
||||
|
||||
/// Returns the localized name of the tag which can be displayed in the user
|
||||
/// interface.
|
||||
public var localizedName: String {
|
||||
return NSLocalizedString("Tag: \(self.rawValue)", comment: "")
|
||||
}
|
||||
|
||||
// TODO: Order of tags should not be determined here.
|
||||
/// Returns an array of all tags. The tags are sorted by whether they are
|
||||
/// returned by the
|
||||
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
|
||||
public static var allTags: [Tag] {
|
||||
return [.Name, .Artist, .Year, .TrackNumber, .TrackCount, .DiscNumber, .DiscCount, .Genre, .AlbumName, .AlbumArtist, .Compilation, .ReleaseDate, .Artwork, .SortName, .SortArtist, .SortAlbumName, .SortAlbumArtist, .Composer, .SortComposer, .Comment]
|
||||
}
|
||||
|
||||
}
|
||||
0
TagTunes/TagTunes-Bridging-Header.h
Normal file → Executable file
345
TagTunes/TagTunesItem.swift
Executable file
@@ -0,0 +1,345 @@
|
||||
//
|
||||
// TagTunesItem.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 21.01.16.
|
||||
// Copyright © 2016 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import SearchAPI
|
||||
|
||||
/// This enum holds errors that may occur during saving.
|
||||
public enum SaveError: ErrorType {
|
||||
|
||||
/// This error indicates that the artwork could not be downloaded.
|
||||
case ArtworkDownloadFailed
|
||||
|
||||
/// This constant indicates that there was probably a error downloading the
|
||||
/// artwork. This is judged by the artwork's resolution.
|
||||
case LowResArtwork
|
||||
|
||||
}
|
||||
|
||||
/// A `TagTunesItem` (*item* for short) represents a `SearchAPIEntity` in the
|
||||
/// TagTunes application. There are two classes that conform to this protocol:
|
||||
/// `TagTunesEntityItem` (*entity*) and `TagTunesGroupItem` (*group*). The
|
||||
/// general structure is as follows:
|
||||
///
|
||||
/// - A group contains zero or more entities.
|
||||
/// - An entity can be contained in a group (but doesn't have to).
|
||||
/// - An entity contains zero or more associated tracks.
|
||||
public protocol TagTunesItem: class {
|
||||
|
||||
/// The `SearchAPIEntity` represented by the item. The `entity` should not
|
||||
/// change after initialization.
|
||||
var entity: SearchAPIEntity { get }
|
||||
|
||||
/// The `TagTunesTrack`s associated with the item. For groups this is the
|
||||
/// union of the `associatedTracks` of its children.
|
||||
var associatedTracks: Set<TagTunesTrack> { get }
|
||||
|
||||
/// Adds the specified `tracks` to the item's `associatedTracks`. For goups
|
||||
/// this sorts the `tracks` into its children. How the tracks are sorted is
|
||||
/// determined by the concrete group.
|
||||
///
|
||||
/// - parameters:
|
||||
/// - tracks: The `TagTunesTrack`s to be associated with the item.
|
||||
///
|
||||
/// - returns: For groups: The tracks that couldn't be sorted.
|
||||
/// For entities: An empty `Set`.
|
||||
func addAssociatedTracks<S: SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S) -> Set<TagTunesTrack>
|
||||
|
||||
/// Removes all `associatedTracks` from the item.
|
||||
func clearAssociatedTracks()
|
||||
|
||||
/// Returns whether the item's `associatedTracks` already contain up-to-date
|
||||
/// tags. This value should respect the user's `Preferences`.
|
||||
var saved: Bool { get }
|
||||
|
||||
}
|
||||
|
||||
public func ==(lhs: TagTunesItem, rhs: TagTunesItem) -> Bool {
|
||||
return lhs.entity == rhs.entity
|
||||
}
|
||||
|
||||
/// Represents an entity item. An entity item directly corresponds to a file (for
|
||||
/// example a song, a movie, or a podcast episode). An entity may have a parent.
|
||||
/// An entity's parent does not usually correspond to a file but rather is an
|
||||
/// abstract grouping criterion (for example a album or a podcast).
|
||||
///
|
||||
/// `TagTunesEntityItem` is an abstract class that can not be initialized
|
||||
/// directly. Instead one of the concrete subclasses (like `SongItem`) should be
|
||||
/// used.
|
||||
public class TagTunesEntityItem: TagTunesItem {
|
||||
|
||||
public let entity: SearchAPIEntity
|
||||
|
||||
/// The entity's parent. The parent may change if the entity is added to a
|
||||
/// different group. The parent is nil for entities that do not belong to a
|
||||
/// group (such as movies or apps).
|
||||
public weak var parentItem: TagTunesGroupItem?
|
||||
|
||||
public var associatedTracks = Set<TagTunesTrack>() {
|
||||
willSet {
|
||||
for track in associatedTracks {
|
||||
track.entity = nil
|
||||
}
|
||||
}
|
||||
didSet {
|
||||
for track in associatedTracks {
|
||||
track.entity = self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes a new `TagTunesEntityItem` with the specified `entity`.
|
||||
///
|
||||
/// The class `TagTunesEntityItem` can not be initialized directly. Instead
|
||||
/// use one of the concrete subclasses like `SongItem`.
|
||||
public init(entity: SearchAPIEntity) {
|
||||
self.entity = entity
|
||||
assert(self.dynamicType != TagTunesEntityItem.self, "TagTunesEntityItem must not be initialized directly.")
|
||||
}
|
||||
|
||||
/// Initializes a new `TagTunesEntityItem` with the specified `entity` and
|
||||
/// `parentItem`. The newly initialized entity is **not** added to the
|
||||
/// `parentItem` automatically.
|
||||
///
|
||||
/// The class `TagTunesEntityItem` can not be initialized directly. Instead
|
||||
/// use one of the concrete subclasses like `SongItem`.
|
||||
public convenience init(entity: SearchAPIEntity, parentItem: TagTunesGroupItem?) {
|
||||
self.init(entity: entity)
|
||||
self.parentItem = parentItem
|
||||
}
|
||||
|
||||
public func addAssociatedTracks<S: SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S) -> Set<TagTunesTrack> {
|
||||
associatedTracks.unionInPlace(tracks)
|
||||
return []
|
||||
}
|
||||
|
||||
public func clearAssociatedTracks() {
|
||||
associatedTracks.removeAll()
|
||||
}
|
||||
|
||||
public var saved: Bool {
|
||||
fatalError("Must override property saved.")
|
||||
}
|
||||
|
||||
/// Returns the value for the specified `tag`. This method should **not**
|
||||
/// act depending on the user's preferences.
|
||||
///
|
||||
/// - returns: The value for the specified `tag` or `nil` if such a value
|
||||
/// does not exist.
|
||||
///
|
||||
/// - throws: Errors that occur when reading the tag. This may for example
|
||||
/// happen if an artwork can not be downloaded.
|
||||
public func valueForTag(tag: Tag) throws -> AnyObject! {
|
||||
fatalError("Must override valueForTag(_:)")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Represents a group item. A group item is the parent of zero or more entities.
|
||||
///
|
||||
/// A group usually does not directly correspond to a specific file but rather is
|
||||
/// an abstract grouping concept. A group would for example be an album (see
|
||||
/// class `AlbumItem`) with its children being the songs stored on disk.
|
||||
///
|
||||
/// Since groups can have children associated with them it may be neccessary to
|
||||
/// load those children sometime after the group has been initialized. The
|
||||
/// `TagTunesGroupItem` class offers the method `beginLoadingChildren()` to deal
|
||||
/// with those cases. See the documentation on that method for details.
|
||||
///
|
||||
/// `TagTunesGroupItem` is an abstract class that can not be initialized
|
||||
/// directly. Instead one of the concrete subclasses (like `AlbumItem`) should be
|
||||
/// used.
|
||||
public class TagTunesGroupItem: TagTunesItem {
|
||||
|
||||
/// This notification is posted by `TagTunesGroupItem`s when their state
|
||||
/// changes. Listening for this notification is the recommended way to react
|
||||
/// to groups starting or finishing loading their children.
|
||||
///
|
||||
/// The `object` associated with the respective notification is the group
|
||||
/// item whose state changed.
|
||||
public static let StateChangedNotificationName = "TagTunesGroupItemStateChangedNotificationName"
|
||||
|
||||
/// The state of a group item.
|
||||
public enum LoadingState {
|
||||
|
||||
/// The group is currently not active. This state is set before and after
|
||||
/// a group loads its children.
|
||||
case Normal
|
||||
|
||||
/// The group is currently loading its children.
|
||||
case Loading(NSURLSessionTask)
|
||||
|
||||
/// This state is basically equivalent to the `Normal` state except that
|
||||
/// indicates that the previous attempt to load the item's children
|
||||
/// failed.
|
||||
case Error(ErrorType)
|
||||
}
|
||||
|
||||
/// The URL session used to load children for groups.
|
||||
private static let urlSession = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration(), delegate: nil, delegateQueue: NSOperationQueue.mainQueue())
|
||||
|
||||
public let entity: SearchAPIEntity
|
||||
|
||||
/// The group's children. It is recommended that subclasses install a
|
||||
/// property observer on this property and sort the children appropriately.
|
||||
public internal(set) var children = [TagTunesEntityItem]()
|
||||
|
||||
/// The state of the group. This state indicates whether the group is
|
||||
/// currently loading its children or not. See `LoadingState` for details.
|
||||
public private(set) var loadingState: LoadingState = .Normal {
|
||||
didSet {
|
||||
NSNotificationCenter.defaultCenter().postNotificationName(TagTunesGroupItem.StateChangedNotificationName, object: self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new group representing the specified `entity`.
|
||||
///
|
||||
/// The class `TagTunesGroupItem` can not be initialized directly. Instead
|
||||
/// use one of the concrete subclasses like `AlbumItem`.
|
||||
public init(entity: SearchAPIEntity) {
|
||||
self.entity = entity
|
||||
assert(self.dynamicType != TagTunesGroupItem.self, "TagTunesGroupItem must not be initialized directly.")
|
||||
}
|
||||
|
||||
public var associatedTracks: Set<TagTunesTrack> {
|
||||
return children.reduce(Set()) { $0.union($1.associatedTracks) }
|
||||
}
|
||||
|
||||
public func addAssociatedTracks<S : SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S) -> Set<TagTunesTrack> {
|
||||
fatalError("Must override addAssociatedTracks(_:)")
|
||||
}
|
||||
|
||||
/// Sorts the specified `track` into the children of the group. If there is
|
||||
/// no child representing the specified `childEntity` a new one is created.
|
||||
///
|
||||
/// In contrast to the `addAssociatedTracks(_:)` method this one can be
|
||||
/// called before the group has loaded its children. Tracks associated by
|
||||
/// this method are preserved when a group loads its children.
|
||||
///
|
||||
/// This method is especially useful if you want to associate the specified
|
||||
/// `track` with the `childEntity` but it is not clear whether the group did
|
||||
/// already load its children.
|
||||
///
|
||||
/// This method must be implemented by subclasses.
|
||||
public func addAssociatedTrack(track: TagTunesTrack, forChildEntity childEntity: SearchAPIEntity) {
|
||||
fatalError("Must override addAssociatedTrack:forChildEntity:")
|
||||
}
|
||||
|
||||
public func clearAssociatedTracks() {
|
||||
for child in children {
|
||||
child.clearAssociatedTracks()
|
||||
}
|
||||
}
|
||||
|
||||
/// Determines whether all children have the same artist as the group itself.
|
||||
///
|
||||
/// This must be implemented by subclasses.
|
||||
public var hasCommonArtist: Bool {
|
||||
fatalError("Must override property hasCommonArtist")
|
||||
}
|
||||
|
||||
/// Starts a network request to load the group's children. This method
|
||||
/// returns immediately.
|
||||
///
|
||||
/// It is possible to monitor the loading of children through the group's
|
||||
/// `loadingState`. Whenever that state changes a
|
||||
/// `TagTunesGroupItem.StateChangedNotificationName` notification is posted
|
||||
/// with the respective group as the notification's `object`.
|
||||
///
|
||||
/// Normally this method should only be invoked once per group. A second
|
||||
/// invocation will still send the network request but the group will not
|
||||
/// change because its children are already loaded. The only exception to
|
||||
/// this rule is if the first request failed (as determined by the
|
||||
/// `LoadingState.Error` case). In that case you can *retry* by invoking this
|
||||
/// method again.
|
||||
public func beginLoadingChildren() {
|
||||
if case .Loading = loadingState {
|
||||
return
|
||||
}
|
||||
var request = SearchAPIRequest(lookupRequestWithID: entity.id)
|
||||
request.entity = self.dynamicType.childEntityType
|
||||
request.country = Preferences.sharedPreferences.iTunesStore
|
||||
if Preferences.sharedPreferences.useEnglishTags {
|
||||
request.language = .English
|
||||
}
|
||||
request.maximumNumberOfResults = 200
|
||||
let task = TagTunesGroupItem.urlSession.dataTaskWithURL(request.URL) { (data, response, error) -> Void in
|
||||
if let theError = error {
|
||||
self.loadingState = .Error(theError)
|
||||
return
|
||||
}
|
||||
do {
|
||||
let result = try SearchAPIResult(data: data!)
|
||||
self.processLookupResult(result)
|
||||
self.loadingState = .Normal
|
||||
} catch let error as NSError {
|
||||
self.loadingState = .Error(error)
|
||||
}
|
||||
}
|
||||
loadingState = .Loading(task)
|
||||
task.resume()
|
||||
}
|
||||
|
||||
/// Returns the `EntityType` of the group's children. This is used to
|
||||
/// construct an appropriate lookup request.
|
||||
///
|
||||
/// This property must be overridden by subclasses.
|
||||
internal class var childEntityType: SearchAPIRequest.EntityType {
|
||||
fatalError("Must override property childEntityType")
|
||||
}
|
||||
|
||||
/// Called when the group did finish loading its children. Subclasses must
|
||||
/// implement this method and should modify the `children` property to
|
||||
/// represent the `result` of the lookup.
|
||||
internal func processLookupResult(result: SearchAPIResult) {
|
||||
fatalError("Must override processLookupResult(_:)")
|
||||
}
|
||||
|
||||
/// Immediately stops loading the group's children and sets the
|
||||
/// `loadingState` to `Error(NSUserCancelledError)`.
|
||||
public func cancelLoadingChildren() {
|
||||
if case let .Loading(task) = loadingState {
|
||||
task.cancel()
|
||||
}
|
||||
let error = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil)
|
||||
loadingState = .Error(error)
|
||||
}
|
||||
|
||||
public var saved: Bool {
|
||||
for child in children {
|
||||
if !child.saved {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension TagTunesEntityItem: Hashable {
|
||||
|
||||
public var hashValue: Int {
|
||||
return Int(entity.id)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension TagTunesGroupItem: Hashable {
|
||||
|
||||
public var hashValue: Int {
|
||||
return Int(entity.id)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public func ==(lhs: TagTunesEntityItem, rhs: TagTunesEntityItem) -> Bool {
|
||||
return lhs.entity.id == rhs.entity.id
|
||||
}
|
||||
|
||||
public func ==(lhs: TagTunesGroupItem, rhs: TagTunesGroupItem) -> Bool {
|
||||
return lhs.entity.id == rhs.entity.id
|
||||
}
|
||||
286
TagTunes/TagTunesTrack.swift
Executable file
@@ -0,0 +1,286 @@
|
||||
//
|
||||
// Track.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 17.03.16.
|
||||
// Copyright © 2016 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import SearchAPI
|
||||
|
||||
// TODO: Documentation
|
||||
public enum TagTunesTrackErrors: ErrorType {
|
||||
|
||||
case FileNotFound
|
||||
case FileNotReadable
|
||||
case NoIDFound
|
||||
|
||||
}
|
||||
|
||||
/// Represents a track in TagTunes. A track is directly related to a file. There
|
||||
/// may be different subclasses of this class representing different kinds of
|
||||
/// tracks (for example mp3, aac, ...).
|
||||
///
|
||||
/// `TagTunesTrack` should be regarded as a protocol. Due to Swift's limitation
|
||||
/// with associated types this is currently not possible. Nevertheless every the
|
||||
/// class `TagTunesTrack` can not be used by itself.
|
||||
public class TagTunesTrack: NSObject, NSSecureCoding {
|
||||
|
||||
/// The entity containing the track.
|
||||
public internal(set) weak var entity: TagTunesEntityItem?
|
||||
|
||||
/// The track's lookup state. By default the state is `Unprocessed`. The
|
||||
/// state is changed by the lookup controller and may be changed on any
|
||||
/// thread.
|
||||
public internal(set) var lookupState = LookupState.Unprocessed
|
||||
|
||||
// TODO: Change DOcumentation
|
||||
/// Returns the `id` of the track. The id can be used to query the track on
|
||||
/// the iTunes Store. If no such id exists, `nil` is returned.
|
||||
///
|
||||
/// This property may block the main thread until the id could be fetched.
|
||||
/// Subclasses may choose to use the `NSProgress` mechanism to report the
|
||||
/// progress of the fetching of the id. If possible the id should be cached
|
||||
/// to minimize waiting time for the user.
|
||||
///
|
||||
/// This property must be overridden by subclasses.
|
||||
public private(set) var id: SearchAPIID?
|
||||
|
||||
// TODO: Documentation
|
||||
public func updateTrackID() {
|
||||
do {
|
||||
try id = readTrackID()
|
||||
} catch {
|
||||
lookupState = LookupState.Unqualified(error)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Documentation
|
||||
public func readTrackID() throws -> SearchAPIID {
|
||||
fatalError("Must override readTrackID()")
|
||||
}
|
||||
|
||||
/// Initializes the track. This is the designated initializer of
|
||||
/// `TagTunesTrack`. Also it is the only initializer that does not crash the
|
||||
/// programm.
|
||||
public override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// Decodes the track. This method must be implemented by subclasses.
|
||||
/// Subclasses must not call this method on `super` in their implementation.
|
||||
/// Instead subclasses should use `super.init()`.
|
||||
@objc public required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("Must override init(coder:)")
|
||||
}
|
||||
|
||||
/// Encoded the track. This method must be implemented by subclasses.
|
||||
@objc public func encodeWithCoder(aCoder: NSCoder) {
|
||||
fatalError("Must override encodeWithCoder(_:)")
|
||||
}
|
||||
|
||||
@objc public static func supportsSecureCoding() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
/// Per-tag querying. This method must be implemented by subclasses. It is
|
||||
/// generally not a good idea to return `nil` from this method.
|
||||
public func valueForTag(tag: Tag) -> AnyObject! {
|
||||
fatalError("Must override valueForTag(_:)")
|
||||
}
|
||||
|
||||
/// Reveals the track. Normally this launches another application in which to
|
||||
/// reveal the track. Which application is launched depends on the actual
|
||||
/// track.
|
||||
///
|
||||
/// This method must be implemented by subclasses.
|
||||
public func reveal() {
|
||||
fatalError("Must override reveal()")
|
||||
}
|
||||
|
||||
// TODO: Documentation
|
||||
public var supportsBatchSaving: Bool {
|
||||
fatalError("Must override property supportsBatchSaving")
|
||||
}
|
||||
|
||||
// TODO: Remove
|
||||
/// Saves the track using the data from the specified `entity`. Subclasses
|
||||
/// may use the `NSProgress` mechanism to report the saving process.
|
||||
///
|
||||
/// This method may be overridden by subclasses. It should respect the user's
|
||||
/// preferences. The default implementation invokes `saveTag(_:value:)` for
|
||||
/// each tag to be saved.
|
||||
///
|
||||
/// - returns: `true` if the `tags` could be saved successfully, `false`
|
||||
/// otherwise. If `false` is returned the `saveErrors` property
|
||||
/// should contain information about the errors that occured. If
|
||||
/// `true` is returned `saveErrors` should be empty.
|
||||
// public func save(entity: TagTunesEntityItem) -> Bool {
|
||||
// var success = true
|
||||
// saveErrors = []
|
||||
// let progress = NSProgress(totalUnitCount: Int64(Tag.allTags.count))
|
||||
// for tag in Tag.allTags {
|
||||
// do {
|
||||
// switch Preferences.sharedPreferences.tagSavingBehaviors[tag]! {
|
||||
// case .Save: try success = success && saveTag(tag, value: entity.valueForTag(tag))
|
||||
// case .Clear: success = success && saveTag(tag, value: nil)
|
||||
// case .Ignore: break
|
||||
// }
|
||||
// } catch let error {
|
||||
// saveErrors.append(error)
|
||||
// }
|
||||
// dispatch_sync(dispatch_get_main_queue()) {
|
||||
// progress.completedUnitCount += 1
|
||||
// }
|
||||
// }
|
||||
// return success
|
||||
// }
|
||||
|
||||
// TODO: Documentation
|
||||
public func saveTags(tags: [Tag: AnyObject?]) throws {
|
||||
fatalError("Must override saveTags(_:)")
|
||||
}
|
||||
|
||||
// TODO: throws documentation
|
||||
/// Saves the specified `value` for the specified `tag`. This method should
|
||||
/// not act depending on the user's `Preferences`.
|
||||
///
|
||||
/// This method must be overridden by subclasses.
|
||||
///
|
||||
/// - parameters:
|
||||
/// - value: The value to be saved. If this value is `nil` the value for
|
||||
/// the specified `tag` should be removed from the track.
|
||||
/// - tag: The `Tag` to be saved.
|
||||
public func saveValue(value: AnyObject?, forTag tag: Tag) throws {
|
||||
fatalError("Must override saveTag(_:value:)")
|
||||
}
|
||||
|
||||
public override var hashValue: Int {
|
||||
fatalError("Must override property hashValue.")
|
||||
}
|
||||
|
||||
public override func isEqual(object: AnyObject?) -> Bool {
|
||||
if let other = object as? TagTunesTrack {
|
||||
return self == other
|
||||
}
|
||||
return super.isEqual(object)
|
||||
}
|
||||
|
||||
public override var hash: Int {
|
||||
return hashValue
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public func ==(lhs: TagTunesTrack, rhs: TagTunesTrack) -> Bool {
|
||||
if let leftTrack = lhs as? ImportedTrack, rightTrack = rhs as? ImportedTrack {
|
||||
return leftTrack == rightTrack
|
||||
}
|
||||
return lhs === rhs
|
||||
}
|
||||
|
||||
public extension TagTunesTrack {
|
||||
|
||||
/// The track's name.
|
||||
public var name: String? {
|
||||
return valueForTag(.Name) as? String
|
||||
}
|
||||
|
||||
/// The track's artist.
|
||||
public var artist: String? {
|
||||
return valueForTag(.Artist) as? String
|
||||
}
|
||||
|
||||
/// The track's year.
|
||||
public var year: Int? {
|
||||
return valueForTag(.Year) as? Int
|
||||
}
|
||||
|
||||
/// The track's track number.
|
||||
public var trackNumber: Int? {
|
||||
return valueForTag(.TrackNumber) as? Int
|
||||
}
|
||||
|
||||
/// The track's track count. The track count is respective to the track's
|
||||
/// `discNumber`.
|
||||
public var trackCount: Int? {
|
||||
return valueForTag(.TrackCount) as? Int
|
||||
}
|
||||
|
||||
/// The track's disc number.
|
||||
public var discNumber: Int? {
|
||||
return valueForTag(.DiscNumber) as? Int
|
||||
}
|
||||
|
||||
/// The track's disc count.
|
||||
public var discCount: Int? {
|
||||
return valueForTag(.DiscCount) as? Int
|
||||
}
|
||||
|
||||
/// The track's genre.
|
||||
public var genre: String? {
|
||||
return valueForTag(.Genre) as? String
|
||||
}
|
||||
|
||||
/// The track's album.
|
||||
public var album: String? {
|
||||
return valueForTag(.AlbumName) as? String
|
||||
}
|
||||
|
||||
/// The track's album artist.
|
||||
public var albumArtist: String? {
|
||||
return valueForTag(.AlbumArtist) as? String
|
||||
}
|
||||
|
||||
/// The track's release date.
|
||||
public var releaseDate: NSDate? {
|
||||
return valueForTag(.ReleaseDate) as? NSDate
|
||||
}
|
||||
|
||||
/// A boolean value indicating whether the track belongs to a compilation
|
||||
/// album.
|
||||
public var compilation: Bool? {
|
||||
return valueForTag(.Compilation) as? Bool
|
||||
}
|
||||
|
||||
/// The track's artwork.
|
||||
public var artwork: NSImage? {
|
||||
return valueForTag(.Artwork) as? NSImage
|
||||
}
|
||||
|
||||
/// The track's sort name.
|
||||
public var sortName: String? {
|
||||
return valueForTag(.SortName) as? String
|
||||
}
|
||||
|
||||
/// The track's sort artist.
|
||||
public var sortArtist: String? {
|
||||
return valueForTag(.SortArtist) as? String
|
||||
}
|
||||
|
||||
/// The track's sort album.
|
||||
public var sortAlbum: String? {
|
||||
return valueForTag(.SortAlbumName) as? String
|
||||
}
|
||||
|
||||
/// The track's sort album artist.
|
||||
public var sortAlbumArtist: String? {
|
||||
return valueForTag(.SortAlbumArtist) as? String
|
||||
}
|
||||
|
||||
/// The track's composer.
|
||||
public var composer: String? {
|
||||
return valueForTag(.Composer) as? String
|
||||
}
|
||||
|
||||
/// The track's sort composer.
|
||||
public var sortComposer: String? {
|
||||
return valueForTag(.SortComposer) as? String
|
||||
}
|
||||
|
||||
/// The track's comment.
|
||||
public var comment: String? {
|
||||
return valueForTag(.Comment) as? String
|
||||
}
|
||||
|
||||
}
|
||||
31
TagTunes/UnsortedTracksController.swift
Executable file
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// UnsortedTracksController.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 07.03.16.
|
||||
// Copyright © 2016 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
/// A unsorted tracks controller manages the unsorted tracks. It is recommended
|
||||
/// that the controller offers a interface for adding unsorted tracks and for
|
||||
/// looking up tracks using the app's `LookupController`.
|
||||
public protocol UnsortedTracksController: class {
|
||||
|
||||
/// The visibility of the unsorted tracks controller. Setting this property
|
||||
/// should show/hide the controller but there's no guarantee that that will
|
||||
/// actually happen.
|
||||
var visible: Bool { get set }
|
||||
|
||||
/// Adds the specified `tracks` to the controller. This method should filter
|
||||
/// out tracks that were already added to TagTunes.
|
||||
func addTracks<S: SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S)
|
||||
|
||||
/// Returns the specified `items` from a drag and drop operation to the
|
||||
/// controller. If this method is called outside a drag and drop operation
|
||||
/// or the drag did not originate from the unsorted tracks controller this
|
||||
/// method should just invoke `addTracks(_:)`.
|
||||
func returnDraggedItems<S: SequenceType where S.Generator.Element == NSPasteboardItem>(items: S)
|
||||
|
||||
}
|
||||
265
TagTunes/UnsortedTracksViewController.swift
Executable file
@@ -0,0 +1,265 @@
|
||||
//
|
||||
// UnsortedTracksViewController.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 04.03.16.
|
||||
// Copyright © 2016 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
class UnsortedTracksViewController: NSViewController, UnsortedTracksController, NSTableViewDataSource, NSTableViewDelegate {
|
||||
|
||||
var unsortedTracks = [TagTunesTrack]()
|
||||
|
||||
/// The `NSPasteboardItem`s returned via the ´returnDraggedItems(_:)` method.
|
||||
/// This property is `nil` unless there is currently a dragging session with
|
||||
/// `tableView` as its source.
|
||||
private var returnedDraggedItems: Set<NSPasteboardItem>?
|
||||
|
||||
@IBOutlet private weak var tableView: NSTableView!
|
||||
|
||||
// MARK: View Controller Life Cycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
NSApp.unsortedTracksController = self
|
||||
|
||||
tableView.registerForDraggedTypes([TrackPboardType])
|
||||
}
|
||||
|
||||
var visible: Bool {
|
||||
set {
|
||||
if newValue {
|
||||
view.window?.windowController?.showWindow(self)
|
||||
} else {
|
||||
view.window?.performClose(self)
|
||||
}
|
||||
}
|
||||
get {
|
||||
return view.window?.visible ?? false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
/// Adds the current iTunes selection to the unsorted tracks. If iTunes is
|
||||
/// currently not running a alert message is displayed.
|
||||
@IBAction func addITunesSelection(sender: AnyObject?) {
|
||||
if !iTunes.running {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = NSLocalizedString("iTunes is not running.", comment: "Error message informing the user that iTunes is not currently running.")
|
||||
alert.informativeText = NSLocalizedString("Please launch iTunes and try again.", comment: "Informative text for the 'iTunes is not running.' error")
|
||||
alert.addButtonWithTitle(NSLocalizedString("OK", comment: "Button title"))
|
||||
alert.beginSheetModalForWindow(view.window!, completionHandler: nil)
|
||||
} else if let selection = iTunes.selection.get() as? [iTunesTrack] {
|
||||
addTracks(selection.map(ImportedTrack.init))
|
||||
}
|
||||
}
|
||||
|
||||
func addTracks<S : SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S) {
|
||||
let newTracks = Array(tracks)
|
||||
unsortedTracks.appendContentsOf(newTracks)
|
||||
let newIndexes = NSIndexSet(indexesInRange: NSRange(location: unsortedTracks.count, length: newTracks.count))
|
||||
tableView.insertRowsAtIndexes(newIndexes, withAnimation: .SlideDown)
|
||||
}
|
||||
|
||||
func returnDraggedItems<S : SequenceType where S.Generator.Element == NSPasteboardItem>(items: S) {
|
||||
if returnedDraggedItems != nil {
|
||||
returnedDraggedItems!.unionInPlace(items)
|
||||
} else {
|
||||
addTracks(items.flatMap { $0.dataForType(TrackPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as? TagTunesTrack })
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the selected items from the unsorted tracks list.
|
||||
@IBAction func delete(sender: AnyObject?) {
|
||||
tableView.beginUpdates()
|
||||
let indexes = tableView.selectedRowIndexes
|
||||
var index = indexes.lastIndex
|
||||
repeat {
|
||||
unsortedTracks.removeAtIndex(index)
|
||||
tableView.removeRowsAtIndexes(NSIndexSet(index: index), withAnimation: .SlideDown)
|
||||
index = indexes.indexLessThanIndex(index)
|
||||
} while index != NSNotFound
|
||||
tableView.endUpdates()
|
||||
}
|
||||
|
||||
/// Reveals the clicked track in iTunes.
|
||||
@IBAction func revealInITunes(sender: AnyObject?) {
|
||||
guard tableView.clickedRow >= 0 else {
|
||||
return
|
||||
}
|
||||
let item = unsortedTracks[tableView.clickedRow]
|
||||
item.reveal()
|
||||
iTunes.activate()
|
||||
}
|
||||
|
||||
/// Enqueues the clicked tracks for lookup using the app's
|
||||
/// `LookupController`.
|
||||
@IBAction func lookupOnITunesMatch(sender: AnyObject?) {
|
||||
if tableView.selectedRowIndexes.containsIndex(tableView.clickedRow) {
|
||||
let indexes = Array(tableView.selectedRowIndexes).sort(>)
|
||||
let tracks = tableView.selectedRowIndexes.map { unsortedTracks[$0] }
|
||||
LookupQueue.globalQueue.enqueueTracksForLookup(tracks)
|
||||
for index in indexes {
|
||||
unsortedTracks.removeAtIndex(index)
|
||||
}
|
||||
tableView.removeRowsAtIndexes(tableView.selectedRowIndexes, withAnimation: .SlideLeft)
|
||||
} else if tableView.clickedRow >= 0 {
|
||||
LookupQueue.globalQueue.enqueueTracksForLookup([unsortedTracks[tableView.clickedRow]])
|
||||
unsortedTracks.removeAtIndex(tableView.clickedRow)
|
||||
tableView.removeRowsAtIndexes(NSIndexSet(index: tableView.clickedRow), withAnimation: .SlideLeft)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Table View
|
||||
|
||||
func numberOfRowsInTableView(tableView: NSTableView) -> Int {
|
||||
return unsortedTracks.count
|
||||
}
|
||||
|
||||
func tableView(tableView: NSTableView, viewForTableColumn tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||
let view = tableView.makeViewWithIdentifier("Cell", owner: nil) as? NSTableCellView
|
||||
let nameFont = NSFont.labelFontOfSize(13)
|
||||
if let name = unsortedTracks[row].name {
|
||||
view?.textField?.stringValue = name
|
||||
view?.textField?.textColor = NSColor.textColor()
|
||||
view?.textField?.font = nameFont
|
||||
} else {
|
||||
let italicFont = NSFontManager.sharedFontManager().convertFont(nameFont, toHaveTrait: .ItalicFontMask)
|
||||
view?.textField?.stringValue = NSLocalizedString("Unnamed Track", comment: "Default name for a track that has no name associated with it.")
|
||||
view?.textField?.textColor = NSColor.disabledControlTextColor()
|
||||
view?.textField?.font = italicFont
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func tableView(tableView: NSTableView, rowActionsForRow row: Int, edge: NSTableRowActionEdge) -> [NSTableViewRowAction] {
|
||||
if edge == .Trailing {
|
||||
return [NSTableViewRowAction(style: .Destructive, title: NSLocalizedString("Remove", comment: "Action title for row in 'Unsorted Tracks' table view.")) { action, index in
|
||||
self.unsortedTracks.removeAtIndex(index)
|
||||
self.tableView.removeRowsAtIndexes(NSIndexSet(index: index), withAnimation: .SlideUp)
|
||||
}
|
||||
]
|
||||
} else {
|
||||
return [NSTableViewRowAction(style: .Regular, title: NSLocalizedString("Lookup on iTunes Match", comment: "Action title for row in 'Unsorted Tracks' table view.")) {action, index in
|
||||
LookupQueue.globalQueue.enqueueTracksForLookup([self.unsortedTracks[index]])
|
||||
self.unsortedTracks.removeAtIndex(index)
|
||||
self.tableView.removeRowsAtIndexes(NSIndexSet(index: index), withAnimation: .SlideRight)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Drag and Drop
|
||||
|
||||
func tableView(tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
|
||||
let pasteboardItem = NSPasteboardItem()
|
||||
pasteboardItem.setData(NSKeyedArchiver.archivedDataWithRootObject(unsortedTracks[row]), forType: TrackPboardType)
|
||||
pasteboardItem.setData(NSKeyedArchiver.archivedDataWithRootObject(row), forType: IndexPboardType)
|
||||
return pasteboardItem
|
||||
}
|
||||
|
||||
func tableView(tableView: NSTableView, draggingSession session: NSDraggingSession, willBeginAtPoint screenPoint: NSPoint, forRowIndexes rowIndexes: NSIndexSet) {
|
||||
returnedDraggedItems = []
|
||||
tableView.hideRowsAtIndexes(rowIndexes, withAnimation: .EffectFade)
|
||||
}
|
||||
|
||||
func tableView(tableView: NSTableView, draggingSession session: NSDraggingSession, endedAtPoint screenPoint: NSPoint, operation: NSDragOperation) {
|
||||
if let pointInWindow = view.window?.convertRectFromScreen(NSRect(origin: screenPoint, size: NSSize.zero)).origin {
|
||||
let pointInView = view.window!.contentView!.convertPoint(pointInWindow, fromView: nil)
|
||||
let targetView = view.window!.contentView!.hitTest(pointInView)
|
||||
if targetView?.isDescendantOf(tableView) ?? false {
|
||||
return
|
||||
}
|
||||
}
|
||||
if operation == .None {
|
||||
tableView.unhideRowsAtIndexes(tableView.hiddenRowIndexes, withAnimation: .EffectFade)
|
||||
} else if let draggedItems = session.draggingPasteboard.pasteboardItems {
|
||||
let draggedIndexes = draggedItems.map { ($0.dataForType(IndexPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as! Int, $0) }.sort { $0.0 > $1.0 }
|
||||
tableView.beginUpdates()
|
||||
for (index, item) in draggedIndexes {
|
||||
if returnedDraggedItems!.contains(item) {
|
||||
tableView.unhideRowsAtIndexes(NSIndexSet(index: index), withAnimation: .EffectGap)
|
||||
} else {
|
||||
unsortedTracks.removeAtIndex(index)
|
||||
tableView.removeRowsAtIndexes(NSIndexSet(index: index), withAnimation: .EffectNone)
|
||||
}
|
||||
}
|
||||
tableView.endUpdates()
|
||||
}
|
||||
returnedDraggedItems = nil
|
||||
}
|
||||
|
||||
func tableView(tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableViewDropOperation) -> NSDragOperation {
|
||||
guard info.draggingPasteboard().canReadItemWithDataConformingToTypes([TrackPboardType]) else {
|
||||
return .None
|
||||
}
|
||||
tableView.setDropRow(row, dropOperation: .Above)
|
||||
return .Move
|
||||
}
|
||||
|
||||
func tableView(tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableViewDropOperation) -> Bool {
|
||||
guard let draggedItems = info.draggingPasteboard().pasteboardItems else {
|
||||
return false
|
||||
}
|
||||
let draggedTracks = draggedItems.map { (index: $0.dataForType(IndexPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as? Int, track: $0.dataForType(TrackPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as? TagTunesTrack) }
|
||||
|
||||
tableView.beginUpdates()
|
||||
var insertionIndex = row
|
||||
if info.draggingSource() === tableView {
|
||||
let sortedTracks = draggedTracks.sort { $0.index > $1.index }
|
||||
for (index, _) in sortedTracks {
|
||||
unsortedTracks.removeAtIndex(index!)
|
||||
tableView.removeRowsAtIndexes(NSIndexSet(index: index!), withAnimation: .EffectNone)
|
||||
if insertionIndex > index! {
|
||||
insertionIndex -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var validDrop = false
|
||||
for (_, track) in draggedTracks where track != nil {
|
||||
unsortedTracks.insert(track!, atIndex: insertionIndex)
|
||||
tableView.insertRowsAtIndexes(NSIndexSet(index: insertionIndex), withAnimation: .EffectGap)
|
||||
insertionIndex += 1
|
||||
validDrop = true
|
||||
}
|
||||
tableView.endUpdates()
|
||||
return validDrop
|
||||
}
|
||||
}
|
||||
|
||||
extension UnsortedTracksViewController: NSUserInterfaceValidations {
|
||||
|
||||
func validateUserInterfaceItem(anItem: NSValidatedUserInterfaceItem) -> Bool {
|
||||
if anItem.action() == #selector(UnsortedTracksViewController.addITunesSelection(_:)) {
|
||||
return canAddITunesSelection()
|
||||
} else if anItem.action() == #selector(UnsortedTracksViewController.delete(_:)) {
|
||||
return canDelete()
|
||||
} else if anItem.action() == #selector(UnsortedTracksViewController.lookupOnITunesMatch(_:)) {
|
||||
return canLookupOnITunesMatch()
|
||||
} else if anItem.action() == #selector(UnsortedTracksViewController.revealInITunes(_:)) {
|
||||
return canRevealInITunes()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func canAddITunesSelection() -> Bool {
|
||||
return iTunes.running && !(iTunes.selection.get() as! [AnyObject]).isEmpty
|
||||
}
|
||||
|
||||
private func canDelete() -> Bool {
|
||||
return tableView.selectedRowIndexes.count > 0
|
||||
}
|
||||
|
||||
private func canLookupOnITunesMatch() -> Bool {
|
||||
return tableView.clickedRow >= 0
|
||||
}
|
||||
|
||||
private func canRevealInITunes() -> Bool {
|
||||
return tableView.clickedRow >= 0 && (tableView.selectedRowIndexes.count == 1 || !tableView.selectedRowIndexes.containsIndex(tableView.clickedRow))
|
||||
}
|
||||
|
||||
}
|
||||
BIN
TagTunes/de.lproj/Localizable.strings
Normal file → Executable file
72
TagTunes/de.lproj/Localizable.stringsdict
Normal file → Executable file
@@ -1,22 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>%d artworks could not be saved.</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@cover@ konnten nicht gespeichert werden.</string>
|
||||
<key>artworks</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>Ein Cover</string>
|
||||
<key>other</key>
|
||||
<string>%d Cover</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>Preparing Lookup for %d Tracks…</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>Suche nach %#@tracks@ vorbereiten…</string>
|
||||
<key>tracks</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>einem Song</string>
|
||||
<key>other</key>
|
||||
<string>%d Songs</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>Looking up %d tracks…</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>Suchen nach %#@tracks@…</string>
|
||||
<key>tracks</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>einem Song</string>
|
||||
<key>other</key>
|
||||
<string>%d Songs</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>%d Tracks Pending</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@tracks@ in der Warteschlange.</string>
|
||||
<key>tracks</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>zero</key>
|
||||
<string>Keine weiteren Songs</string>
|
||||
<key>one</key>
|
||||
<string>Ein weiterer Song</string>
|
||||
<key>other</key>
|
||||
<string>%d weitere Songs</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
0
TagTunes/iTunes.h
Normal file → Executable file
0
TagTunes/iTunes.m
Normal file → Executable file
172
TagTunes/iTunes.swift
Normal file → Executable file
@@ -7,171 +7,17 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AppKitPlus
|
||||
import SearchAPI
|
||||
|
||||
/// The Cocoa Scripting Bridge interface of iTunes.
|
||||
public let iTunes = iTunesApplication(bundleIdentifier: "com.apple.iTunes")!
|
||||
|
||||
/// An ID as returned from the
|
||||
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html)
|
||||
public typealias iTunesId = UInt
|
||||
/// A pasteboard type for `TagTunesTrack` objects. This type is used for dragging
|
||||
/// tracks in TagTunes. It's data should be a single encoded `TagTunesTrack`
|
||||
/// object.
|
||||
public let TrackPboardType = "public.item.tagtunestrack"
|
||||
|
||||
|
||||
/// This struct contains static helper objects and methods to work with the
|
||||
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
|
||||
public struct iTunesAPI {
|
||||
|
||||
// MARK: Types
|
||||
|
||||
/// Error types indicating error responses from the
|
||||
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
|
||||
public enum Error: ErrorType {
|
||||
case InvalidCountryCode
|
||||
case InvalidLanguageCode
|
||||
case UnknownError
|
||||
}
|
||||
|
||||
/// Contains constants identifying the fields in a response from the
|
||||
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
|
||||
public enum Field: String {
|
||||
case WrapperType = "wrapperType"
|
||||
case Kind = "kind"
|
||||
|
||||
case TrackId = "trackId"
|
||||
case TrackName = "trackName"
|
||||
case TrackCensoredName = "trackCensoredName"
|
||||
case ArtistName = "artistName"
|
||||
case ReleaseDate = "releaseDate"
|
||||
case TrackNumber = "trackNumber"
|
||||
case TrackCount = "trackCount"
|
||||
case DiscNumber = "discNumber"
|
||||
case DiscCount = "discCount"
|
||||
case PrimaryGenreName = "primaryGenreName"
|
||||
|
||||
case CollectionId = "collectionId"
|
||||
case CollectionName = "collectionName"
|
||||
case CollectionCensoredName = "collectionCensoredName"
|
||||
case CollectionViewUrl = "collectionViewUrl"
|
||||
case CollectionArtistName = "collectionArtistName"
|
||||
|
||||
case ArtworkUrl60 = "artworkUrl60"
|
||||
case ArtworkUrl100 = "artworkUrl100"
|
||||
}
|
||||
|
||||
// MARK: Static Properties and Functions
|
||||
|
||||
/// This formatter is configured to be used to parse dates returned from the
|
||||
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html)
|
||||
internal static let sharedDateFormatter: NSDateFormatter = {
|
||||
let dateFormatter = NSDateFormatter()
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
/// Processes the data returned from a request to the
|
||||
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
|
||||
/// The `data` has to be in a valid JSON format. See `NSJSONSerialization`
|
||||
/// for details.
|
||||
///
|
||||
/// Currently only tracks and albums are supported. If there are any other
|
||||
/// entries in the specified data this function will raise an exception.
|
||||
///
|
||||
/// - throws: Parsing errors and `iTUnesAPI.Error` constants.
|
||||
/// - returns: An array of albums populated with all associated tracks in the
|
||||
/// specified data.
|
||||
public static func parseAPIData(data: NSData) throws -> [Album] {
|
||||
guard let parsedData = try NSJSONSerialization.JSONObjectWithData(data, options: []) as? [String: AnyObject] else {
|
||||
throw Error.UnknownError
|
||||
}
|
||||
// Handle API Errors
|
||||
if let errorMessage = parsedData["errorMessage"] as? String {
|
||||
switch errorMessage {
|
||||
case "Invalid value(s) for key(s): [country]":
|
||||
throw Error.InvalidCountryCode
|
||||
case "Invalid value(s) for key(s): [language]":
|
||||
throw Error.InvalidLanguageCode
|
||||
default:
|
||||
throw Error.UnknownError
|
||||
}
|
||||
}
|
||||
// Parse API Results
|
||||
var albums = [iTunesId: Album]()
|
||||
if let results = parsedData["results"] as? [[String: AnyObject]] {
|
||||
for result in results {
|
||||
let convertedResult = result.filter({ (key, value) -> Bool in Field(rawValue: key) != nil }).map({ (Field(rawValue: $0)!, $1)
|
||||
})
|
||||
let albumId = convertedResult[.CollectionId] as! iTunesId
|
||||
if albums[albumId] == nil {
|
||||
albums[albumId] = Album(data: convertedResult)
|
||||
}
|
||||
if isTrack(convertedResult) {
|
||||
albums[albumId]?.addTrack(Track(data: convertedResult))
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array(albums.values)
|
||||
}
|
||||
|
||||
private static func isTrack(data: [Field: AnyObject]) -> Bool {
|
||||
return data[.WrapperType] as! String == "track" && data[.Kind] as! String == "song"
|
||||
}
|
||||
|
||||
/// Creates an URL that searches the
|
||||
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html)
|
||||
/// for albums matching a specific `term`.
|
||||
///
|
||||
/// This function respects the user's preferences (See `Preferences` class).
|
||||
///
|
||||
/// - returns: The query URL or `nil` if `term` is invalid.
|
||||
public static func createAlbumSearchURLForTerm(term: String) -> NSURL? {
|
||||
var searchTerm = term.stringByReplacingOccurrencesOfString(" ", withString: "+")
|
||||
searchTerm = searchTerm.stringByAddingPercentEncodingWithAllowedCharacters(NSCharacterSet())!
|
||||
if searchTerm.isEmpty {
|
||||
return nil
|
||||
}
|
||||
return NSURL(string: "https://itunes.apple.com/search?term=\(searchTerm)&media=music&entity=album&limit=\(Preferences.sharedPreferences.numberOfSearchResults)&country=\(Preferences.sharedPreferences.iTunesStore)" + (Preferences.sharedPreferences.useEnglishTags ? "&lang=en" : ""))
|
||||
}
|
||||
|
||||
/// Creates an URL that looks up all tracks that belong to the album with the
|
||||
/// specified `id` in the
|
||||
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
|
||||
///
|
||||
/// This function respects the user's preferences (See `Preferences` class).
|
||||
public static func createAlbumLookupURLForId(id: iTunesId) -> NSURL {
|
||||
return NSURL(string: "http://itunes.apple.com/lookup?id=\(id)&entity=song&country=\(Preferences.sharedPreferences.iTunesStore)&limit=200" + (Preferences.sharedPreferences.useEnglishTags ? "&lang=en" : ""))!
|
||||
}
|
||||
}
|
||||
|
||||
/// Defines a type that can be parsed from a result of the
|
||||
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
|
||||
public protocol iTunesType {
|
||||
|
||||
/// Initializes the receiver with the specified data. The receiver may use
|
||||
/// all or only some of the values.
|
||||
///
|
||||
/// This method requires `data` to contain the expected formats. If data
|
||||
/// contains no data or data in an invalid format for an expected key, this
|
||||
/// method raises an exception. To check wether a specified dictionary is
|
||||
/// valid use `canInitializeFromData`.
|
||||
init(data: [iTunesAPI.Field: AnyObject])
|
||||
|
||||
/// Returns all fields that are required to initialize an instance of the
|
||||
/// receiving type.
|
||||
static var requiredFields: [iTunesAPI.Field] { get }
|
||||
|
||||
}
|
||||
|
||||
extension iTunesType {
|
||||
|
||||
/// Returns wether the specified `data` can be used to initialize a instance
|
||||
/// of the receiving type.
|
||||
public static func canInitializeFromData(data: [iTunesAPI.Field: AnyObject]) -> Bool {
|
||||
for field in requiredFields {
|
||||
if data[field] == nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
/// A pasteboard type for `Int`s. The specific use of this type is not specified.
|
||||
/// This pasteboard type should only be used for private purposes (for example to
|
||||
/// *remember* the original indexes of dragged items in a table view).
|
||||
public let IndexPboardType = "public.item.index"
|
||||