Compare commits
11 Commits
version-1.
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99f412d3aa | ||
|
|
0a485ff42a | ||
|
|
ba472864df | ||
|
|
f3669932df | ||
|
|
cb231a9f0c | ||
|
|
ed5e1083d6 | ||
|
|
dfc3be5fd1 | ||
|
|
f75c16cb27 | ||
|
|
98d6d492d0 | ||
|
|
a228f91409 | ||
|
|
e152c3da44 |
0
Base.lproj/Credits.rtf
Normal file → Executable file
57
Changelog.txt
Normal file → Executable file
@@ -1,24 +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
|
||||
|
||||
Version 1.2.1
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
19
README.md
Normal file → Executable file
@@ -1,11 +1,14 @@
|
||||
TagTunes is an application that uses Apple's [Search API][Search API] to get tags for your music files direcly from the iTunes store. With TagTunes you can easily clean your music library using exactly the tags that Apple itself uses.
|
||||
|
||||
#[Download TagTunes][Download 1.2]
|
||||
#[Download TagTunes][Download 1.2.1]
|
||||
|
||||
------------------------------------------------------------
|
||||
|
||||
## All Versions
|
||||
|
||||
### [Version 1.2.1][Download 1.2.1]
|
||||
This version adds an option to use English tags in every iTunes Store and fixes some issues.
|
||||
|
||||
### [Version 1.2][Download 1.2]
|
||||
This version adds a German translation and a setting to search in a specific iTunes Store.
|
||||
|
||||
@@ -33,11 +36,14 @@ The first release of TagTunes includes all functions needed to clean your iTunes
|
||||
#### The tags I got from TagTunes differ from the one's in the iTunes Store.
|
||||
> Unfortunately Apple's Search API does not always return the name results as the iTunes Store does. This mainly affects composers (which aren't returned by the Search API at all) and track titles.
|
||||
|
||||
#### I have an iTunes account for a different country
|
||||
> In the preferences of TagTunes you can set your preferred iTunes store. You may also choose to use English tags although you use metadata from a non-English store.
|
||||
|
||||
#### Does TagTunes support other sources than the Search API?
|
||||
> No, currently TagTunes does not support other sources.
|
||||
|
||||
#### Can I contribute to TagTunes?
|
||||
> Please E-Mail me at [dev.kwittenburg@icloud.com][E-Mail]
|
||||
> Please E-Mail me at [codello@wittenburg.kim][E-Mail]
|
||||
|
||||
------------------------------------------------------------
|
||||
|
||||
@@ -46,7 +52,8 @@ Currently TagTunes is not released under a open source license. This will most l
|
||||
|
||||
|
||||
[Search API]: https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html
|
||||
[Download 1.2]: https://bitbucket.org/Codello/tagtunes/downloads/TagTunes%201.2.dmg
|
||||
[Download 1.1]: https://bitbucket.org/Codello/tagtunes/downloads/TagTunes%201.1.dmg
|
||||
[Download 1.0]: https://bitbucket.org/Codello/tagtunes/downloads/TagTunes%201.0.dmg
|
||||
[E-Mail]: mailto:dev.kwittenburg@icloud.com
|
||||
[Download 1.2.1]: https://gitlab.com/Codello/tagtunes/uploads/d070455776f6ed87a53f9acf045cc39d/TagTunes_1.2.1.zip
|
||||
[Download 1.2]: https://gitlab.com/Codello/tagtunes/uploads/9e8190548179287cb4306a659c303fc7/TagTunes_1.2.zip
|
||||
[Download 1.1]: https://gitlab.com/Codello/tagtunes/uploads/d519a326dc8840153b5c03b3ebce5f2f/TagTunes_1.1.zip
|
||||
[Download 1.0]:
|
||||
[E-Mail]: mailto:codello@wittenburg.kim
|
||||
164
TagTunes.xcodeproj/project.pbxproj
Normal file → Executable file
@@ -3,33 +3,50 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 46;
|
||||
objectVersion = 47;
|
||||
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,17 +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; };
|
||||
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>"; };
|
||||
@@ -86,21 +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>"; };
|
||||
3B86A40B1BA9F11200B150AE /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Main.strings; 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>";
|
||||
@@ -329,7 +385,7 @@
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 3B76C76A1B909B280025D550 /* Build configuration list for PBXProject "TagTunes" */;
|
||||
compatibilityVersion = "Xcode 3.2";
|
||||
compatibilityVersion = "Xcode 6.3";
|
||||
developmentRegion = English;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
@@ -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 */,
|
||||
3B86A40B1BA9F11200B150AE /* 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
//
|
||||
// Album.swift
|
||||
// Tag for iTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 29.05.15.
|
||||
// Copyright (c) 2015 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
/// Represents an album of the
|
||||
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
|
||||
public class Album: iTunesType {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public let id: iTunesId
|
||||
|
||||
public let name: String
|
||||
|
||||
public let censoredName: String
|
||||
|
||||
public let viewURL: NSURL
|
||||
|
||||
public let artwork: Artwork
|
||||
|
||||
public let trackCount: Int
|
||||
|
||||
public let releaseDate: NSDate
|
||||
|
||||
public let genre: String
|
||||
|
||||
public let artistName: String
|
||||
|
||||
public private(set) var tracks = [Track]()
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
internal init(id: iTunesId, name: String, censoredName: String, viewURL: NSURL, artwork: Artwork, trackCount: Int, releaseDate: NSDate, genre: String, artistName: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.censoredName = censoredName
|
||||
self.viewURL = viewURL
|
||||
self.artwork = artwork
|
||||
self.trackCount = trackCount
|
||||
self.releaseDate = releaseDate
|
||||
self.genre = genre
|
||||
self.artistName = artistName
|
||||
}
|
||||
|
||||
public required init(data: [iTunesAPI.Field : AnyObject]) {
|
||||
id = data[.CollectionId] as! UInt
|
||||
name = data[.CollectionName] as! String
|
||||
censoredName = data[.CollectionCensoredName] as! String
|
||||
viewURL = NSURL(string: data[.CollectionViewUrl] as! String)!
|
||||
|
||||
artwork = Artwork(data: data)
|
||||
trackCount = data[.TrackCount] as! Int
|
||||
releaseDate = iTunesAPI.sharedDateFormatter.dateFromString(data[.ReleaseDate] as! String)!
|
||||
genre = data[.PrimaryGenreName] as! String
|
||||
if let artistName = data[.CollectionArtistName] as? String {
|
||||
self.artistName = artistName
|
||||
} else {
|
||||
artistName = data[.ArtistName] as! String
|
||||
}
|
||||
}
|
||||
|
||||
public static var requiredFields: [iTunesAPI.Field] {
|
||||
return [.CollectionId, .CollectionName, .CollectionCensoredName, .CollectionViewUrl, .TrackCount, .ReleaseDate, .PrimaryGenreName, .ArtistName]
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Adds a track to the album.
|
||||
internal func addTrack(newTrack: Track) {
|
||||
newTrack.album = self
|
||||
var index = 0
|
||||
for track in tracks {
|
||||
if newTrack.discNumber < track.discNumber {
|
||||
break
|
||||
} else if newTrack.discNumber == track.discNumber {
|
||||
if newTrack.trackNumber <= track.trackNumber {
|
||||
break
|
||||
}
|
||||
}
|
||||
++index
|
||||
}
|
||||
tracks.insert(newTrack, atIndex: index)
|
||||
}
|
||||
|
||||
/// Returns wether all tracks in the album have the same artist name as the
|
||||
/// album itself.
|
||||
public var hasSameArtistNameAsTracks: Bool {
|
||||
for track in tracks {
|
||||
if artistName != track.artistName {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Saves the album to iTunes.
|
||||
public func save() {
|
||||
for track in tracks {
|
||||
track.save()
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the album's artwork to the directory specified in the user's
|
||||
/// preferences (See `Preferences` for details).
|
||||
public func saveArtwork() throws {
|
||||
if let url = Preferences.sharedPreferences.artworkTarget {
|
||||
try artwork.saveToURL(url, filename: name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if all tracks of the album are saved.
|
||||
public var saved: Bool {
|
||||
for track in tracks {
|
||||
if !track.saved {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
extension Album: CustomStringConvertible {
|
||||
|
||||
public var description: String {
|
||||
return "\"\(name)\" (\(tracks.count) tracks)"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Album: Hashable {
|
||||
|
||||
public var hashValue: Int {
|
||||
return Int(id)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public func ==(lhs: Album, rhs: Album) -> Bool {
|
||||
return lhs === rhs
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
//
|
||||
// AlbumCollection.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 08.09.15.
|
||||
// Copyright © 2015 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Manages a collection of albums. Managing includes support for deferred
|
||||
/// loading of an album's tracks as well as error support.
|
||||
public class AlbumCollection: CollectionType {
|
||||
|
||||
// MARK: Types
|
||||
|
||||
private enum AlbumState {
|
||||
|
||||
case Normal
|
||||
|
||||
case Error(NSError)
|
||||
|
||||
case Loading(NSURLSessionTask)
|
||||
|
||||
}
|
||||
|
||||
/// Notifications posted by an album collection. The `userInfo` of these
|
||||
/// notifications contains all keys specified in `Keys`.
|
||||
public struct Notifications {
|
||||
|
||||
/// Posted when an album is added to a collection. This notification is
|
||||
/// only posted if the album collection actually changed.
|
||||
public static let albumAdded = "AlbumAddedNotificationName"
|
||||
|
||||
/// Posted when an album is removed from a collection. This notification
|
||||
/// is only posted if the album collection actually changed.
|
||||
public static let albumRemoved = "AlbumRemovedNotificationName"
|
||||
|
||||
/// Posted when the album collection started a network request for an
|
||||
/// album's tracks.
|
||||
///
|
||||
/// Note that the values for the keys `Album` and `AlbumIndex` for the
|
||||
/// corresponding `AlbumFinishedLoading` notification may both be
|
||||
/// different.
|
||||
public static let albumStartedLoading = "AlbumStartedLoadingNotificationName"
|
||||
|
||||
/// Posted when an album collection finished loading the tracks for an
|
||||
/// album. Receiving this notification does not mean that the tracks were
|
||||
/// actually loaded successfully. It just means that the networ
|
||||
/// connection terminated. Use `errorForAlbum` to determine if an error
|
||||
/// occured while the tracks have been loaded.
|
||||
///
|
||||
/// - note: Since the actual `Album` instance in the album collection may
|
||||
/// change during loading its tracks it is preferred that you use the
|
||||
/// `AlbumIndexKey` of the notification to determine which album finished
|
||||
/// loading its tracks. You can however use the `AlbumKey` as well to
|
||||
/// access the `Album` instance that is currently present in the
|
||||
/// collection at the respective index.
|
||||
public static let albumFinishedLoading = "AlbumFinishedLoadingNotificationName"
|
||||
|
||||
/// These constants are available as keys in the `userInfo` dictionary
|
||||
/// for any notification an album collection may post.
|
||||
public struct Keys {
|
||||
|
||||
/// The `Album` instance affected by the notification.
|
||||
public static let album = "AlbumKey"
|
||||
|
||||
/// The index of the album affected by the notification.
|
||||
public static let albumIndex = "AlbumIndexKey"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// Access the userlying array of albums.
|
||||
public private(set) var albums = [Album]()
|
||||
|
||||
private var albumStates = [Album: AlbumState]()
|
||||
|
||||
/// The URL session used to load tracks for albums.
|
||||
private let urlSession = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration(), delegate: nil, delegateQueue: NSOperationQueue.mainQueue())
|
||||
|
||||
// MARK: Collection Type
|
||||
|
||||
public init() {}
|
||||
|
||||
public var startIndex: Int {
|
||||
return albums.startIndex
|
||||
}
|
||||
|
||||
public var endIndex: Int {
|
||||
return albums.endIndex
|
||||
}
|
||||
|
||||
public subscript (position: Int) -> Album {
|
||||
return albums[position]
|
||||
}
|
||||
|
||||
// MARK: Album Collection
|
||||
|
||||
/// Adds the specified album, if not already present, and begins to load its
|
||||
/// tracks.
|
||||
///
|
||||
/// - parameters:
|
||||
/// - album: The album to be added.
|
||||
/// - flag: Specify `false` if the album collection should not begin to
|
||||
/// load the album's tracks immediately.
|
||||
public func addAlbum(album: Album, beginLoading flag: Bool = true) {
|
||||
if !albums.contains(album) {
|
||||
albums.append(album)
|
||||
let userInfo: [NSObject: AnyObject] = [AlbumCollection.Notifications.Keys.album: album, AlbumCollection.Notifications.Keys.albumIndex: albums.count-1]
|
||||
NSNotificationCenter.defaultCenter().postNotificationName(AlbumCollection.Notifications.albumAdded, object: self, userInfo: userInfo)
|
||||
if flag {
|
||||
beginLoadingTracksForAlbum(album)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the specified album from the collection if it is present.
|
||||
public func removeAlbum(album: Album) {
|
||||
if let index = albums.indexOf(album) {
|
||||
removeAlbumAtIndex(index)
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the album at the specified index from the collection.
|
||||
///
|
||||
/// - requires: The specified index must be in the collection's range.
|
||||
public func removeAlbumAtIndex(index: Int) {
|
||||
let album = self[index]
|
||||
setAlbumState(nil, forAlbum: album)
|
||||
albums.removeAtIndex(index)
|
||||
let userInfo: [NSObject: AnyObject] = [AlbumCollection.Notifications.Keys.album: album, AlbumCollection.Notifications.Keys.albumIndex: index]
|
||||
NSNotificationCenter.defaultCenter().postNotificationName(AlbumCollection.Notifications.albumRemoved, object: self, userInfo: userInfo)
|
||||
}
|
||||
|
||||
/// Begins to load the tracks for the specified album. If there is already a
|
||||
/// request for the specified album it is cancelled. When the tracks for the
|
||||
/// specified album have been loaded or an error occured, a
|
||||
/// `AlbumFinishedLoadingNotification` is posted.
|
||||
public func beginLoadingTracksForAlbum(album: Album) {
|
||||
guard let albumIndex = albums.indexOf(album) else {
|
||||
return
|
||||
}
|
||||
let url = iTunesAPI.createAlbumLookupURLForId(album.id)
|
||||
let task = urlSession.dataTaskWithURL(url) { (data, response, error) -> Void in
|
||||
var newAlbumIndex = self.albums.indexOf(album)!
|
||||
defer {
|
||||
let userInfo: [NSObject: AnyObject] = [AlbumCollection.Notifications.Keys.album: self.albums[albumIndex], AlbumCollection.Notifications.Keys.albumIndex: albumIndex]
|
||||
NSNotificationCenter.defaultCenter().postNotificationName(AlbumCollection.Notifications.albumFinishedLoading, object: self, userInfo: userInfo)
|
||||
}
|
||||
guard error == nil else {
|
||||
if error!.code != NSUserCancelledError {
|
||||
self.albumStates[album] = .Error(error!)
|
||||
}
|
||||
return
|
||||
}
|
||||
do {
|
||||
let newAlbum = try iTunesAPI.parseAPIData(data!)[0]
|
||||
self.albums.removeAtIndex(newAlbumIndex)
|
||||
self.albums.insert(newAlbum, atIndex: newAlbumIndex)
|
||||
self.setAlbumState(.Normal, forAlbum: album)
|
||||
} catch let error as NSError {
|
||||
self.setAlbumState(.Error(error), forAlbum: album)
|
||||
} catch _ {
|
||||
// Will never happen
|
||||
}
|
||||
}
|
||||
setAlbumState(.Loading(task), forAlbum: album)
|
||||
task.resume()
|
||||
let userInfo: [NSObject: AnyObject] = [AlbumCollection.Notifications.Keys.album: album, AlbumCollection.Notifications.Keys.albumIndex: albumIndex]
|
||||
NSNotificationCenter.defaultCenter().postNotificationName(AlbumCollection.Notifications.albumStartedLoading, object: self, userInfo: userInfo)
|
||||
|
||||
}
|
||||
|
||||
/// Cancels the request to load the tracks for the specified album and sets
|
||||
/// the error for the album to a `NSUserCancelledError` in the
|
||||
/// `NSCocoaErrorDomain`.
|
||||
public func cancelLoadingTracksForAlbum(album: Album) {
|
||||
let error = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil)
|
||||
setAlbumState(.Error(error), forAlbum: album)
|
||||
}
|
||||
|
||||
/// Sets the state for the specified album. If the previous state was
|
||||
/// `Loading` the associated task is cancelled.
|
||||
private func setAlbumState(state: AlbumState?, forAlbum album: Album) {
|
||||
if case let .Some(.Loading(task)) = albumStates[album] {
|
||||
task.cancel()
|
||||
}
|
||||
albumStates[album] = state
|
||||
}
|
||||
|
||||
public func isAlbumLoading(album: Album) -> Bool {
|
||||
if case .Some(.Loading) = albumStates[album] {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public func errorForAlbum(album: Album) -> NSError? {
|
||||
if case let .Some(.Error(error)) = albumStates[album] {
|
||||
return error
|
||||
}
|
||||
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
22
TagTunes/Assets.xcassets/PreferenceStore.imageset/Contents.json
vendored
Executable file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "PreferenceStore.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "PreferenceStore@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
BIN
TagTunes/Assets.xcassets/PreferenceStore.imageset/PreferenceStore.png
vendored
Executable file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
TagTunes/Assets.xcassets/PreferenceStore.imageset/PreferenceStore@2x.png
vendored
Executable file
|
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>
|
||||
|
||||
879
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</string>
|
||||
<string>2.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>200</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
|
||||
}
|
||||
|
||||
}
|
||||
1106
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,39 +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 {
|
||||
}
|
||||
|
||||
class StorePreferencesViewController: NSViewController {
|
||||
|
||||
dynamic var iTunesStores: [String] {
|
||||
return NSLocale.ISOCountryCodes().map { NSLocale.currentLocale().displayNameForKey(NSLocaleCountryCode, value: $0)! }
|
||||
@@ -58,7 +29,7 @@ internal class GeneralPreferencesViewController: NSViewController {
|
||||
|
||||
}
|
||||
|
||||
internal class TagsPreferencesViewController: NSViewController, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate {
|
||||
class TagsPreferencesViewController: NSViewController, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate {
|
||||
|
||||
// MARK: Types
|
||||
|
||||
@@ -80,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
|
||||
@@ -96,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
|
||||
@@ -107,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
|
||||
@@ -126,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:
|
||||
|
||||
114
TagTunes/Preferences.swift
Normal file → Executable file
@@ -27,31 +27,19 @@ 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"
|
||||
|
||||
static let iTunesStoreKey = "iTunes Store"
|
||||
|
||||
static let useLowResolutionArtworkKey = "Use Low Resolution Artwork"
|
||||
static let useEnglishTagsKey = "Use English Tags"
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -79,73 +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.useLowResolutionArtworkKey: false,
|
||||
UserDefaultsConstants.useEnglishTagsKey: 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 {
|
||||
@@ -167,15 +109,14 @@ import Cocoa
|
||||
return NSUserDefaults.standardUserDefaults().stringForKey(UserDefaultsConstants.iTunesStoreKey)!
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
/// If `true` the Search API Request adds the "lang=en" option.
|
||||
public dynamic var useEnglishTags: Bool {
|
||||
set {
|
||||
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: UserDefaultsConstants.useLowResolutionArtworkKey)
|
||||
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: UserDefaultsConstants.useEnglishTagsKey)
|
||||
}
|
||||
get {
|
||||
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.useLowResolutionArtworkKey)
|
||||
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.useEnglishTagsKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,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
|
||||
|
||||
@@ -222,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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
//
|
||||
// SearchResult.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 31.08.15.
|
||||
// Copyright © 2015 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
/// Represents an `Album` returned fromm the
|
||||
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
|
||||
public class SearchResult {
|
||||
|
||||
public let id: iTunesId
|
||||
|
||||
public let name: String
|
||||
|
||||
public let censoredName: String
|
||||
|
||||
public let viewURL: NSURL
|
||||
|
||||
public let artwork: Artwork
|
||||
|
||||
public let trackCount: Int
|
||||
|
||||
public let releaseDate: NSDate
|
||||
|
||||
public let genre: String
|
||||
|
||||
public let artistName: String
|
||||
|
||||
public init(representedAlbum: Album) {
|
||||
id = representedAlbum.id
|
||||
name = representedAlbum.name
|
||||
censoredName = representedAlbum.censoredName
|
||||
viewURL = representedAlbum.viewURL
|
||||
artwork = representedAlbum.artwork
|
||||
trackCount = representedAlbum.trackCount
|
||||
releaseDate = representedAlbum.releaseDate
|
||||
genre = representedAlbum.genre
|
||||
artistName = representedAlbum.artistName
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SearchResult: Hashable {
|
||||
|
||||
public var hashValue: Int {
|
||||
return Int(id)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Album {
|
||||
|
||||
public convenience init(searchResult: SearchResult) {
|
||||
self.init(id: searchResult.id, name: searchResult.name, censoredName: searchResult.censoredName, viewURL: searchResult.viewURL, artwork: searchResult.artwork, trackCount: searchResult.trackCount, releaseDate: searchResult.releaseDate, genre: searchResult.genre, artistName: searchResult.artistName)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public func ==(lhs: SearchResult, rhs: SearchResult) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
29
TagTunes/TrackTableCellView.swift → TagTunes/SongTableCellView.swift
Normal file → Executable file
@@ -9,9 +9,8 @@
|
||||
import Cocoa
|
||||
import AppKitPlus
|
||||
|
||||
/// A table cell view to display information for a `Track`. This class should
|
||||
/// only be initialized from a nib or storyboard.
|
||||
public class TrackTableCellView: AdvancedTableCellView {
|
||||
/// A table cell view to display information for a `SongItem`.
|
||||
public class SongTableCellView: AdvancedTableCellView {
|
||||
|
||||
// MARK: Types
|
||||
|
||||
@@ -96,26 +95,26 @@ public class TrackTableCellView: AdvancedTableCellView {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Sets up the receiver to display `track`.
|
||||
public func setupForTrack(track: Track) {
|
||||
style = track.album.hasSameArtistNameAsTracks ? .Simple : .CompactSubtitle
|
||||
/// 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 ? track.censoredName : track.name
|
||||
if track.associatedTracks.isEmpty {
|
||||
textField?.stringValue = Preferences.sharedPreferences.useCensoredNames ? songItem.song.censoredName : songItem.song.name
|
||||
if songItem.associatedTracks.isEmpty {
|
||||
textField?.textColor = NSColor.disabledControlTextColor()
|
||||
} else if track.associatedTracks.count > 1 || track.associatedTracks[0].name.compare(track.name, options: Preferences.sharedPreferences.caseSensitive ? [] : .CaseInsensitiveSearch, range: nil, locale: nil) != .OrderedSame {
|
||||
} 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 = track.artistName
|
||||
trackNumberTextField?.stringValue = "\(track.trackNumber)"
|
||||
if track.associatedTracks.isEmpty {
|
||||
imageView?.image = TrackTableCellView.Images.grayTickImage
|
||||
secondaryTextField?.stringValue = songItem.song.artist.name
|
||||
trackNumberTextField?.stringValue = "\(songItem.song.trackNumber)"
|
||||
if songItem.associatedTracks.isEmpty {
|
||||
imageView?.image = SongTableCellView.Images.grayTickImage
|
||||
} else {
|
||||
imageView?.image = TrackTableCellView.Images.tickImage
|
||||
imageView?.image = SongTableCellView.Images.tickImage
|
||||
}
|
||||
if track.saved {
|
||||
if songItem.saved {
|
||||
let aspectRatioConstraint = NSLayoutConstraint(
|
||||
item: savedImageView,
|
||||
attribute: .Width,
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
//
|
||||
// Track.swift
|
||||
// Tag for iTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 30.05.15.
|
||||
// Copyright (c) 2015 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
/// Represents a track of the
|
||||
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
|
||||
public class Track: iTunesType {
|
||||
|
||||
// MARK: Types
|
||||
|
||||
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"
|
||||
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:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public var clearable: Bool {
|
||||
switch self {
|
||||
case .Year, .TrackNumber, .TrackCount, .DiscNumber, .DiscCount:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a string identifying the respective tag that can be displayed
|
||||
/// to the user.
|
||||
public var localizedName: String {
|
||||
return NSLocalizedString("Tag: \(self.rawValue)", comment: "")
|
||||
}
|
||||
|
||||
/// Returns an array of all tags.
|
||||
public static var allTags: [Tag] {
|
||||
return [.Name, .Artist, .Year, .TrackNumber, .TrackCount, .DiscNumber, .DiscCount, .Genre, .AlbumName, .AlbumArtist, .SortName, .SortArtist, .SortAlbumName, .SortAlbumArtist, .Composer, .SortComposer, .Comment]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public let id: iTunesId
|
||||
|
||||
public let name: String
|
||||
|
||||
public let censoredName: String
|
||||
|
||||
public let artistName: String
|
||||
|
||||
public let releaseDate: NSDate
|
||||
|
||||
public let trackNumber: Int
|
||||
|
||||
public let trackCount: Int
|
||||
|
||||
public let discNumber: Int
|
||||
|
||||
public let discCount: Int
|
||||
|
||||
public let genre: String
|
||||
|
||||
public internal(set) weak var album: Album!
|
||||
|
||||
/// These tracks will be changed, if `save()` is called.
|
||||
public var associatedTracks = [iTunesTrack]()
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
public required init(data: [iTunesAPI.Field: AnyObject]) {
|
||||
id = data[.TrackId] as! UInt
|
||||
name = data[.TrackName] as! String
|
||||
censoredName = data[.TrackCensoredName] as! String
|
||||
artistName = data[.ArtistName] as! String
|
||||
releaseDate = iTunesAPI.sharedDateFormatter.dateFromString(data[.ReleaseDate] as! String)!
|
||||
trackNumber = data[.TrackNumber] as! Int
|
||||
trackCount = data[.TrackCount] as! Int
|
||||
discNumber = data[.DiscNumber] as! Int
|
||||
discCount = data[.DiscCount] as! Int
|
||||
genre = data[.PrimaryGenreName] as! String
|
||||
}
|
||||
|
||||
public static var requiredFields: [iTunesAPI.Field] {
|
||||
return [.TrackId, .TrackName, .TrackCensoredName, .ArtistName, .ReleaseDate, .TrackNumber, .TrackCount, .DiscNumber, .DiscCount, .PrimaryGenreName]
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Saves the track. This means that its properties are applied to every
|
||||
/// `iTunesTrack` in its `associatedTracks`.
|
||||
///
|
||||
/// This method respects the user's preferences (See `Preferences` class).
|
||||
public func save() {
|
||||
saveToTracks(associatedTracks)
|
||||
}
|
||||
|
||||
/// Applies the receiver's properties to the specified tracks.
|
||||
///
|
||||
/// This method respects the user's preferences (See `Preferences` class).
|
||||
public func saveToTracks(tracks: [iTunesTrack]) {
|
||||
for track in tracks {
|
||||
saveToTrack(track)
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the receiver's properties to the specified `track`.
|
||||
///
|
||||
/// This method respects the user's preferences (See `Preferences` class).
|
||||
public func saveToTrack(track: iTunesTrack) {
|
||||
let components = NSCalendar.currentCalendar().components(.Year, fromDate: releaseDate)
|
||||
let trackName = Preferences.sharedPreferences.useCensoredNames ? censoredName : name
|
||||
saveTag(.Name, toTrack: track, value: trackName)
|
||||
saveTag(.Artist, toTrack: track, value: artistName)
|
||||
saveTag(.Year, toTrack: track, value: components.year)
|
||||
saveTag(.TrackNumber, toTrack: track, value: trackNumber)
|
||||
saveTag(.TrackCount, toTrack: track, value: trackCount)
|
||||
saveTag(.DiscNumber, toTrack: track, value: discNumber)
|
||||
saveTag(.DiscCount, toTrack: track, value: discCount)
|
||||
saveTag(.Genre, toTrack: track, value: genre)
|
||||
let albumName = Preferences.sharedPreferences.useCensoredNames ? album.censoredName : album.name
|
||||
saveTag(.AlbumName, toTrack: track, value: albumName)
|
||||
saveTag(.AlbumArtist, toTrack: track, value: album.artistName)
|
||||
saveTag(.SortName, toTrack: track, value: nil)
|
||||
saveTag(.SortArtist, toTrack: track, value: nil)
|
||||
saveTag(.SortAlbumName, toTrack: track, value: nil)
|
||||
saveTag(.SortAlbumArtist, toTrack: track, value: nil)
|
||||
saveTag(.Composer, toTrack: track, value: nil)
|
||||
saveTag(.SortComposer, toTrack: track, value: nil)
|
||||
saveTag(.Comment, toTrack: track, value: nil)
|
||||
|
||||
if Preferences.sharedPreferences.clearArtworks {
|
||||
track.artworks().removeAllObjects()
|
||||
}
|
||||
}
|
||||
|
||||
private func saveTag(tag: Tag, toTrack track: iTunesTrack, value: AnyObject?) {
|
||||
switch Preferences.sharedPreferences.tagSavingBehaviors[tag]! {
|
||||
case .Save:
|
||||
track.setValue(value, forKey: tag.rawValue)
|
||||
case .Clear:
|
||||
track.setValue("", forKey: tag.rawValue)
|
||||
case .Ignore:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if all `associatedTrack`s contain the same values as the
|
||||
/// `Track` instance.
|
||||
public var saved: Bool {
|
||||
let components = NSCalendar.currentCalendar().components(.Year, fromDate: releaseDate)
|
||||
for track in associatedTracks {
|
||||
let trackName = Preferences.sharedPreferences.useCensoredNames ? censoredName : name
|
||||
let albumName = Preferences.sharedPreferences.useCensoredNames ? album.censoredName : album.name
|
||||
let options = Preferences.sharedPreferences.caseSensitive ? [] : NSStringCompareOptions.CaseInsensitiveSearch
|
||||
guard track.name.compare(trackName, options: options, range: nil, locale: nil) == .OrderedSame else {
|
||||
return false
|
||||
}
|
||||
guard track.album.compare(albumName, options: options, range: nil, locale: nil) == .OrderedSame else {
|
||||
return false
|
||||
}
|
||||
guard track.artist == artistName && track.year == components.year && track.trackNumber == trackNumber && track.trackCount == trackCount && track.discNumber == discNumber && track.discCount == discCount && track.genre == genre && track.albumArtist == album.artistName && track.composer == "" else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Track: Hashable {
|
||||
|
||||
public var hashValue: Int {
|
||||
return Int(id)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public func ==(lhs: Track, rhs: Track) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Customize Toolbar…"; ObjectID = "1UK-8n-QPP"; */
|
||||
"1UK-8n-QPP.title" = "Symbolleiste anpassen…";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "TagTunes"; ObjectID = "1Xt-HY-uBw"; */
|
||||
"1Xt-HY-uBw.title" = "TagTunes";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Quit TagTunes"; ObjectID = "4sb-4s-VLi"; */
|
||||
"4sb-4s-VLi.title" = "TagTunes beenden";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Edit"; ObjectID = "5QF-Oa-p0T"; */
|
||||
"5QF-Oa-p0T.title" = "Bearbeiten";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "About TagTunes"; ObjectID = "5kV-Vb-QxS"; */
|
||||
"5kV-Vb-QxS.title" = "Über TagTunes";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Redo"; ObjectID = "6dh-zS-Vam"; */
|
||||
"6dh-zS-Vam.title" = "Wiederholen";
|
||||
|
||||
/* Class = "NSTextFieldCell"; title = "Some tags are not provided by the iTunes Search API. TagTunes can only clear these tags."; ObjectID = "8L8-Nr-zgu"; */
|
||||
"8L8-Nr-zgu.title" = "Einige Tags werden nicht von der iTunes Search API bereitgestellt. Diese Tags können nur gelöscht werden.";
|
||||
|
||||
/* Class = "NSButtonCell"; title = "Choose…"; ObjectID = "A5S-ps-EYW"; */
|
||||
"A5S-ps-EYW.title" = "Wählen…";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Preferences…"; ObjectID = "BOF-NM-1cW"; */
|
||||
"BOF-NM-1cW.title" = "Einstellungen…";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Save As…"; ObjectID = "Bw7-FT-i3A"; */
|
||||
"Bw7-FT-i3A.title" = "Sichern unter…";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Close"; ObjectID = "DVo-aG-piG"; */
|
||||
"DVo-aG-piG.title" = "Schließen";
|
||||
|
||||
/* Class = "NSMenu"; title = "Help"; ObjectID = "F2S-fz-NVQ"; */
|
||||
"F2S-fz-NVQ.title" = "Hilfe";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "TagTunes Help"; ObjectID = "FKE-Sm-Kum"; */
|
||||
"FKE-Sm-Kum.title" = "TagTunes Hilfe";
|
||||
|
||||
/* Class = "NSToolbarItem"; label = "Remove"; ObjectID = "Fst-WO-GlQ"; */
|
||||
"Fst-WO-GlQ.label" = "Entfernen";
|
||||
|
||||
/* Class = "NSToolbarItem"; paletteLabel = "Remove"; ObjectID = "Fst-WO-GlQ"; */
|
||||
"Fst-WO-GlQ.paletteLabel" = "Entfernen";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "View"; ObjectID = "H8h-7b-M4v"; */
|
||||
"H8h-7b-M4v.title" = "Ansicht";
|
||||
|
||||
/* Class = "NSMenu"; title = "View"; ObjectID = "HyV-fh-RgO"; */
|
||||
"HyV-fh-RgO.title" = "Ansicht";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Open…"; ObjectID = "IAo-SY-fd9"; */
|
||||
"IAo-SY-fd9.title" = "Öffnen…";
|
||||
|
||||
/* Class = "NSWindow"; title = "TagTunes"; ObjectID = "IQv-IB-iLA"; */
|
||||
"IQv-IB-iLA.title" = "TagTunes";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Revert to Saved"; ObjectID = "KaW-ft-85H"; */
|
||||
"KaW-ft-85H.title" = "Zuletzt gesichert";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Show All"; ObjectID = "Kd2-mp-pUS"; */
|
||||
"Kd2-mp-pUS.title" = "Alle Versionen durchsuchen";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Bring All to Front"; ObjectID = "LE2-aR-0XJ"; */
|
||||
"LE2-aR-0XJ.title" = "Alle nach vorne bringen";
|
||||
|
||||
/* Class = "NSTextFieldCell"; title = "If checked the search results are not hidden if a result is added."; ObjectID = "Lnf-PQ-PX4"; */
|
||||
"Lnf-PQ-PX4.title" = "Wenn ausgewählt, werden die Suchergebnisse nicht ausgeblended, wenn ein Ergebnis ausgewählt wird.";
|
||||
|
||||
/* Class = "NSTextFieldCell"; title = "iTunes Store:"; ObjectID = "M6p-MI-JS7"; */
|
||||
"M6p-MI-JS7.title" = "iTunes Store:";
|
||||
|
||||
/* Class = "NSTextFieldCell"; title = "This option one affects artwork displayed in TagTunes. Saved artwork will always have full resolution. Using this option may make searching faster but will also slow down saving."; ObjectID = "NMm-Jw-bon"; */
|
||||
"NMm-Jw-bon.title" = "Diese Einstellung ändert nur die Darstellung in TagTunes. Beim Speichern wird das Cover immer mit der höchsten Auflösung gesichert. Wenn diese Optiona aktiviert ist, werden Suchergebnisse schneller geladen, jedoch verlängert sich die Zeit beim Speichern.";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Services"; ObjectID = "NMo-om-nkz"; */
|
||||
"NMo-om-nkz.title" = "Dienste";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Minimize"; ObjectID = "OY7-WF-poV"; */
|
||||
"OY7-WF-poV.title" = "Im Dock ablegen";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Hide TagTunes"; ObjectID = "Olw-nP-bQN"; */
|
||||
"Olw-nP-bQN.title" = "TagTunes ausblenden";
|
||||
|
||||
/* Class = "NSToolbarItem"; label = "Save Artwork"; ObjectID = "PKp-tG-6Fu"; */
|
||||
"PKp-tG-6Fu.label" = "Cover sichern";
|
||||
|
||||
/* Class = "NSToolbarItem"; paletteLabel = "Save Artwork"; ObjectID = "PKp-tG-6Fu"; */
|
||||
"PKp-tG-6Fu.paletteLabel" = "Cover sichern";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Zoom"; ObjectID = "R4o-n2-Eq4"; */
|
||||
"R4o-n2-Eq4.title" = "Zoomen";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Select All"; ObjectID = "Ruw-6m-B2m"; */
|
||||
"Ruw-6m-B2m.title" = "Alles auswählen";
|
||||
|
||||
/* Class = "NSMenu"; title = "Window"; ObjectID = "Td7-aD-5lo"; */
|
||||
"Td7-aD-5lo.title" = "Fenster";
|
||||
|
||||
/* Class = "NSButtonCell"; title = "Case Sensitive"; ObjectID = "Ttb-GR-JAy"; */
|
||||
"Ttb-GR-JAy.title" = "Groß- und Kleinschreibung berücksichtigen";
|
||||
|
||||
/* Class = "NSButtonCell"; title = "Use Censored Names"; ObjectID = "UOJ-Ol-UjW"; */
|
||||
"UOJ-Ol-UjW.title" = "Zensierte Namen verwenden";
|
||||
|
||||
/* Class = "NSButtonCell"; title = "Clear Artworks"; ObjectID = "Upq-Aq-AbA"; */
|
||||
"Upq-Aq-AbA.title" = "Cover löschen";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Hide Others"; ObjectID = "Vdr-fp-XzO"; */
|
||||
"Vdr-fp-XzO.title" = "Andere ausblenden";
|
||||
|
||||
/* Class = "NSMenu"; title = "Edit"; ObjectID = "W48-6f-4Dl"; */
|
||||
"W48-6f-4Dl.title" = "Bearbeiten";
|
||||
|
||||
/* Class = "NSButtonCell"; title = "Overwrite existing files"; ObjectID = "WQN-Bj-me1"; */
|
||||
"WQN-Bj-me1.title" = "Existierende Dateien überschreiben";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "New"; ObjectID = "Was-JA-tGl"; */
|
||||
"Was-JA-tGl.title" = "Neu";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Paste and Match Style"; ObjectID = "WeT-3V-zwk"; */
|
||||
"WeT-3V-zwk.title" = "Einsetzen und Stil anpassen";
|
||||
|
||||
/* Class = "NSTextFieldCell"; title = "If selected, TagTunes will remove all artworks from saved tracks."; ObjectID = "Wg2-d3-xkK"; */
|
||||
"Wg2-d3-xkK.title" = "Wenn ausgewählt, löscht TagTunes die Cover von Songs beim Speichern.";
|
||||
|
||||
/* Class = "NSTextFieldCell"; title = "Search Results:"; ObjectID = "aTC-x8-iNw"; */
|
||||
"aTC-x8-iNw.title" = "Suchergebnisse:";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Print…"; ObjectID = "aTl-1u-JFS"; */
|
||||
"aTl-1u-JFS.title" = "Drucken…";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Window"; ObjectID = "aUF-d1-5bR"; */
|
||||
"aUF-d1-5bR.title" = "Fenster";
|
||||
|
||||
/* Class = "NSToolbarItem"; label = "Save"; ObjectID = "ax1-DR-t4v"; */
|
||||
"ax1-DR-t4v.label" = "Speichern";
|
||||
|
||||
/* Class = "NSToolbarItem"; paletteLabel = "Save to iTunes"; ObjectID = "ax1-DR-t4v"; */
|
||||
"ax1-DR-t4v.paletteLabel" = "In iTunes speichern";
|
||||
|
||||
/* Class = "NSMenu"; title = "File"; ObjectID = "bib-Uj-vzu"; */
|
||||
"bib-Uj-vzu.title" = "Ablage";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "File"; ObjectID = "dMs-cI-mzQ"; */
|
||||
"dMs-cI-mzQ.title" = "Ablage";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Undo"; ObjectID = "dRJ-4n-Yzg"; */
|
||||
"dRJ-4n-Yzg.title" = "Widerrufen";
|
||||
|
||||
/* Class = "BindingConnection"; ibShadowedIsNilPlaceholder = "Choose an artwork directory…"; ObjectID = "eFO-dQ-CMc"; */
|
||||
"eFO-dQ-CMc.ibShadowedIsNilPlaceholder" = "Wählen Sie einen Ordner…";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Paste"; ObjectID = "gVA-U4-sdL"; */
|
||||
"gVA-U4-sdL.title" = "Einsetzen";
|
||||
|
||||
/* Class = "NSButtonCell"; title = "Remove saved items"; ObjectID = "heo-fa-eoL"; */
|
||||
"heo-fa-eoL.title" = "Gespeicherte Objekte entfernen";
|
||||
|
||||
/* Class = "NSMenu"; title = "Services"; ObjectID = "hz9-B4-Xy5"; */
|
||||
"hz9-B4-Xy5.title" = "Dienste";
|
||||
|
||||
/* Class = "NSMenu"; title = "Open Recent"; ObjectID = "oas-Oc-fiZ"; */
|
||||
"oas-Oc-fiZ.title" = "Benutzte Dokumente";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Delete"; ObjectID = "pa3-QI-u2k"; */
|
||||
"pa3-QI-u2k.title" = "Löschem";
|
||||
|
||||
/* Class = "NSButtonCell"; title = "Use low resolution artwork"; ObjectID = "pp5-kZ-w5f"; */
|
||||
"pp5-kZ-w5f.title" = "Keine hochauflösenden Cover verwenden";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Save…"; ObjectID = "pxx-59-PXV"; */
|
||||
"pxx-59-PXV.title" = "Sichern…";
|
||||
|
||||
/* Class = "NSButtonCell"; title = "Automatically Save Artwork"; ObjectID = "q2l-4t-mL0"; */
|
||||
"q2l-4t-mL0.title" = "Cover automatisch sichern";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Page Setup…"; ObjectID = "qIS-W8-SiK"; */
|
||||
"qIS-W8-SiK.title" = "Papierformat…";
|
||||
|
||||
/* Class = "NSViewController"; title = "Tags"; ObjectID = "qlM-h3-Tfw"; */
|
||||
"qlM-h3-Tfw.title" = "Tags";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Show Toolbar"; ObjectID = "snW-S8-Cw5"; */
|
||||
"snW-S8-Cw5.title" = "Symbolleiste einblenden";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Open Recent"; ObjectID = "tXI-mr-wws"; */
|
||||
"tXI-mr-wws.title" = "Benutzte Dokumente";
|
||||
|
||||
/* Class = "NSViewController"; title = "General"; ObjectID = "tzd-4a-CRb"; */
|
||||
"tzd-4a-CRb.title" = "Allgemein";
|
||||
|
||||
/* Class = "NSButtonCell"; title = "Keep Search Results"; ObjectID = "uED-ee-Oc7"; */
|
||||
"uED-ee-Oc7.title" = "Suchergebnisse eingeblendet lassen";
|
||||
|
||||
/* Class = "NSMenu"; title = "TagTunes"; ObjectID = "uQy-DD-JDr"; */
|
||||
"uQy-DD-JDr.title" = "TagTunes";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Cut"; ObjectID = "uRl-iY-unG"; */
|
||||
"uRl-iY-unG.title" = "Ausschneiden";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Clear Menu"; ObjectID = "vNY-rz-j42"; */
|
||||
"vNY-rz-j42.title" = "Einträge löschen";
|
||||
|
||||
/* Class = "NSToolbarItem"; label = "Add Selection"; ObjectID = "vfK-go-3oR"; */
|
||||
"vfK-go-3oR.label" = "Auswahl einfügen";
|
||||
|
||||
/* Class = "NSToolbarItem"; paletteLabel = "Add iTunes Selection"; ObjectID = "vfK-go-3oR"; */
|
||||
"vfK-go-3oR.paletteLabel" = "Auswahl aus iTunes einfügen";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Help"; ObjectID = "wpr-3q-Mcd"; */
|
||||
"wpr-3q-Mcd.title" = "Hilfe";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Copy"; ObjectID = "x3v-GG-iWU"; */
|
||||
"x3v-GG-iWU.title" = "Kpieren";
|
||||
|
||||
/* Class = "NSTextFieldCell"; title = "There is some music that does not exist in all iTunes Stores. You can select a country to search the iTunes Store for that country."; ObjectID = "xMj-9f-8N6"; */
|
||||
"xMj-9f-8N6.title" = "Einige Songs gibt es nur in bestimmten iTunes Stores. TagTunes verwendet den iTunes Store des ausgewählten Landes.";
|
||||
|
||||
/* Class = "NSTextFieldCell"; title = "If selected, TagTunes will remove saved objects from the list."; ObjectID = "xtk-Fn-FCa"; */
|
||||
"xtk-Fn-FCa.title" = "Wenn ausgewählt, werden Objekte beim Speichern aus der Liste entfernt.";
|
||||
|
||||
/* Class = "NSButtonCell"; title = "Don't remove saved albums"; ObjectID = "yih-Me-SPj"; */
|
||||
"yih-Me-SPj.title" = "Alben beim Speichern nicht entfernen";
|
||||
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)")
|
||||
}
|
||||
|
||||
/// 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")!
|
||||
}
|
||||
}
|
||||
|
||||
/// 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"
|
||||